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