@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.js
CHANGED
|
@@ -831,6 +831,7 @@ __export(app_plugin_exports, {
|
|
|
831
831
|
collectBundleHooks: () => collectBundleHooks
|
|
832
832
|
});
|
|
833
833
|
import { wireAuthoredTranslationSync } from "@objectstack/core";
|
|
834
|
+
import { assertProtocolCompat } from "@objectstack/metadata-core";
|
|
834
835
|
import { resolveMultiOrgEnabled } from "@objectstack/types";
|
|
835
836
|
function collectBundleHooks(bundle) {
|
|
836
837
|
const out = [];
|
|
@@ -916,6 +917,7 @@ var init_app_plugin = __esm({
|
|
|
916
917
|
}
|
|
917
918
|
const sys = this.bundle.manifest || this.bundle;
|
|
918
919
|
const appId = sys.id || sys.name;
|
|
920
|
+
assertProtocolCompat(sys, void 0, (m) => ctx.logger.warn(`[AppPlugin] ${m}`));
|
|
919
921
|
ctx.logger.info("Registering App Service", {
|
|
920
922
|
appId,
|
|
921
923
|
pluginName: this.name,
|
|
@@ -1363,6 +1365,18 @@ var init_app_plugin = __esm({
|
|
|
1363
1365
|
const budget = new Promise((resolve) => {
|
|
1364
1366
|
timer = setTimeout(() => resolve("budget"), seedBudgetMs);
|
|
1365
1367
|
});
|
|
1368
|
+
const emitSeedSettled = (overBudget) => {
|
|
1369
|
+
const trigger = ctx.trigger;
|
|
1370
|
+
if (typeof trigger !== "function") return;
|
|
1371
|
+
try {
|
|
1372
|
+
const p = trigger.call(ctx, "app:seeded", { appId, overBudget });
|
|
1373
|
+
if (p && typeof p.catch === "function") {
|
|
1374
|
+
p.catch((err) => ctx.logger.debug("[Seeder] app:seeded trigger failed", { appId, error: err?.message ?? String(err) }));
|
|
1375
|
+
}
|
|
1376
|
+
} catch (err) {
|
|
1377
|
+
ctx.logger.debug("[Seeder] app:seeded trigger failed", { appId, error: err?.message ?? String(err) });
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1366
1380
|
const winner = await Promise.race([seedPromise.then(() => "done"), budget]);
|
|
1367
1381
|
if (timer) clearTimeout(timer);
|
|
1368
1382
|
if (winner === "budget") {
|
|
@@ -1371,7 +1385,9 @@ var init_app_plugin = __esm({
|
|
|
1371
1385
|
);
|
|
1372
1386
|
seedPromise.catch((err) => {
|
|
1373
1387
|
ctx.logger.warn("[Seeder] Background seed failed after budget", { appId, error: err?.message ?? String(err) });
|
|
1374
|
-
});
|
|
1388
|
+
}).then(() => emitSeedSettled(true));
|
|
1389
|
+
} else {
|
|
1390
|
+
emitSeedSettled(false);
|
|
1375
1391
|
}
|
|
1376
1392
|
}
|
|
1377
1393
|
}
|
|
@@ -2087,9 +2103,19 @@ function safeGet(ctx, name) {
|
|
|
2087
2103
|
|
|
2088
2104
|
// src/http-dispatcher.ts
|
|
2089
2105
|
init_package_state_store();
|
|
2090
|
-
import {
|
|
2106
|
+
import {
|
|
2107
|
+
getEnv,
|
|
2108
|
+
resolveLocale,
|
|
2109
|
+
evaluateAuthGate,
|
|
2110
|
+
isAuthGateAllowlisted,
|
|
2111
|
+
shouldDenyAnonymous,
|
|
2112
|
+
ANONYMOUS_DENY_STATUS,
|
|
2113
|
+
ANONYMOUS_DENY_CODE,
|
|
2114
|
+
ANONYMOUS_DENY_MESSAGE
|
|
2115
|
+
} from "@objectstack/core";
|
|
2091
2116
|
import { isMcpServerEnabled } from "@objectstack/types";
|
|
2092
2117
|
import { CoreServiceName } from "@objectstack/spec/system";
|
|
2118
|
+
import { readServiceSelfInfo } from "@objectstack/spec/api";
|
|
2093
2119
|
import { MCP_OAUTH_SCOPES } from "@objectstack/spec/ai";
|
|
2094
2120
|
import { pluralToSingular, PLURAL_TO_SINGULAR } from "@objectstack/spec/shared";
|
|
2095
2121
|
|
|
@@ -2101,6 +2127,9 @@ var ACTION_TO_API_METHOD = {
|
|
|
2101
2127
|
delete: "delete",
|
|
2102
2128
|
query: "list",
|
|
2103
2129
|
find: "list",
|
|
2130
|
+
// Aggregation is a list-class read: an object whose whitelist excludes
|
|
2131
|
+
// `list` must not leak row statistics through GROUP BY either.
|
|
2132
|
+
aggregate: "list",
|
|
2104
2133
|
batch: "bulk"
|
|
2105
2134
|
};
|
|
2106
2135
|
function checkApiExposure(def, action) {
|
|
@@ -2205,6 +2234,7 @@ async function resolveExecutionContext(opts) {
|
|
|
2205
2234
|
if (authz.email) ctx.email = authz.email;
|
|
2206
2235
|
if (authz.accessToken) ctx.accessToken = authz.accessToken;
|
|
2207
2236
|
if (authz.tabPermissions) ctx.tabPermissions = authz.tabPermissions;
|
|
2237
|
+
if (authz.posture) ctx.posture = authz.posture;
|
|
2208
2238
|
ctx.org_user_ids = authz.org_user_ids;
|
|
2209
2239
|
if (oauthPrincipal && ctx.userId === oauthPrincipal.userId) {
|
|
2210
2240
|
ctx.oauthScopes = oauthPrincipal.scopes;
|
|
@@ -2452,6 +2482,26 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2452
2482
|
}
|
|
2453
2483
|
throw { statusCode: 503, message: "Data service not available" };
|
|
2454
2484
|
}
|
|
2485
|
+
if (action === "aggregate") {
|
|
2486
|
+
if (!Array.isArray(params.aggregations) || params.aggregations.length === 0) {
|
|
2487
|
+
throw { statusCode: 400, message: "aggregate requires at least one aggregation" };
|
|
2488
|
+
}
|
|
2489
|
+
const engine = await this.getObjectQLService(scopeId) ?? await this.resolveService("objectql", scopeId).catch(() => null);
|
|
2490
|
+
if (engine && typeof engine.aggregate === "function") {
|
|
2491
|
+
const rows = await engine.aggregate(
|
|
2492
|
+
params.object,
|
|
2493
|
+
{
|
|
2494
|
+
...params.where ? { where: params.where } : {},
|
|
2495
|
+
...params.groupBy ? { groupBy: params.groupBy } : {},
|
|
2496
|
+
...params.aggregations ? { aggregations: params.aggregations } : {},
|
|
2497
|
+
...params.timezone ? { timezone: params.timezone } : {},
|
|
2498
|
+
...executionContext ? { context: executionContext } : {}
|
|
2499
|
+
}
|
|
2500
|
+
);
|
|
2501
|
+
return { object: params.object, rows: rows ?? [] };
|
|
2502
|
+
}
|
|
2503
|
+
throw { statusCode: 503, message: "Data service not available" };
|
|
2504
|
+
}
|
|
2455
2505
|
if (action === "batch") {
|
|
2456
2506
|
return { object: params.object, results: [] };
|
|
2457
2507
|
}
|
|
@@ -2735,31 +2785,45 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2735
2785
|
const res = await callData("get", { object, id }, driver, envId, ec);
|
|
2736
2786
|
return res?.record ?? res ?? null;
|
|
2737
2787
|
},
|
|
2788
|
+
aggregate: async (object, o) => {
|
|
2789
|
+
const res = await callData(
|
|
2790
|
+
"aggregate",
|
|
2791
|
+
{
|
|
2792
|
+
object,
|
|
2793
|
+
where: o?.where,
|
|
2794
|
+
groupBy: o?.groupBy,
|
|
2795
|
+
aggregations: o?.aggregations,
|
|
2796
|
+
timezone: o?.timezone
|
|
2797
|
+
},
|
|
2798
|
+
void 0,
|
|
2799
|
+
envId,
|
|
2800
|
+
ec
|
|
2801
|
+
);
|
|
2802
|
+
return res?.rows ?? [];
|
|
2803
|
+
},
|
|
2738
2804
|
create: async (object, data) => await callData("create", { object, data }, driver, envId, ec),
|
|
2739
2805
|
update: async (object, id, data) => await callData("update", { object, id, data }, driver, envId, ec),
|
|
2740
2806
|
remove: async (object, id) => await callData("delete", { object, id }, driver, envId, ec),
|
|
2741
2807
|
// ── Business-action surface (McpActionBridge) ──────────────
|
|
2742
2808
|
// Resolution + dispatch flow through the framework's own action
|
|
2743
|
-
// mechanism (engine.executeAction / automation flow runner)
|
|
2744
|
-
//
|
|
2745
|
-
// the
|
|
2809
|
+
// mechanism (engine.executeAction / automation flow runner). All
|
|
2810
|
+
// gating is at INVOKE time — `ai.exposed` (author opt-in, #2849) +
|
|
2811
|
+
// the ADR-0066 D4 capability gate + record load under the caller's
|
|
2812
|
+
// RLS. Script/body handlers then run TRUSTED (see
|
|
2813
|
+
// buildActionEngineFacade); flows honour `runAs` with the caller's
|
|
2814
|
+
// identity forwarded. No `@objectstack/service-ai`.
|
|
2746
2815
|
listActions: async () => {
|
|
2747
2816
|
const meta = await getMeta();
|
|
2748
|
-
const objs = await meta?.listObjects?.() ?? [];
|
|
2749
2817
|
const hasAutomation = Boolean(
|
|
2750
2818
|
await this.resolveService("automation", envId).catch(() => null)
|
|
2751
2819
|
);
|
|
2752
2820
|
const out = [];
|
|
2753
|
-
for (const obj of
|
|
2754
|
-
const objectName = obj?.name;
|
|
2821
|
+
for (const { action, objectName, obj } of await this.collectActionDeclarations(meta)) {
|
|
2755
2822
|
if (!objectName || isSystemObjectName(objectName)) continue;
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
if (this.actionPermissionError(action, ec)) continue;
|
|
2761
|
-
out.push(this.summarizeAction(action, obj, objectName));
|
|
2762
|
-
}
|
|
2823
|
+
if (!this.isHeadlessInvokableAction(action, hasAutomation)) continue;
|
|
2824
|
+
if (this.actionAiExposureError(action)) continue;
|
|
2825
|
+
if (this.actionPermissionError(action, ec)) continue;
|
|
2826
|
+
out.push(this.summarizeAction(action, obj, objectName));
|
|
2763
2827
|
}
|
|
2764
2828
|
return out;
|
|
2765
2829
|
},
|
|
@@ -2785,6 +2849,23 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2785
2849
|
const on = objectName ? ` on '${objectName}'` : "";
|
|
2786
2850
|
return `Action '${actionDef?.name ?? "unknown"}'${on} requires capability [${required.join(", ")}] \u2014 caller is missing [${missing.join(", ")}]`;
|
|
2787
2851
|
}
|
|
2852
|
+
/**
|
|
2853
|
+
* [#2849 / ADR-0011] AI-exposure gate for the MCP action surface. Returns a
|
|
2854
|
+
* human-readable error string unless the action's author explicitly opted it
|
|
2855
|
+
* into the AI surface with `ai.exposed: true`, or `null` when exposed.
|
|
2856
|
+
*
|
|
2857
|
+
* This gate is the REAL agent-facing boundary for actions: script/body
|
|
2858
|
+
* handlers execute as TRUSTED application code (the engine facade carries no
|
|
2859
|
+
* ExecutionContext — see {@link buildActionEngineFacade}), so once invoked, a
|
|
2860
|
+
* body's reads/writes are NOT bounded by the caller's RLS/FLS or an agent's
|
|
2861
|
+
* data ceiling (ADR-0090 D10). The author's explicit opt-in — not a data-layer
|
|
2862
|
+
* backstop — therefore decides what AI may trigger. Fail-closed by default.
|
|
2863
|
+
*/
|
|
2864
|
+
actionAiExposureError(actionDef, objectName) {
|
|
2865
|
+
if (actionDef?.ai?.exposed === true) return null;
|
|
2866
|
+
const on = objectName ? ` on '${objectName}'` : "";
|
|
2867
|
+
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 }\``;
|
|
2868
|
+
}
|
|
2788
2869
|
/**
|
|
2789
2870
|
* Whether an action has a headless invocation path (so MCP can run it).
|
|
2790
2871
|
* Mirrors the supported-type set of the (now cloud-side) action-tools
|
|
@@ -2867,7 +2948,18 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2867
2948
|
}
|
|
2868
2949
|
return out;
|
|
2869
2950
|
}
|
|
2870
|
-
/**
|
|
2951
|
+
/**
|
|
2952
|
+
* Slim engine facade matching the ActionContext.engine shape handlers expect.
|
|
2953
|
+
*
|
|
2954
|
+
* ⚠️ TRUSTED (SECURITY-DEFINER-like) BY DESIGN (#2849): these calls carry NO
|
|
2955
|
+
* ExecutionContext, so the data engine's security middleware skips RLS / FLS /
|
|
2956
|
+
* CRUD / tenant scoping entirely. Action bodies are the app author's own code
|
|
2957
|
+
* and legitimately perform cross-object writes the invoking user could not
|
|
2958
|
+
* (convert-lead, cascade-close). The boundary is therefore enforced at INVOKE
|
|
2959
|
+
* time (`ai.exposed` + ADR-0066 D4 capability gate), and every dispatch is
|
|
2960
|
+
* audit-logged. Longer-term direction: an action-level `runAs: 'user'|'system'`
|
|
2961
|
+
* mirroring flows (ADR-0049) — tracked in #2849.
|
|
2962
|
+
*/
|
|
2871
2963
|
buildActionEngineFacade(ql) {
|
|
2872
2964
|
return {
|
|
2873
2965
|
async insert(object, data) {
|
|
@@ -2895,11 +2987,19 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2895
2987
|
}
|
|
2896
2988
|
/**
|
|
2897
2989
|
* Resolve + invoke a business action by its declarative name for the MCP
|
|
2898
|
-
* `run_action` tool. Enforces the
|
|
2899
|
-
*
|
|
2900
|
-
* dispatches through the framework's
|
|
2901
|
-
* automation flow runner (flow).
|
|
2902
|
-
* failure so the tool surfaces a
|
|
2990
|
+
* `run_action` tool. Enforces the AI-exposure gate (`ai.exposed`, #2849), the
|
|
2991
|
+
* ADR-0066 D4 capability gate, loads the subject record under the caller's
|
|
2992
|
+
* RLS for row-context actions, and dispatches through the framework's
|
|
2993
|
+
* `engine.executeAction` (script/body) or automation flow runner (flow).
|
|
2994
|
+
* Throws on denial / not-found / handler failure so the tool surfaces a
|
|
2995
|
+
* clean tool-error. No service-ai dependency.
|
|
2996
|
+
*
|
|
2997
|
+
* SECURITY MODEL (#2849): all gating happens at INVOKE time. A script/body
|
|
2998
|
+
* handler then runs as trusted code — its engine facade performs
|
|
2999
|
+
* context-less reads/writes that bypass RLS/FLS (SECURITY-DEFINER-like), so
|
|
3000
|
+
* the caller's permissions and an agent's ADR-0090 D10 data ceiling do NOT
|
|
3001
|
+
* bound what the body does internally. Flow actions differ: the flow engine
|
|
3002
|
+
* receives the caller's identity below and honours `runAs` (ADR-0049).
|
|
2903
3003
|
*/
|
|
2904
3004
|
async invokeBusinessAction(name, input, wiring) {
|
|
2905
3005
|
const { driver, envId, ec, getMeta, callData } = wiring;
|
|
@@ -2922,6 +3022,8 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2922
3022
|
`Action '${name}' (type='${action?.type ?? "script"}') cannot be invoked via MCP`
|
|
2923
3023
|
);
|
|
2924
3024
|
}
|
|
3025
|
+
const exposureError = this.actionAiExposureError(action, objectName);
|
|
3026
|
+
if (exposureError) throw new Error(exposureError);
|
|
2925
3027
|
const gateError = this.actionPermissionError(action, ec, objectName);
|
|
2926
3028
|
if (gateError) throw new Error(gateError);
|
|
2927
3029
|
let record = {};
|
|
@@ -2940,7 +3042,15 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2940
3042
|
throw new Error(`Action '${name}' is a flow but no automation service is available`);
|
|
2941
3043
|
}
|
|
2942
3044
|
const result = await automation.execute(action.target, {
|
|
2943
|
-
|
|
3045
|
+
record,
|
|
3046
|
+
...objectName !== "global" ? { object: objectName } : {},
|
|
3047
|
+
userId: ec?.userId,
|
|
3048
|
+
...Array.isArray(ec?.positions) && ec.positions.length ? { positions: ec.positions } : {},
|
|
3049
|
+
...Array.isArray(ec?.permissions) && ec.permissions.length ? { permissions: ec.permissions } : {},
|
|
3050
|
+
...ec?.tenantId ? { tenantId: ec.tenantId } : {},
|
|
3051
|
+
// Record fields seed flows' named `isInput` variables (like the
|
|
3052
|
+
// record-change trigger); explicit action params win on clash.
|
|
3053
|
+
params: { ...record, ...params }
|
|
2944
3054
|
});
|
|
2945
3055
|
if (result && typeof result === "object" && "success" in result && result.success === false) {
|
|
2946
3056
|
throw new Error(`Flow '${action.target}' failed: ${result.error ?? "unknown error"}`);
|
|
@@ -2951,6 +3061,9 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2951
3061
|
if (!ql || typeof ql.executeAction !== "function") {
|
|
2952
3062
|
throw new Error("Data engine not available for action dispatch");
|
|
2953
3063
|
}
|
|
3064
|
+
console.info(
|
|
3065
|
+
`[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"}')` : "")
|
|
3066
|
+
);
|
|
2954
3067
|
const actionContext = {
|
|
2955
3068
|
record,
|
|
2956
3069
|
user,
|
|
@@ -2981,24 +3094,82 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2981
3094
|
* and no `objectName` was supplied (so `run_action` can ask for one).
|
|
2982
3095
|
*/
|
|
2983
3096
|
async resolveActionByName(meta, name, objectName) {
|
|
3097
|
+
const decls = await this.collectActionDeclarations(meta);
|
|
2984
3098
|
if (objectName) {
|
|
2985
|
-
const
|
|
2986
|
-
|
|
2987
|
-
return action ? { action, objectName } : null;
|
|
2988
|
-
}
|
|
2989
|
-
const objs = await meta?.listObjects?.() ?? [];
|
|
2990
|
-
const matches = [];
|
|
2991
|
-
for (const obj of objs) {
|
|
2992
|
-
if (!obj?.name) continue;
|
|
2993
|
-
const action = Array.isArray(obj?.actions) ? obj.actions.find((a) => a?.name === name) : void 0;
|
|
2994
|
-
if (action) matches.push({ action, objectName: obj.name });
|
|
3099
|
+
const hit = decls.find((d) => d.objectName === objectName && d.action?.name === name);
|
|
3100
|
+
return hit ? { action: hit.action, objectName } : null;
|
|
2995
3101
|
}
|
|
3102
|
+
const matches = decls.filter((d) => d.action?.name === name);
|
|
2996
3103
|
if (matches.length === 0) return null;
|
|
2997
3104
|
if (matches.length > 1) {
|
|
2998
3105
|
const where = matches.map((m) => m.objectName).join(", ");
|
|
2999
3106
|
throw new Error(`Action '${name}' exists on multiple objects (${where}); pass objectName to disambiguate`);
|
|
3000
3107
|
}
|
|
3001
|
-
return matches[0];
|
|
3108
|
+
return { action: matches[0].action, objectName: matches[0].objectName };
|
|
3109
|
+
}
|
|
3110
|
+
/**
|
|
3111
|
+
* The MCP surface's single declaration source: every action declaration the
|
|
3112
|
+
* bridge may list or invoke, as `{ action, objectName, obj }` rows.
|
|
3113
|
+
*
|
|
3114
|
+
* Two shapes feed it (#3010):
|
|
3115
|
+
* 1. `object.actions` — bundle/artifact objects and authored object rows.
|
|
3116
|
+
* 2. Standalone `action` metadata items — Studio-authored rows that the
|
|
3117
|
+
* engine executes since #2608 (`resyncAuthoredActions`) but that never
|
|
3118
|
+
* appear inside any object definition. Their owning object follows the
|
|
3119
|
+
* same convention as the engine registration key (`objectName` field,
|
|
3120
|
+
* legacy `object` field, else the `'global'` wildcard).
|
|
3121
|
+
*
|
|
3122
|
+
* On a key clash (`objectName:name`) the object-embedded declaration wins,
|
|
3123
|
+
* mirroring the execution layer's artifact-wins rule — `resyncAuthoredActions`
|
|
3124
|
+
* refuses to clobber an artifact-registered handler, so the embedded
|
|
3125
|
+
* declaration is the one that matches what actually runs. All MCP gating
|
|
3126
|
+
* (`ai.exposed`, ADR-0066 D4, headless-invokability) applies downstream of
|
|
3127
|
+
* this collection, unchanged.
|
|
3128
|
+
*/
|
|
3129
|
+
async collectActionDeclarations(meta) {
|
|
3130
|
+
const objs = await meta?.listObjects?.() ?? [];
|
|
3131
|
+
const objByName = /* @__PURE__ */ new Map();
|
|
3132
|
+
for (const obj of objs) {
|
|
3133
|
+
if (typeof obj?.name === "string") objByName.set(obj.name, obj);
|
|
3134
|
+
}
|
|
3135
|
+
const out = [];
|
|
3136
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3137
|
+
for (const obj of objs) {
|
|
3138
|
+
const objectName = obj?.name;
|
|
3139
|
+
if (!objectName) continue;
|
|
3140
|
+
for (const action of Array.isArray(obj?.actions) ? obj.actions : []) {
|
|
3141
|
+
if (!action || typeof action.name !== "string") continue;
|
|
3142
|
+
seen.add(`${objectName}:${action.name}`);
|
|
3143
|
+
out.push({ action, objectName, obj });
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
let standalone = [];
|
|
3147
|
+
try {
|
|
3148
|
+
standalone = await meta?.loadMany?.("action") ?? [];
|
|
3149
|
+
} catch {
|
|
3150
|
+
standalone = [];
|
|
3151
|
+
}
|
|
3152
|
+
for (const action of standalone) {
|
|
3153
|
+
if (!action || typeof action.name !== "string") continue;
|
|
3154
|
+
const objectName = this.standaloneActionObjectName(action);
|
|
3155
|
+
const key = `${objectName}:${action.name}`;
|
|
3156
|
+
if (seen.has(key)) continue;
|
|
3157
|
+
seen.add(key);
|
|
3158
|
+
out.push({ action, objectName, obj: objByName.get(objectName) });
|
|
3159
|
+
}
|
|
3160
|
+
return out;
|
|
3161
|
+
}
|
|
3162
|
+
/**
|
|
3163
|
+
* Owning object of a standalone `action` item — must stay in lockstep with
|
|
3164
|
+
* the ObjectQL plugin's `actionObjectKey` (the engine registration key), so
|
|
3165
|
+
* the declaration the MCP surface resolves is the one whose handler
|
|
3166
|
+
* `executeAction` will find: spec `objectName`, bundle-collector `object`,
|
|
3167
|
+
* else the `'global'` wildcard.
|
|
3168
|
+
*/
|
|
3169
|
+
standaloneActionObjectName(action) {
|
|
3170
|
+
if (typeof action?.objectName === "string" && action.objectName.length > 0) return action.objectName;
|
|
3171
|
+
if (typeof action?.object === "string" && action.object.length > 0) return action.object;
|
|
3172
|
+
return "global";
|
|
3002
3173
|
}
|
|
3003
3174
|
/**
|
|
3004
3175
|
* Generate a `sys_api_key` and return the raw secret EXACTLY ONCE
|
|
@@ -3267,7 +3438,6 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3267
3438
|
const hasAuth = !!authSvc;
|
|
3268
3439
|
const hasGraphQL = !!(graphqlSvc || this.kernel.graphql);
|
|
3269
3440
|
const hasSearch = !!searchSvc;
|
|
3270
|
-
const hasWebSockets = !!realtimeSvc;
|
|
3271
3441
|
const hasFiles = !!filesSvc;
|
|
3272
3442
|
const hasAnalytics = !!analyticsSvc;
|
|
3273
3443
|
const hasWorkflow = !!workflowSvc;
|
|
@@ -3290,7 +3460,12 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3290
3460
|
analytics: hasAnalytics ? `${prefix}/analytics` : void 0,
|
|
3291
3461
|
automation: hasAutomation ? `${prefix}/automation` : void 0,
|
|
3292
3462
|
workflow: hasWorkflow ? `${prefix}/workflow` : void 0,
|
|
3293
|
-
|
|
3463
|
+
// Never advertised (ADR-0076 D12, #2462): service-realtime is an
|
|
3464
|
+
// in-process pub/sub bus — the dispatcher has no /realtime branch
|
|
3465
|
+
// and no plugin mounts one, so an advertised route would 404.
|
|
3466
|
+
// Re-add only when a real HTTP/WS surface exists (and then it must
|
|
3467
|
+
// pass through the shouldDenyAnonymous gate, #2567).
|
|
3468
|
+
realtime: void 0,
|
|
3294
3469
|
notifications: hasNotification ? `${prefix}/notifications` : void 0,
|
|
3295
3470
|
ai: hasAi ? `${prefix}/ai` : void 0,
|
|
3296
3471
|
i18n: hasI18n ? `${prefix}/i18n` : void 0,
|
|
@@ -3299,19 +3474,27 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3299
3474
|
// out. The objectui Integrations page reads this.
|
|
3300
3475
|
mcp: _HttpDispatcher.isMcpEnabled() ? `${prefix}/mcp` : void 0
|
|
3301
3476
|
};
|
|
3302
|
-
const svcAvailable = (route, provider) =>
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3477
|
+
const svcAvailable = (route, provider, svc) => {
|
|
3478
|
+
const self = svc ? readServiceSelfInfo(svc) : void 0;
|
|
3479
|
+
if (self) {
|
|
3480
|
+
return {
|
|
3481
|
+
enabled: true,
|
|
3482
|
+
status: self.status,
|
|
3483
|
+
handlerReady: self.handlerReady ?? false,
|
|
3484
|
+
route,
|
|
3485
|
+
provider,
|
|
3486
|
+
message: self.message
|
|
3487
|
+
};
|
|
3488
|
+
}
|
|
3489
|
+
return { enabled: true, status: "available", handlerReady: true, route, provider };
|
|
3490
|
+
};
|
|
3309
3491
|
const svcUnavailable = (name) => ({
|
|
3310
3492
|
enabled: false,
|
|
3311
3493
|
status: "unavailable",
|
|
3312
3494
|
handlerReady: false,
|
|
3313
3495
|
message: `Install a ${name} plugin to enable`
|
|
3314
3496
|
});
|
|
3497
|
+
const realtimeSelf = realtimeSvc ? readServiceSelfInfo(realtimeSvc) : void 0;
|
|
3315
3498
|
let locale = { default: "en", supported: ["en"], timezone: "UTC" };
|
|
3316
3499
|
if (hasI18n && i18nSvc) {
|
|
3317
3500
|
const defaultLocale = typeof i18nSvc.getDefaultLocale === "function" ? i18nSvc.getDefaultLocale() : "en";
|
|
@@ -3332,7 +3515,10 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3332
3515
|
features: {
|
|
3333
3516
|
graphql: hasGraphQL,
|
|
3334
3517
|
search: hasSearch,
|
|
3335
|
-
|
|
3518
|
+
// No WS/HTTP realtime surface is mounted anywhere — a mere
|
|
3519
|
+
// in-process realtime service must not advertise websockets
|
|
3520
|
+
// (ADR-0076 D12, #2462).
|
|
3521
|
+
websockets: false,
|
|
3336
3522
|
files: hasFiles,
|
|
3337
3523
|
analytics: hasAnalytics,
|
|
3338
3524
|
ai: hasAi,
|
|
@@ -3345,21 +3531,31 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3345
3531
|
metadata: { enabled: true, status: "degraded", handlerReady: true, route: routes.metadata, provider: "kernel", message: "In-memory registry; DB persistence pending" },
|
|
3346
3532
|
data: svcAvailable(routes.data, "kernel"),
|
|
3347
3533
|
// Plugin-provided — only available when a plugin registers the service
|
|
3348
|
-
auth: hasAuth ? svcAvailable(routes.auth) : svcUnavailable("auth"),
|
|
3349
|
-
automation: hasAutomation ? svcAvailable(routes.automation) : svcUnavailable("automation"),
|
|
3350
|
-
analytics: hasAnalytics ? svcAvailable(routes.analytics) : svcUnavailable("analytics"),
|
|
3351
|
-
cache: hasCache ? svcAvailable() : svcUnavailable("cache"),
|
|
3352
|
-
queue: hasQueue ? svcAvailable() : svcUnavailable("queue"),
|
|
3353
|
-
job: hasJob ? svcAvailable() : svcUnavailable("job"),
|
|
3354
|
-
ui: hasUi ? svcAvailable(routes.ui) : svcUnavailable("ui"),
|
|
3355
|
-
workflow: hasWorkflow ? svcAvailable(routes.workflow) : svcUnavailable("workflow"),
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3534
|
+
auth: hasAuth ? svcAvailable(routes.auth, void 0, authSvc) : svcUnavailable("auth"),
|
|
3535
|
+
automation: hasAutomation ? svcAvailable(routes.automation, void 0, automationSvc) : svcUnavailable("automation"),
|
|
3536
|
+
analytics: hasAnalytics ? svcAvailable(routes.analytics, void 0, analyticsSvc) : svcUnavailable("analytics"),
|
|
3537
|
+
cache: hasCache ? svcAvailable(void 0, void 0, cacheSvc) : svcUnavailable("cache"),
|
|
3538
|
+
queue: hasQueue ? svcAvailable(void 0, void 0, queueSvc) : svcUnavailable("queue"),
|
|
3539
|
+
job: hasJob ? svcAvailable(void 0, void 0, jobSvc) : svcUnavailable("job"),
|
|
3540
|
+
ui: hasUi ? svcAvailable(routes.ui, void 0, uiSvc) : svcUnavailable("ui"),
|
|
3541
|
+
workflow: hasWorkflow ? svcAvailable(routes.workflow, void 0, workflowSvc) : svcUnavailable("workflow"),
|
|
3542
|
+
// Honest entry (ADR-0076 D12, #2462): the registered realtime
|
|
3543
|
+
// service is an in-process event bus with NO mounted HTTP/WS
|
|
3544
|
+
// surface — report it degraded with handlerReady:false (or as
|
|
3545
|
+
// the stub it declares itself to be), never as an available
|
|
3546
|
+
// HTTP capability with a route that would 404.
|
|
3547
|
+
realtime: realtimeSvc ? {
|
|
3548
|
+
enabled: true,
|
|
3549
|
+
status: realtimeSelf?.status ?? "degraded",
|
|
3550
|
+
handlerReady: false,
|
|
3551
|
+
message: realtimeSelf?.message ?? "In-process event bus only \u2014 no HTTP/WS realtime surface is mounted"
|
|
3552
|
+
} : svcUnavailable("realtime"),
|
|
3553
|
+
notification: hasNotification ? svcAvailable(routes.notifications, void 0, notificationSvc) : svcUnavailable("notification"),
|
|
3554
|
+
ai: hasAi ? svcAvailable(routes.ai, void 0, aiSvc) : svcUnavailable("ai"),
|
|
3555
|
+
i18n: hasI18n ? svcAvailable(routes.i18n, void 0, i18nSvc) : svcUnavailable("i18n"),
|
|
3556
|
+
graphql: hasGraphQL ? svcAvailable(routes.graphql, void 0, graphqlSvc) : svcUnavailable("graphql"),
|
|
3557
|
+
"file-storage": hasFiles ? svcAvailable(routes.storage, void 0, filesSvc) : svcUnavailable("file-storage"),
|
|
3558
|
+
search: hasSearch ? svcAvailable(void 0, void 0, searchSvc) : svcUnavailable("search")
|
|
3363
3559
|
},
|
|
3364
3560
|
locale
|
|
3365
3561
|
};
|
|
@@ -3371,13 +3567,58 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3371
3567
|
if (!body || !body.query) {
|
|
3372
3568
|
throw { statusCode: 400, message: "Missing query in request body" };
|
|
3373
3569
|
}
|
|
3570
|
+
let ec = context.executionContext;
|
|
3571
|
+
if (!ec) {
|
|
3572
|
+
ec = await this.resolveRequestExecutionContext(context);
|
|
3573
|
+
if (ec) context.executionContext = ec;
|
|
3574
|
+
}
|
|
3575
|
+
if (shouldDenyAnonymous({ requireAuth: this.requireAuth, userId: ec?.userId, isSystem: ec?.isSystem })) {
|
|
3576
|
+
throw {
|
|
3577
|
+
statusCode: ANONYMOUS_DENY_STATUS,
|
|
3578
|
+
message: ANONYMOUS_DENY_MESSAGE,
|
|
3579
|
+
code: ANONYMOUS_DENY_CODE
|
|
3580
|
+
};
|
|
3581
|
+
}
|
|
3374
3582
|
if (typeof this.kernel.graphql !== "function") {
|
|
3375
3583
|
throw { statusCode: 501, message: "GraphQL service not available" };
|
|
3376
3584
|
}
|
|
3377
3585
|
return this.kernel.graphql(body.query, body.variables, {
|
|
3378
|
-
request: context.request
|
|
3586
|
+
request: context.request,
|
|
3587
|
+
context: ec
|
|
3379
3588
|
});
|
|
3380
3589
|
}
|
|
3590
|
+
/**
|
|
3591
|
+
* Resolve the RBAC/RLS execution context for a request that did NOT flow
|
|
3592
|
+
* through {@link dispatch} (which resolves and caches it on
|
|
3593
|
+
* `context.executionContext`). The dispatcher-plugin's direct `/graphql`
|
|
3594
|
+
* route is the current caller. Mirrors the identity resolution `dispatch()`
|
|
3595
|
+
* performs so an anonymous-deny gate can tell an authenticated caller from
|
|
3596
|
+
* an anonymous one, and so the caller's identity can be THREADED to the
|
|
3597
|
+
* engine (#2992 / ADR-0096 D1) instead of dropped. Best-effort: returns
|
|
3598
|
+
* `undefined` on failure (treated as anonymous, i.e. denied under
|
|
3599
|
+
* `requireAuth`).
|
|
3600
|
+
*/
|
|
3601
|
+
async resolveRequestExecutionContext(context) {
|
|
3602
|
+
try {
|
|
3603
|
+
return await resolveExecutionContext({
|
|
3604
|
+
getService: (n) => this.resolveService(n, context.environmentId),
|
|
3605
|
+
getQl: async () => {
|
|
3606
|
+
const k = this.kernel;
|
|
3607
|
+
if (k && typeof k.getServiceAsync === "function") {
|
|
3608
|
+
const ql = await k.getServiceAsync("objectql").catch(() => void 0);
|
|
3609
|
+
if (ql && (ql.registry || typeof ql.find === "function")) return ql;
|
|
3610
|
+
}
|
|
3611
|
+
return this.getObjectQLService(context.environmentId);
|
|
3612
|
+
},
|
|
3613
|
+
request: context.request,
|
|
3614
|
+
// OAuth access tokens are honoured only on the MCP surface
|
|
3615
|
+
// (#2698); GraphQL enforces no per-scope tool gating.
|
|
3616
|
+
acceptOAuthAccessToken: false
|
|
3617
|
+
});
|
|
3618
|
+
} catch {
|
|
3619
|
+
return void 0;
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3381
3622
|
/**
|
|
3382
3623
|
* Handles Auth requests
|
|
3383
3624
|
* path: sub-path after /auth/
|
|
@@ -3445,12 +3686,12 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3445
3686
|
* Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object)
|
|
3446
3687
|
*/
|
|
3447
3688
|
async handleMetadata(path, _context, method, body, query) {
|
|
3448
|
-
|
|
3689
|
+
{
|
|
3449
3690
|
const ec = _context.executionContext;
|
|
3450
|
-
if (
|
|
3691
|
+
if (shouldDenyAnonymous({ requireAuth: this.requireAuth, userId: ec?.userId, isSystem: ec?.isSystem })) {
|
|
3451
3692
|
return {
|
|
3452
3693
|
handled: true,
|
|
3453
|
-
response: this.error(
|
|
3694
|
+
response: this.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE })
|
|
3454
3695
|
};
|
|
3455
3696
|
}
|
|
3456
3697
|
}
|
|
@@ -3832,8 +4073,11 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3832
4073
|
return { handled: true, response: this.error("Security service not available", 503) };
|
|
3833
4074
|
}
|
|
3834
4075
|
const ec = context.executionContext;
|
|
3835
|
-
if (
|
|
3836
|
-
return {
|
|
4076
|
+
if (shouldDenyAnonymous({ requireAuth: true, userId: ec?.userId, isSystem: ec?.isSystem })) {
|
|
4077
|
+
return {
|
|
4078
|
+
handled: true,
|
|
4079
|
+
response: this.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE })
|
|
4080
|
+
};
|
|
3837
4081
|
}
|
|
3838
4082
|
const m = method.toUpperCase();
|
|
3839
4083
|
const parts = path.split("/").filter(Boolean);
|
|
@@ -3956,6 +4200,17 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3956
4200
|
}
|
|
3957
4201
|
if (parts.length === 0 && m === "POST") {
|
|
3958
4202
|
const manifest = body.manifest || body;
|
|
4203
|
+
const pkgId = typeof manifest?.id === "string" ? manifest.id.trim() : "";
|
|
4204
|
+
if (!pkgId) {
|
|
4205
|
+
return { handled: true, response: this.error("Package id is required", 400) };
|
|
4206
|
+
}
|
|
4207
|
+
const overwrite = body?.overwrite === true || query?.overwrite === "true" || query?.overwrite === true;
|
|
4208
|
+
if (!overwrite && registry.getPackage(pkgId)) {
|
|
4209
|
+
return {
|
|
4210
|
+
handled: true,
|
|
4211
|
+
response: this.error(`Package '${pkgId}' already exists`, 409)
|
|
4212
|
+
};
|
|
4213
|
+
}
|
|
3959
4214
|
let pkg;
|
|
3960
4215
|
const protocolSvc = await this.resolveService("protocol").catch(() => null);
|
|
3961
4216
|
if (protocolSvc && typeof protocolSvc.installPackage === "function") {
|
|
@@ -4883,6 +5138,9 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4883
5138
|
engine: engineFacade,
|
|
4884
5139
|
params: { ...reqParams, recordId, objectName }
|
|
4885
5140
|
};
|
|
5141
|
+
console.info(
|
|
5142
|
+
`[action-audit] REST action '${objectName}/${actionName}' \u2014 body executes TRUSTED (context-less engine, RLS/FLS-bypassing) for user '${userFromAuth.id}'`
|
|
5143
|
+
);
|
|
4886
5144
|
try {
|
|
4887
5145
|
let result;
|
|
4888
5146
|
try {
|
|
@@ -4955,12 +5213,12 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4955
5213
|
if (route.method !== method) continue;
|
|
4956
5214
|
const params = matchRoute(route.path, fullPath);
|
|
4957
5215
|
if (params === null) continue;
|
|
4958
|
-
if (
|
|
5216
|
+
if (route.auth !== false) {
|
|
4959
5217
|
const gec = context.executionContext;
|
|
4960
|
-
if (
|
|
5218
|
+
if (shouldDenyAnonymous({ requireAuth: this.requireAuth, userId: gec?.userId, isSystem: gec?.isSystem })) {
|
|
4961
5219
|
return {
|
|
4962
5220
|
handled: true,
|
|
4963
|
-
response: this.error(
|
|
5221
|
+
response: this.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE })
|
|
4964
5222
|
};
|
|
4965
5223
|
}
|
|
4966
5224
|
}
|
|
@@ -5900,7 +6158,12 @@ function createDispatcherPlugin(config = {}) {
|
|
|
5900
6158
|
if (!server) return;
|
|
5901
6159
|
const kernel = ctx.getKernel();
|
|
5902
6160
|
const enforceMembership = config.enforceProjectMembership ?? (config.scoping?.enableProjectScoping ?? false);
|
|
5903
|
-
const requireAuth = config.requireAuth ?? config.scoping?.requireAuth ??
|
|
6161
|
+
const requireAuth = config.requireAuth ?? config.scoping?.requireAuth ?? true;
|
|
6162
|
+
if (!requireAuth) {
|
|
6163
|
+
ctx.logger?.warn?.(
|
|
6164
|
+
"[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)."
|
|
6165
|
+
);
|
|
6166
|
+
}
|
|
5904
6167
|
const dispatcher = new HttpDispatcher(kernel, void 0, {
|
|
5905
6168
|
enforceProjectMembership: enforceMembership,
|
|
5906
6169
|
requireAuth
|
|
@@ -6087,6 +6350,14 @@ function createDispatcherPlugin(config = {}) {
|
|
|
6087
6350
|
errorResponse(err, res);
|
|
6088
6351
|
}
|
|
6089
6352
|
});
|
|
6353
|
+
server.patch(`${prefix}/packages/:id`, async (req, res) => {
|
|
6354
|
+
try {
|
|
6355
|
+
const result = await dispatcher.handlePackages(`/${req.params.id}`, "PATCH", req.body, req.query, { request: req });
|
|
6356
|
+
sendResult(result, res);
|
|
6357
|
+
} catch (err) {
|
|
6358
|
+
errorResponse(err, res);
|
|
6359
|
+
}
|
|
6360
|
+
});
|
|
6090
6361
|
server.patch(`${prefix}/packages/:id/enable`, async (req, res) => {
|
|
6091
6362
|
try {
|
|
6092
6363
|
const result = await dispatcher.handlePackages(`/${req.params.id}/enable`, "PATCH", {}, {}, { request: req });
|