@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.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 { 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";
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) bound to
2740
- // THIS request's ExecutionContextthe same permission + RLS path
2741
- // 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`.
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 objs) {
2750
- const objectName = obj?.name;
2821
+ for (const { action, objectName, obj } of await this.collectActionDeclarations(meta)) {
2751
2822
  if (!objectName || isSystemObjectName(objectName)) continue;
2752
- const actions = Array.isArray(obj?.actions) ? obj.actions : [];
2753
- for (const action of actions) {
2754
- if (!action || typeof action.name !== "string") continue;
2755
- if (!this.isHeadlessInvokableAction(action, hasAutomation)) continue;
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
- /** 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
+ */
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 ADR-0066 D4 capability gate and RLS as the
2895
- * caller, loads the subject record under RLS for row-context actions, and
2896
- * dispatches through the framework's `engine.executeAction` (script/body) or
2897
- * automation flow runner (flow). Throws on denial / not-found / handler
2898
- * 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).
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
- 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 }
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 def = await meta?.getObject?.(objectName);
2982
- const action = Array.isArray(def?.actions) ? def.actions.find((a) => a?.name === name) : void 0;
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
- 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,
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
- enabled: true,
3300
- status: "available",
3301
- handlerReady: true,
3302
- route,
3303
- provider
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
- 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,
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
- realtime: hasWebSockets ? svcAvailable(routes.realtime) : svcUnavailable("realtime"),
3353
- notification: hasNotification ? svcAvailable(routes.notifications) : svcUnavailable("notification"),
3354
- ai: hasAi ? svcAvailable(routes.ai) : svcUnavailable("ai"),
3355
- i18n: hasI18n ? svcAvailable(routes.i18n) : svcUnavailable("i18n"),
3356
- graphql: hasGraphQL ? svcAvailable(routes.graphql) : svcUnavailable("graphql"),
3357
- "file-storage": hasFiles ? svcAvailable(routes.storage) : svcUnavailable("file-storage"),
3358
- 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")
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
- if (this.requireAuth) {
3689
+ {
3445
3690
  const ec = _context.executionContext;
3446
- if (!ec?.userId && !ec?.isSystem) {
3691
+ if (shouldDenyAnonymous({ requireAuth: this.requireAuth, userId: ec?.userId, isSystem: ec?.isSystem })) {
3447
3692
  return {
3448
3693
  handled: true,
3449
- 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 })
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 (!ec?.userId && !ec?.isSystem) {
3832
- 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
+ };
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 (this.requireAuth && route.auth !== false) {
5216
+ if (route.auth !== false) {
4955
5217
  const gec = context.executionContext;
4956
- if (!gec?.userId && !gec?.isSystem) {
5218
+ if (shouldDenyAnonymous({ requireAuth: this.requireAuth, userId: gec?.userId, isSystem: gec?.isSystem })) {
4957
5219
  return {
4958
5220
  handled: true,
4959
- 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 })
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 ?? 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
+ }
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 });