@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.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,
|
|
@@ -1032,6 +1034,10 @@ var init_app_plugin = __esm({
|
|
|
1032
1034
|
const SECURITY_FIELDS = [
|
|
1033
1035
|
["positions", "position"],
|
|
1034
1036
|
["permissions", "permission"],
|
|
1037
|
+
// [ADR-0066 D1] Package-declared authorization capabilities —
|
|
1038
|
+
// read back by bootstrapDeclaredCapabilities to seed
|
|
1039
|
+
// sys_capability with package provenance.
|
|
1040
|
+
["capabilities", "capability"],
|
|
1035
1041
|
["sharingRules", "sharing_rule"],
|
|
1036
1042
|
["policies", "policy"]
|
|
1037
1043
|
];
|
|
@@ -1359,6 +1365,18 @@ var init_app_plugin = __esm({
|
|
|
1359
1365
|
const budget = new Promise((resolve) => {
|
|
1360
1366
|
timer = setTimeout(() => resolve("budget"), seedBudgetMs);
|
|
1361
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
|
+
};
|
|
1362
1380
|
const winner = await Promise.race([seedPromise.then(() => "done"), budget]);
|
|
1363
1381
|
if (timer) clearTimeout(timer);
|
|
1364
1382
|
if (winner === "budget") {
|
|
@@ -1367,7 +1385,9 @@ var init_app_plugin = __esm({
|
|
|
1367
1385
|
);
|
|
1368
1386
|
seedPromise.catch((err) => {
|
|
1369
1387
|
ctx.logger.warn("[Seeder] Background seed failed after budget", { appId, error: err?.message ?? String(err) });
|
|
1370
|
-
});
|
|
1388
|
+
}).then(() => emitSeedSettled(true));
|
|
1389
|
+
} else {
|
|
1390
|
+
emitSeedSettled(false);
|
|
1371
1391
|
}
|
|
1372
1392
|
}
|
|
1373
1393
|
}
|
|
@@ -2083,9 +2103,19 @@ function safeGet(ctx, name) {
|
|
|
2083
2103
|
|
|
2084
2104
|
// src/http-dispatcher.ts
|
|
2085
2105
|
init_package_state_store();
|
|
2086
|
-
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";
|
|
2087
2116
|
import { isMcpServerEnabled } from "@objectstack/types";
|
|
2088
2117
|
import { CoreServiceName } from "@objectstack/spec/system";
|
|
2118
|
+
import { readServiceSelfInfo } from "@objectstack/spec/api";
|
|
2089
2119
|
import { MCP_OAUTH_SCOPES } from "@objectstack/spec/ai";
|
|
2090
2120
|
import { pluralToSingular, PLURAL_TO_SINGULAR } from "@objectstack/spec/shared";
|
|
2091
2121
|
|
|
@@ -2097,6 +2127,9 @@ var ACTION_TO_API_METHOD = {
|
|
|
2097
2127
|
delete: "delete",
|
|
2098
2128
|
query: "list",
|
|
2099
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",
|
|
2100
2133
|
batch: "bulk"
|
|
2101
2134
|
};
|
|
2102
2135
|
function checkApiExposure(def, action) {
|
|
@@ -2201,6 +2234,7 @@ async function resolveExecutionContext(opts) {
|
|
|
2201
2234
|
if (authz.email) ctx.email = authz.email;
|
|
2202
2235
|
if (authz.accessToken) ctx.accessToken = authz.accessToken;
|
|
2203
2236
|
if (authz.tabPermissions) ctx.tabPermissions = authz.tabPermissions;
|
|
2237
|
+
if (authz.posture) ctx.posture = authz.posture;
|
|
2204
2238
|
ctx.org_user_ids = authz.org_user_ids;
|
|
2205
2239
|
if (oauthPrincipal && ctx.userId === oauthPrincipal.userId) {
|
|
2206
2240
|
ctx.oauthScopes = oauthPrincipal.scopes;
|
|
@@ -2448,6 +2482,26 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2448
2482
|
}
|
|
2449
2483
|
throw { statusCode: 503, message: "Data service not available" };
|
|
2450
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
|
+
}
|
|
2451
2505
|
if (action === "batch") {
|
|
2452
2506
|
return { object: params.object, results: [] };
|
|
2453
2507
|
}
|
|
@@ -2731,31 +2785,45 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2731
2785
|
const res = await callData("get", { object, id }, driver, envId, ec);
|
|
2732
2786
|
return res?.record ?? res ?? null;
|
|
2733
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
|
+
},
|
|
2734
2804
|
create: async (object, data) => await callData("create", { object, data }, driver, envId, ec),
|
|
2735
2805
|
update: async (object, id, data) => await callData("update", { object, id, data }, driver, envId, ec),
|
|
2736
2806
|
remove: async (object, id) => await callData("delete", { object, id }, driver, envId, ec),
|
|
2737
2807
|
// ── Business-action surface (McpActionBridge) ──────────────
|
|
2738
2808
|
// Resolution + dispatch flow through the framework's own action
|
|
2739
|
-
// mechanism (engine.executeAction / automation flow runner)
|
|
2740
|
-
//
|
|
2741
|
-
// 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`.
|
|
2742
2815
|
listActions: async () => {
|
|
2743
2816
|
const meta = await getMeta();
|
|
2744
|
-
const objs = await meta?.listObjects?.() ?? [];
|
|
2745
2817
|
const hasAutomation = Boolean(
|
|
2746
2818
|
await this.resolveService("automation", envId).catch(() => null)
|
|
2747
2819
|
);
|
|
2748
2820
|
const out = [];
|
|
2749
|
-
for (const obj of
|
|
2750
|
-
const objectName = obj?.name;
|
|
2821
|
+
for (const { action, objectName, obj } of await this.collectActionDeclarations(meta)) {
|
|
2751
2822
|
if (!objectName || isSystemObjectName(objectName)) continue;
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
if (this.actionPermissionError(action, ec)) continue;
|
|
2757
|
-
out.push(this.summarizeAction(action, obj, objectName));
|
|
2758
|
-
}
|
|
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));
|
|
2759
2827
|
}
|
|
2760
2828
|
return out;
|
|
2761
2829
|
},
|
|
@@ -2781,6 +2849,23 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2781
2849
|
const on = objectName ? ` on '${objectName}'` : "";
|
|
2782
2850
|
return `Action '${actionDef?.name ?? "unknown"}'${on} requires capability [${required.join(", ")}] \u2014 caller is missing [${missing.join(", ")}]`;
|
|
2783
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
|
+
}
|
|
2784
2869
|
/**
|
|
2785
2870
|
* Whether an action has a headless invocation path (so MCP can run it).
|
|
2786
2871
|
* Mirrors the supported-type set of the (now cloud-side) action-tools
|
|
@@ -2863,7 +2948,18 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2863
2948
|
}
|
|
2864
2949
|
return out;
|
|
2865
2950
|
}
|
|
2866
|
-
/**
|
|
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
|
+
*/
|
|
2867
2963
|
buildActionEngineFacade(ql) {
|
|
2868
2964
|
return {
|
|
2869
2965
|
async insert(object, data) {
|
|
@@ -2891,11 +2987,19 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2891
2987
|
}
|
|
2892
2988
|
/**
|
|
2893
2989
|
* Resolve + invoke a business action by its declarative name for the MCP
|
|
2894
|
-
* `run_action` tool. Enforces the
|
|
2895
|
-
*
|
|
2896
|
-
* dispatches through the framework's
|
|
2897
|
-
* automation flow runner (flow).
|
|
2898
|
-
* 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).
|
|
2899
3003
|
*/
|
|
2900
3004
|
async invokeBusinessAction(name, input, wiring) {
|
|
2901
3005
|
const { driver, envId, ec, getMeta, callData } = wiring;
|
|
@@ -2918,6 +3022,8 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2918
3022
|
`Action '${name}' (type='${action?.type ?? "script"}') cannot be invoked via MCP`
|
|
2919
3023
|
);
|
|
2920
3024
|
}
|
|
3025
|
+
const exposureError = this.actionAiExposureError(action, objectName);
|
|
3026
|
+
if (exposureError) throw new Error(exposureError);
|
|
2921
3027
|
const gateError = this.actionPermissionError(action, ec, objectName);
|
|
2922
3028
|
if (gateError) throw new Error(gateError);
|
|
2923
3029
|
let record = {};
|
|
@@ -2936,7 +3042,15 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2936
3042
|
throw new Error(`Action '${name}' is a flow but no automation service is available`);
|
|
2937
3043
|
}
|
|
2938
3044
|
const result = await automation.execute(action.target, {
|
|
2939
|
-
|
|
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 }
|
|
2940
3054
|
});
|
|
2941
3055
|
if (result && typeof result === "object" && "success" in result && result.success === false) {
|
|
2942
3056
|
throw new Error(`Flow '${action.target}' failed: ${result.error ?? "unknown error"}`);
|
|
@@ -2947,6 +3061,9 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2947
3061
|
if (!ql || typeof ql.executeAction !== "function") {
|
|
2948
3062
|
throw new Error("Data engine not available for action dispatch");
|
|
2949
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
|
+
);
|
|
2950
3067
|
const actionContext = {
|
|
2951
3068
|
record,
|
|
2952
3069
|
user,
|
|
@@ -2977,24 +3094,82 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
2977
3094
|
* and no `objectName` was supplied (so `run_action` can ask for one).
|
|
2978
3095
|
*/
|
|
2979
3096
|
async resolveActionByName(meta, name, objectName) {
|
|
3097
|
+
const decls = await this.collectActionDeclarations(meta);
|
|
2980
3098
|
if (objectName) {
|
|
2981
|
-
const
|
|
2982
|
-
|
|
2983
|
-
return action ? { action, objectName } : null;
|
|
2984
|
-
}
|
|
2985
|
-
const objs = await meta?.listObjects?.() ?? [];
|
|
2986
|
-
const matches = [];
|
|
2987
|
-
for (const obj of objs) {
|
|
2988
|
-
if (!obj?.name) continue;
|
|
2989
|
-
const action = Array.isArray(obj?.actions) ? obj.actions.find((a) => a?.name === name) : void 0;
|
|
2990
|
-
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;
|
|
2991
3101
|
}
|
|
3102
|
+
const matches = decls.filter((d) => d.action?.name === name);
|
|
2992
3103
|
if (matches.length === 0) return null;
|
|
2993
3104
|
if (matches.length > 1) {
|
|
2994
3105
|
const where = matches.map((m) => m.objectName).join(", ");
|
|
2995
3106
|
throw new Error(`Action '${name}' exists on multiple objects (${where}); pass objectName to disambiguate`);
|
|
2996
3107
|
}
|
|
2997
|
-
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";
|
|
2998
3173
|
}
|
|
2999
3174
|
/**
|
|
3000
3175
|
* Generate a `sys_api_key` and return the raw secret EXACTLY ONCE
|
|
@@ -3263,7 +3438,6 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3263
3438
|
const hasAuth = !!authSvc;
|
|
3264
3439
|
const hasGraphQL = !!(graphqlSvc || this.kernel.graphql);
|
|
3265
3440
|
const hasSearch = !!searchSvc;
|
|
3266
|
-
const hasWebSockets = !!realtimeSvc;
|
|
3267
3441
|
const hasFiles = !!filesSvc;
|
|
3268
3442
|
const hasAnalytics = !!analyticsSvc;
|
|
3269
3443
|
const hasWorkflow = !!workflowSvc;
|
|
@@ -3286,7 +3460,12 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3286
3460
|
analytics: hasAnalytics ? `${prefix}/analytics` : void 0,
|
|
3287
3461
|
automation: hasAutomation ? `${prefix}/automation` : void 0,
|
|
3288
3462
|
workflow: hasWorkflow ? `${prefix}/workflow` : void 0,
|
|
3289
|
-
|
|
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,
|
|
3290
3469
|
notifications: hasNotification ? `${prefix}/notifications` : void 0,
|
|
3291
3470
|
ai: hasAi ? `${prefix}/ai` : void 0,
|
|
3292
3471
|
i18n: hasI18n ? `${prefix}/i18n` : void 0,
|
|
@@ -3295,19 +3474,27 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3295
3474
|
// out. The objectui Integrations page reads this.
|
|
3296
3475
|
mcp: _HttpDispatcher.isMcpEnabled() ? `${prefix}/mcp` : void 0
|
|
3297
3476
|
};
|
|
3298
|
-
const svcAvailable = (route, provider) =>
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
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
|
+
};
|
|
3305
3491
|
const svcUnavailable = (name) => ({
|
|
3306
3492
|
enabled: false,
|
|
3307
3493
|
status: "unavailable",
|
|
3308
3494
|
handlerReady: false,
|
|
3309
3495
|
message: `Install a ${name} plugin to enable`
|
|
3310
3496
|
});
|
|
3497
|
+
const realtimeSelf = realtimeSvc ? readServiceSelfInfo(realtimeSvc) : void 0;
|
|
3311
3498
|
let locale = { default: "en", supported: ["en"], timezone: "UTC" };
|
|
3312
3499
|
if (hasI18n && i18nSvc) {
|
|
3313
3500
|
const defaultLocale = typeof i18nSvc.getDefaultLocale === "function" ? i18nSvc.getDefaultLocale() : "en";
|
|
@@ -3328,7 +3515,10 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3328
3515
|
features: {
|
|
3329
3516
|
graphql: hasGraphQL,
|
|
3330
3517
|
search: hasSearch,
|
|
3331
|
-
|
|
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,
|
|
3332
3522
|
files: hasFiles,
|
|
3333
3523
|
analytics: hasAnalytics,
|
|
3334
3524
|
ai: hasAi,
|
|
@@ -3341,21 +3531,31 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3341
3531
|
metadata: { enabled: true, status: "degraded", handlerReady: true, route: routes.metadata, provider: "kernel", message: "In-memory registry; DB persistence pending" },
|
|
3342
3532
|
data: svcAvailable(routes.data, "kernel"),
|
|
3343
3533
|
// Plugin-provided — only available when a plugin registers the service
|
|
3344
|
-
auth: hasAuth ? svcAvailable(routes.auth) : svcUnavailable("auth"),
|
|
3345
|
-
automation: hasAutomation ? svcAvailable(routes.automation) : svcUnavailable("automation"),
|
|
3346
|
-
analytics: hasAnalytics ? svcAvailable(routes.analytics) : svcUnavailable("analytics"),
|
|
3347
|
-
cache: hasCache ? svcAvailable() : svcUnavailable("cache"),
|
|
3348
|
-
queue: hasQueue ? svcAvailable() : svcUnavailable("queue"),
|
|
3349
|
-
job: hasJob ? svcAvailable() : svcUnavailable("job"),
|
|
3350
|
-
ui: hasUi ? svcAvailable(routes.ui) : svcUnavailable("ui"),
|
|
3351
|
-
workflow: hasWorkflow ? svcAvailable(routes.workflow) : svcUnavailable("workflow"),
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
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")
|
|
3359
3559
|
},
|
|
3360
3560
|
locale
|
|
3361
3561
|
};
|
|
@@ -3367,13 +3567,58 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3367
3567
|
if (!body || !body.query) {
|
|
3368
3568
|
throw { statusCode: 400, message: "Missing query in request body" };
|
|
3369
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
|
+
}
|
|
3370
3582
|
if (typeof this.kernel.graphql !== "function") {
|
|
3371
3583
|
throw { statusCode: 501, message: "GraphQL service not available" };
|
|
3372
3584
|
}
|
|
3373
3585
|
return this.kernel.graphql(body.query, body.variables, {
|
|
3374
|
-
request: context.request
|
|
3586
|
+
request: context.request,
|
|
3587
|
+
context: ec
|
|
3375
3588
|
});
|
|
3376
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
|
+
}
|
|
3377
3622
|
/**
|
|
3378
3623
|
* Handles Auth requests
|
|
3379
3624
|
* path: sub-path after /auth/
|
|
@@ -3441,12 +3686,12 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3441
3686
|
* Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object)
|
|
3442
3687
|
*/
|
|
3443
3688
|
async handleMetadata(path, _context, method, body, query) {
|
|
3444
|
-
|
|
3689
|
+
{
|
|
3445
3690
|
const ec = _context.executionContext;
|
|
3446
|
-
if (
|
|
3691
|
+
if (shouldDenyAnonymous({ requireAuth: this.requireAuth, userId: ec?.userId, isSystem: ec?.isSystem })) {
|
|
3447
3692
|
return {
|
|
3448
3693
|
handled: true,
|
|
3449
|
-
response: this.error(
|
|
3694
|
+
response: this.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE })
|
|
3450
3695
|
};
|
|
3451
3696
|
}
|
|
3452
3697
|
}
|
|
@@ -3828,8 +4073,11 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3828
4073
|
return { handled: true, response: this.error("Security service not available", 503) };
|
|
3829
4074
|
}
|
|
3830
4075
|
const ec = context.executionContext;
|
|
3831
|
-
if (
|
|
3832
|
-
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
|
+
};
|
|
3833
4081
|
}
|
|
3834
4082
|
const m = method.toUpperCase();
|
|
3835
4083
|
const parts = path.split("/").filter(Boolean);
|
|
@@ -3952,6 +4200,17 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
3952
4200
|
}
|
|
3953
4201
|
if (parts.length === 0 && m === "POST") {
|
|
3954
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
|
+
}
|
|
3955
4214
|
let pkg;
|
|
3956
4215
|
const protocolSvc = await this.resolveService("protocol").catch(() => null);
|
|
3957
4216
|
if (protocolSvc && typeof protocolSvc.installPackage === "function") {
|
|
@@ -4879,6 +5138,9 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4879
5138
|
engine: engineFacade,
|
|
4880
5139
|
params: { ...reqParams, recordId, objectName }
|
|
4881
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
|
+
);
|
|
4882
5144
|
try {
|
|
4883
5145
|
let result;
|
|
4884
5146
|
try {
|
|
@@ -4951,12 +5213,12 @@ var _HttpDispatcher = class _HttpDispatcher {
|
|
|
4951
5213
|
if (route.method !== method) continue;
|
|
4952
5214
|
const params = matchRoute(route.path, fullPath);
|
|
4953
5215
|
if (params === null) continue;
|
|
4954
|
-
if (
|
|
5216
|
+
if (route.auth !== false) {
|
|
4955
5217
|
const gec = context.executionContext;
|
|
4956
|
-
if (
|
|
5218
|
+
if (shouldDenyAnonymous({ requireAuth: this.requireAuth, userId: gec?.userId, isSystem: gec?.isSystem })) {
|
|
4957
5219
|
return {
|
|
4958
5220
|
handled: true,
|
|
4959
|
-
response: this.error(
|
|
5221
|
+
response: this.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE })
|
|
4960
5222
|
};
|
|
4961
5223
|
}
|
|
4962
5224
|
}
|
|
@@ -5896,7 +6158,12 @@ function createDispatcherPlugin(config = {}) {
|
|
|
5896
6158
|
if (!server) return;
|
|
5897
6159
|
const kernel = ctx.getKernel();
|
|
5898
6160
|
const enforceMembership = config.enforceProjectMembership ?? (config.scoping?.enableProjectScoping ?? false);
|
|
5899
|
-
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
|
+
}
|
|
5900
6167
|
const dispatcher = new HttpDispatcher(kernel, void 0, {
|
|
5901
6168
|
enforceProjectMembership: enforceMembership,
|
|
5902
6169
|
requireAuth
|
|
@@ -6083,6 +6350,14 @@ function createDispatcherPlugin(config = {}) {
|
|
|
6083
6350
|
errorResponse(err, res);
|
|
6084
6351
|
}
|
|
6085
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
|
+
});
|
|
6086
6361
|
server.patch(`${prefix}/packages/:id/enable`, async (req, res) => {
|
|
6087
6362
|
try {
|
|
6088
6363
|
const result = await dispatcher.handlePackages(`/${req.params.id}/enable`, "PATCH", {}, {}, { request: req });
|