@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.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 { getEnv, resolveLocale, evaluateAuthGate, isAuthGateAllowlisted } from "@objectstack/core";
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) bound to
2744
- // THIS request's ExecutionContextthe same permission + RLS path
2745
- // the REST `/actions/...` route uses. No `@objectstack/service-ai`.
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 objs) {
2754
- const objectName = obj?.name;
2821
+ for (const { action, objectName, obj } of await this.collectActionDeclarations(meta)) {
2755
2822
  if (!objectName || isSystemObjectName(objectName)) continue;
2756
- const actions = Array.isArray(obj?.actions) ? obj.actions : [];
2757
- for (const action of actions) {
2758
- if (!action || typeof action.name !== "string") continue;
2759
- if (!this.isHeadlessInvokableAction(action, hasAutomation)) continue;
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
- /** Slim engine facade matching the ActionContext.engine shape handlers expect. */
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 ADR-0066 D4 capability gate and RLS as the
2899
- * caller, loads the subject record under RLS for row-context actions, and
2900
- * dispatches through the framework's `engine.executeAction` (script/body) or
2901
- * automation flow runner (flow). Throws on denial / not-found / handler
2902
- * failure so the tool surfaces a clean tool-error. No service-ai dependency.
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
- triggerData: { record, params, user, action: action.name }
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 def = await meta?.getObject?.(objectName);
2986
- const action = Array.isArray(def?.actions) ? def.actions.find((a) => a?.name === name) : void 0;
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
- realtime: hasWebSockets ? `${prefix}/realtime` : void 0,
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
- enabled: true,
3304
- status: "available",
3305
- handlerReady: true,
3306
- route,
3307
- provider
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
- websockets: hasWebSockets,
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
- realtime: hasWebSockets ? svcAvailable(routes.realtime) : svcUnavailable("realtime"),
3357
- notification: hasNotification ? svcAvailable(routes.notifications) : svcUnavailable("notification"),
3358
- ai: hasAi ? svcAvailable(routes.ai) : svcUnavailable("ai"),
3359
- i18n: hasI18n ? svcAvailable(routes.i18n) : svcUnavailable("i18n"),
3360
- graphql: hasGraphQL ? svcAvailable(routes.graphql) : svcUnavailable("graphql"),
3361
- "file-storage": hasFiles ? svcAvailable(routes.storage) : svcUnavailable("file-storage"),
3362
- search: hasSearch ? svcAvailable() : svcUnavailable("search")
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
- if (this.requireAuth) {
3689
+ {
3449
3690
  const ec = _context.executionContext;
3450
- if (!ec?.userId && !ec?.isSystem) {
3691
+ if (shouldDenyAnonymous({ requireAuth: this.requireAuth, userId: ec?.userId, isSystem: ec?.isSystem })) {
3451
3692
  return {
3452
3693
  handled: true,
3453
- response: this.error("Authentication is required to access this endpoint.", 401, { code: "unauthenticated" })
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 (!ec?.userId && !ec?.isSystem) {
3836
- return { handled: true, response: this.error("Authentication required", 401) };
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 (this.requireAuth && route.auth !== false) {
5216
+ if (route.auth !== false) {
4959
5217
  const gec = context.executionContext;
4960
- if (!gec?.userId && !gec?.isSystem) {
5218
+ if (shouldDenyAnonymous({ requireAuth: this.requireAuth, userId: gec?.userId, isSystem: gec?.isSystem })) {
4961
5219
  return {
4962
5220
  handled: true,
4963
- response: this.error("Authentication is required to access this endpoint.", 401, { code: "unauthenticated" })
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 ?? false;
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 });