@objectstack/runtime 10.3.0 → 11.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
@@ -802,7 +802,7 @@ __export(app_plugin_exports, {
802
802
  collectBundleFunctions: () => collectBundleFunctions,
803
803
  collectBundleHooks: () => collectBundleHooks
804
804
  });
805
- import { readEnvWithDeprecation } from "@objectstack/types";
805
+ import { resolveMultiOrgEnabled } from "@objectstack/types";
806
806
  function collectBundleHooks(bundle) {
807
807
  const out = [];
808
808
  const seen = /* @__PURE__ */ new Set();
@@ -1254,7 +1254,7 @@ var init_app_plugin = __esm({
1254
1254
  } catch (e) {
1255
1255
  ctx.logger.warn("[Seeder] Failed to register seed-datasets/seed-replayer service", { error: e?.message });
1256
1256
  }
1257
- const multiTenant = String(readEnvWithDeprecation("OS_MULTI_ORG_ENABLED", "OS_MULTI_TENANT") ?? "false").toLowerCase() !== "false";
1257
+ const multiTenant = resolveMultiOrgEnabled();
1258
1258
  if (multiTenant) {
1259
1259
  ctx.logger.info("[Seeder] multi-tenant mode \u2014 skipping inline seed; per-org replay will run on sys_organization insert");
1260
1260
  } else {
@@ -1525,7 +1525,7 @@ import { resolve as resolvePath2 } from "path";
1525
1525
  import { mkdirSync as mkdirSync2 } from "fs";
1526
1526
  import { homedir } from "os";
1527
1527
  import { z } from "zod";
1528
- import { readEnvWithDeprecation as readEnvWithDeprecation2 } from "@objectstack/types";
1528
+ import { readEnvWithDeprecation } from "@objectstack/types";
1529
1529
  function resolveObjectStackHome() {
1530
1530
  const raw = process.env.OS_HOME?.trim();
1531
1531
  if (raw && raw.length > 0) {
@@ -1556,7 +1556,7 @@ async function createStandaloneStack(config) {
1556
1556
  const environmentId = cfg.environmentId ?? process.env.OS_ENVIRONMENT_ID ?? "proj_local";
1557
1557
  const artifactPathInput = cfg.artifactPath ?? process.env.OS_ARTIFACT_PATH ?? resolvePath2(cwd, "dist/objectstack.json");
1558
1558
  const artifactPath = isHttpUrl(artifactPathInput) ? artifactPathInput : artifactPathInput.startsWith("/") ? artifactPathInput : resolvePath2(cwd, artifactPathInput);
1559
- const dbUrl = cfg.databaseUrl ?? readEnvWithDeprecation2("OS_DATABASE_URL", "DATABASE_URL")?.trim() ?? process.env.TURSO_DATABASE_URL?.trim() ?? (process.env.OS_HOME?.trim() ? `file:${resolvePath2(resolveObjectStackHome(), "data/standalone.db")}` : cfg.projectRoot ? `file:${resolvePath2(cfg.projectRoot, ".objectstack/data/standalone.db")}` : `file:${resolvePath2(resolveObjectStackHome(), "data/standalone.db")}`);
1559
+ const dbUrl = cfg.databaseUrl ?? readEnvWithDeprecation("OS_DATABASE_URL", "DATABASE_URL", { silent: true })?.trim() ?? process.env.TURSO_DATABASE_URL?.trim() ?? (process.env.OS_HOME?.trim() ? `file:${resolvePath2(resolveObjectStackHome(), "data/standalone.db")}` : cfg.projectRoot ? `file:${resolvePath2(cfg.projectRoot, ".objectstack/data/standalone.db")}` : `file:${resolvePath2(resolveObjectStackHome(), "data/standalone.db")}`);
1560
1560
  const explicitDriver = cfg.databaseDriver ?? process.env.OS_DATABASE_DRIVER?.trim();
1561
1561
  const dbDriver = explicitDriver ?? detectDriverFromUrl(dbUrl);
1562
1562
  let driverPlugin;
@@ -1975,7 +1975,7 @@ function safeGet(ctx, name) {
1975
1975
 
1976
1976
  // src/http-dispatcher.ts
1977
1977
  init_package_state_store();
1978
- import { getEnv, resolveLocale } from "@objectstack/core";
1978
+ import { getEnv, resolveLocale, evaluateAuthGate, isAuthGateAllowlisted } from "@objectstack/core";
1979
1979
  import { CoreServiceName } from "@objectstack/spec/system";
1980
1980
  import { pluralToSingular, PLURAL_TO_SINGULAR } from "@objectstack/spec/shared";
1981
1981
 
@@ -2008,18 +2008,11 @@ function checkApiExposure(def, action) {
2008
2008
  return { allowed: true };
2009
2009
  }
2010
2010
 
2011
- // src/security/api-key.ts
2011
+ // src/security/resolve-execution-context.ts
2012
2012
  import {
2013
- API_KEY_PREFIX,
2014
- hashApiKey,
2015
- generateApiKey,
2016
- extractApiKey,
2017
- parseScopes,
2018
- isExpired,
2019
- resolveApiKeyPrincipal
2013
+ resolveAuthzContext,
2014
+ resolveLocalizationContext
2020
2015
  } from "@objectstack/core";
2021
-
2022
- // src/security/resolve-execution-context.ts
2023
2016
  function toHeaders(input) {
2024
2017
  if (!input) return new Headers();
2025
2018
  if (typeof Headers !== "undefined" && input instanceof Headers) return input;
@@ -2035,212 +2028,44 @@ function toHeaders(input) {
2035
2028
  }
2036
2029
  return h;
2037
2030
  }
2038
- function safeJsonParse2(s, fallback) {
2039
- try {
2040
- return JSON.parse(s);
2041
- } catch {
2042
- return fallback;
2043
- }
2044
- }
2045
- async function tryFind(ql, object, where, limit = 100) {
2046
- if (!ql || typeof ql.find !== "function") return [];
2047
- try {
2048
- let rows = await ql.find(object, { where, limit, context: { isSystem: true } });
2049
- if (rows && rows.value) rows = rows.value;
2050
- return Array.isArray(rows) ? rows : [];
2051
- } catch {
2052
- return [];
2053
- }
2054
- }
2055
- function isValidTimeZone(tz) {
2056
- try {
2057
- new Intl.DateTimeFormat("en-US", { timeZone: tz });
2058
- return true;
2059
- } catch {
2060
- return false;
2061
- }
2062
- }
2063
- function coerceTimeZone(value) {
2064
- const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
2065
- return s && isValidTimeZone(s) ? s : void 0;
2066
- }
2067
- function coerceLocale(value) {
2068
- const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
2069
- return s || void 0;
2070
- }
2071
- function coerceCurrency(value) {
2072
- const s = typeof value === "string" ? value.trim().toUpperCase() : "";
2073
- return /^[A-Z]{3}$/.test(s) ? s : void 0;
2074
- }
2075
- async function resolveLocalization(opts, ql, sctx) {
2076
- try {
2077
- const settings = await opts.getService("settings");
2078
- if (settings && typeof settings.get === "function") {
2079
- const [tzRes, localeRes, currencyRes] = await Promise.all([
2080
- settings.get("localization", "timezone", sctx).catch(() => void 0),
2081
- settings.get("localization", "locale", sctx).catch(() => void 0),
2082
- settings.get("localization", "currency", sctx).catch(() => void 0)
2083
- ]);
2084
- const tz = coerceTimeZone(tzRes?.value);
2085
- const locale = coerceLocale(localeRes?.value);
2086
- const currency = coerceCurrency(currencyRes?.value);
2087
- if (tz || locale || currency) return { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency };
2088
- }
2089
- } catch {
2090
- }
2091
- const tzRows = await tryFind(ql, "sys_setting", { namespace: "localization", key: "timezone", scope: "tenant" }, 1);
2092
- const localeRows = await tryFind(ql, "sys_setting", { namespace: "localization", key: "locale", scope: "tenant" }, 1);
2093
- const currencyRows = await tryFind(ql, "sys_setting", { namespace: "localization", key: "currency", scope: "tenant" }, 1);
2094
- return {
2095
- timezone: coerceTimeZone(tzRows[0]?.value) ?? "UTC",
2096
- locale: coerceLocale(localeRows[0]?.value) ?? "en-US",
2097
- currency: coerceCurrency(currencyRows[0]?.value)
2098
- };
2099
- }
2100
2031
  async function resolveExecutionContext(opts) {
2101
- const headers = opts.request?.headers;
2102
- const ctx = {
2103
- roles: [],
2104
- permissions: [],
2105
- systemPermissions: [],
2106
- isSystem: false
2107
- };
2108
- let userId;
2109
- let tenantId;
2110
- const keyPrincipal = await resolveApiKeyPrincipal(await opts.getQl(), headers);
2111
- if (keyPrincipal) {
2112
- userId = keyPrincipal.userId;
2113
- tenantId = keyPrincipal.tenantId;
2114
- for (const scope of keyPrincipal.scopes) {
2115
- if (!ctx.permissions.includes(scope)) ctx.permissions.push(scope);
2116
- }
2117
- }
2118
- if (!userId) {
2032
+ const headers = toHeaders(opts.request?.headers);
2033
+ const ql = await opts.getQl();
2034
+ const getSession = async (h) => {
2119
2035
  try {
2120
2036
  const authService = await opts.getService("auth");
2121
2037
  let api = authService?.api;
2122
- if (!api && typeof authService?.getApi === "function") {
2123
- api = await authService.getApi();
2124
- }
2125
- const headersInstance = toHeaders(headers);
2126
- const sessionData = await api?.getSession?.({ headers: headersInstance });
2127
- userId = sessionData?.user?.id ?? sessionData?.session?.userId;
2128
- tenantId = tenantId ?? sessionData?.session?.activeOrganizationId;
2129
- ctx.accessToken = sessionData?.session?.token ?? ctx.accessToken;
2130
- if (sessionData?.user?.email) ctx.email = String(sessionData.user.email);
2038
+ if (!api && typeof authService?.getApi === "function") api = await authService.getApi();
2039
+ return await api?.getSession?.({ headers: h });
2131
2040
  } catch {
2041
+ return void 0;
2132
2042
  }
2133
- }
2134
- if (userId) ctx.userId = userId;
2135
- if (tenantId) ctx.tenantId = tenantId;
2136
- if (!userId) return ctx;
2137
- const ql = await opts.getQl();
2138
- if (!ql) return ctx;
2139
- if (!ctx.email) {
2140
- const userRows = await tryFind(ql, "sys_user", { id: userId }, 1);
2141
- if (userRows[0]?.email) ctx.email = String(userRows[0].email);
2142
- }
2143
- const memberWhere = tenantId ? { user_id: userId, organization_id: tenantId } : { user_id: userId };
2144
- const members = await tryFind(ql, "sys_member", memberWhere, 50);
2145
- for (const m of members) {
2146
- if (m.role && typeof m.role === "string") {
2147
- for (const r of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
2148
- if (!ctx.roles.includes(r)) ctx.roles.push(r);
2149
- }
2150
- }
2151
- }
2152
- const userRoleRows = await tryFind(ql, "sys_user_role", { user_id: userId }, 200);
2153
- for (const ur of userRoleRows) {
2154
- const org = ur.organization_id ?? null;
2155
- if (org && tenantId && org !== tenantId) continue;
2156
- const r = ur.role;
2157
- if (typeof r === "string" && r && !ctx.roles.includes(r)) ctx.roles.push(r);
2158
- }
2159
- if (tenantId) {
2160
- const orgMembers = await tryFind(
2161
- ql,
2162
- "sys_member",
2163
- { organization_id: tenantId },
2164
- 1e3
2165
- );
2166
- const orgUserIds = Array.from(
2167
- new Set(
2168
- orgMembers.map((m) => m.user_id ?? m.userId).filter((v) => typeof v === "string" && v.length > 0)
2169
- )
2170
- );
2171
- if (!orgUserIds.includes(userId)) orgUserIds.push(userId);
2172
- ctx.org_user_ids = orgUserIds;
2173
- } else {
2174
- ctx.org_user_ids = [userId];
2175
- }
2176
- const upsRows = await tryFind(ql, "sys_user_permission_set", { user_id: userId }, 100);
2177
- const psIds = new Set(
2178
- upsRows.filter((r) => {
2179
- const org = r.organization_id ?? r.organizationId ?? null;
2180
- return !(org && tenantId && org !== tenantId);
2181
- }).map((r) => r.permission_set_id ?? r.permissionSetId).filter(Boolean)
2182
- );
2183
- if (ctx.roles.length > 0) {
2184
- const roleRows = await tryFind(ql, "sys_role", { name: { $in: ctx.roles } }, 100);
2185
- const roleIds = roleRows.map((r) => r.id).filter(Boolean);
2186
- if (roleIds.length > 0) {
2187
- const rpsRows = await tryFind(
2188
- ql,
2189
- "sys_role_permission_set",
2190
- { role_id: { $in: roleIds } },
2191
- 500
2192
- );
2193
- for (const r of rpsRows) {
2194
- const id = r.permission_set_id ?? r.permissionSetId;
2195
- if (id) psIds.add(id);
2196
- }
2197
- }
2198
- }
2199
- if (psIds.size > 0) {
2200
- const psRows = await tryFind(
2043
+ };
2044
+ const authz = await resolveAuthzContext({ ql, headers, getSession });
2045
+ const ctx = {
2046
+ roles: authz.roles,
2047
+ permissions: authz.permissions,
2048
+ systemPermissions: authz.systemPermissions,
2049
+ isSystem: false
2050
+ };
2051
+ if (authz.userId) ctx.userId = authz.userId;
2052
+ if (authz.tenantId) ctx.tenantId = authz.tenantId;
2053
+ if (authz.email) ctx.email = authz.email;
2054
+ if (authz.accessToken) ctx.accessToken = authz.accessToken;
2055
+ if (authz.tabPermissions) ctx.tabPermissions = authz.tabPermissions;
2056
+ ctx.org_user_ids = authz.org_user_ids;
2057
+ if (authz.userId) {
2058
+ const settings = await Promise.resolve(opts.getService("settings")).catch(() => void 0);
2059
+ const localization = await resolveLocalizationContext({
2201
2060
  ql,
2202
- "sys_permission_set",
2203
- { id: { $in: Array.from(psIds) } },
2204
- 500
2205
- );
2206
- const tabRank = {
2207
- hidden: 0,
2208
- default_off: 1,
2209
- default_on: 2,
2210
- visible: 3
2211
- };
2212
- const mergedTabs = {};
2213
- for (const ps of psRows) {
2214
- if (ps.name && !ctx.permissions.includes(ps.name)) {
2215
- ctx.permissions.push(ps.name);
2216
- }
2217
- const sysPerms = typeof ps.system_permissions === "string" ? safeJsonParse2(ps.system_permissions, []) : ps.system_permissions ?? ps.systemPermissions;
2218
- if (Array.isArray(sysPerms)) {
2219
- for (const p of sysPerms) {
2220
- if (typeof p === "string" && !ctx.systemPermissions.includes(p)) {
2221
- ctx.systemPermissions.push(p);
2222
- }
2223
- }
2224
- }
2225
- const tabs = typeof ps.tab_permissions === "string" ? safeJsonParse2(ps.tab_permissions, {}) : ps.tab_permissions ?? ps.tabPermissions;
2226
- if (tabs && typeof tabs === "object") {
2227
- for (const [app, val] of Object.entries(tabs)) {
2228
- if (typeof val !== "string" || !(val in tabRank)) continue;
2229
- const cur = mergedTabs[app];
2230
- if (!cur || tabRank[val] > tabRank[cur]) {
2231
- mergedTabs[app] = val;
2232
- }
2233
- }
2234
- }
2235
- }
2236
- if (Object.keys(mergedTabs).length > 0) {
2237
- ctx.tabPermissions = mergedTabs;
2238
- }
2061
+ settings,
2062
+ tenantId: authz.tenantId,
2063
+ userId: authz.userId
2064
+ });
2065
+ ctx.timezone = localization.timezone;
2066
+ ctx.locale = localization.locale;
2067
+ if (localization.currency) ctx.currency = localization.currency;
2239
2068
  }
2240
- const localization = await resolveLocalization(opts, ql, { tenantId, userId });
2241
- ctx.timezone = localization.timezone;
2242
- ctx.locale = localization.locale;
2243
- if (localization.currency) ctx.currency = localization.currency;
2244
2069
  return ctx;
2245
2070
  }
2246
2071
  function isPermissionDeniedError(e) {
@@ -2249,6 +2074,17 @@ function isPermissionDeniedError(e) {
2249
2074
  return anyE.name === "PermissionDeniedError" || anyE.code === "PERMISSION_DENIED" || typeof anyE.message === "string" && anyE.message.startsWith("[Security] Access denied");
2250
2075
  }
2251
2076
 
2077
+ // src/security/api-key.ts
2078
+ import {
2079
+ API_KEY_PREFIX,
2080
+ hashApiKey,
2081
+ generateApiKey,
2082
+ extractApiKey,
2083
+ parseScopes,
2084
+ isExpired,
2085
+ resolveApiKeyPrincipal
2086
+ } from "@objectstack/core";
2087
+
2252
2088
  // src/http-dispatcher.ts
2253
2089
  function randomUUID() {
2254
2090
  if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
@@ -2260,6 +2096,9 @@ function randomUUID() {
2260
2096
  return v.toString(16);
2261
2097
  });
2262
2098
  }
2099
+ function isSystemObjectName(name) {
2100
+ return /^sys_/i.test(name);
2101
+ }
2263
2102
  var _HttpDispatcher = class _HttpDispatcher {
2264
2103
  /**
2265
2104
  * @param _envRegistryIgnored — RETIRED (ADR-0006 Phase 5). Environment
@@ -2377,7 +2216,10 @@ var _HttpDispatcher = class _HttpDispatcher {
2377
2216
  return extra ? { ...base, ...extra } : qlOpts ? base : void 0;
2378
2217
  };
2379
2218
  if (action === "create") {
2380
- if (ql) {
2219
+ if (protocol && typeof protocol.createData === "function") {
2220
+ return await protocol.createData({ object: params.object, data: params.data, ...scopeId ? { environmentId: scopeId } : {}, context: executionContext });
2221
+ }
2222
+ if (ql && typeof ql.insert === "function") {
2381
2223
  const res = await ql.insert(params.object, params.data, qlOpts);
2382
2224
  const record = { ...params.data, ...res };
2383
2225
  return { object: params.object, id: record.id, record };
@@ -2398,7 +2240,10 @@ var _HttpDispatcher = class _HttpDispatcher {
2398
2240
  throw { statusCode: 503, message: "Data service not available" };
2399
2241
  }
2400
2242
  if (action === "update") {
2401
- if (ql && params.id) {
2243
+ if (protocol && typeof protocol.updateData === "function") {
2244
+ return await protocol.updateData({ object: params.object, id: params.id, data: params.data, ...scopeId ? { environmentId: scopeId } : {}, context: executionContext });
2245
+ }
2246
+ if (ql && params.id && typeof ql.update === "function") {
2402
2247
  let all = await ql.find(params.object, findOpts({ where: { id: params.id }, limit: 1 }));
2403
2248
  if (all && all.value) all = all.value;
2404
2249
  if (!all) all = [];
@@ -2410,7 +2255,10 @@ var _HttpDispatcher = class _HttpDispatcher {
2410
2255
  throw { statusCode: 503, message: "Data service not available" };
2411
2256
  }
2412
2257
  if (action === "delete") {
2413
- if (ql) {
2258
+ if (protocol && typeof protocol.deleteData === "function") {
2259
+ return await protocol.deleteData({ object: params.object, id: params.id, ...scopeId ? { environmentId: scopeId } : {}, context: executionContext });
2260
+ }
2261
+ if (ql && typeof ql.delete === "function") {
2414
2262
  await ql.delete(params.object, findOpts({ where: { id: params.id } }));
2415
2263
  return { object: params.object, id: params.id, deleted: true };
2416
2264
  }
@@ -2602,9 +2450,269 @@ var _HttpDispatcher = class _HttpDispatcher {
2602
2450
  },
2603
2451
  create: async (object, data) => await callData("create", { object, data }, driver, envId, ec),
2604
2452
  update: async (object, id, data) => await callData("update", { object, id, data }, driver, envId, ec),
2605
- remove: async (object, id) => await callData("delete", { object, id }, driver, envId, ec)
2453
+ remove: async (object, id) => await callData("delete", { object, id }, driver, envId, ec),
2454
+ // ── Business-action surface (McpActionBridge) ──────────────
2455
+ // Resolution + dispatch flow through the framework's own action
2456
+ // mechanism (engine.executeAction / automation flow runner) bound to
2457
+ // THIS request's ExecutionContext — the same permission + RLS path
2458
+ // the REST `/actions/...` route uses. No `@objectstack/service-ai`.
2459
+ listActions: async () => {
2460
+ const meta = await getMeta();
2461
+ const objs = await meta?.listObjects?.() ?? [];
2462
+ const hasAutomation = Boolean(
2463
+ await this.resolveService("automation", envId).catch(() => null)
2464
+ );
2465
+ const out = [];
2466
+ for (const obj of objs) {
2467
+ const objectName = obj?.name;
2468
+ if (!objectName || isSystemObjectName(objectName)) continue;
2469
+ const actions = Array.isArray(obj?.actions) ? obj.actions : [];
2470
+ for (const action of actions) {
2471
+ if (!action || typeof action.name !== "string") continue;
2472
+ if (!this.isHeadlessInvokableAction(action, hasAutomation)) continue;
2473
+ if (this.actionPermissionError(action, ec)) continue;
2474
+ out.push(this.summarizeAction(action, obj, objectName));
2475
+ }
2476
+ }
2477
+ return out;
2478
+ },
2479
+ runAction: async (name, input) => this.invokeBusinessAction(name, input ?? {}, { driver, envId, ec, getMeta, callData })
2480
+ };
2481
+ }
2482
+ // ── MCP action bridge helpers ──────────────────────────────────────
2483
+ /**
2484
+ * [ADR-0066 D4] Shared capability gate for an action invocation. Returns a
2485
+ * human-readable error string when the caller's `systemPermissions` don't
2486
+ * cover the action's declared `requiredPermissions`, or `null` when allowed.
2487
+ * System/engine self-invocation (`isSystem`) bypasses; an action without
2488
+ * `requiredPermissions` is ungated. Single-sourced so the REST `/actions/...`
2489
+ * route and the MCP `run_action` bridge enforce the SAME declaration.
2490
+ */
2491
+ actionPermissionError(actionDef, ec, objectName) {
2492
+ const required = Array.isArray(actionDef?.requiredPermissions) ? actionDef.requiredPermissions : [];
2493
+ if (required.length === 0) return null;
2494
+ if (ec?.isSystem) return null;
2495
+ const held = new Set(ec?.systemPermissions ?? []);
2496
+ const missing = required.filter((perm) => !held.has(perm));
2497
+ if (missing.length === 0) return null;
2498
+ const on = objectName ? ` on '${objectName}'` : "";
2499
+ return `Action '${actionDef?.name ?? "unknown"}'${on} requires capability [${required.join(", ")}] \u2014 caller is missing [${missing.join(", ")}]`;
2500
+ }
2501
+ /**
2502
+ * Whether an action has a headless invocation path (so MCP can run it).
2503
+ * Mirrors the supported-type set of the (now cloud-side) action-tools
2504
+ * bridge: `script` needs a handler binding (`target`) or an inline `body`;
2505
+ * `flow` needs a `target` and an automation service. UI-only types
2506
+ * (`url`, `modal`, `form`) and `api` have no server dispatch here.
2507
+ */
2508
+ isHeadlessInvokableAction(action, hasAutomation) {
2509
+ const type = action?.type ?? "script";
2510
+ if (type === "script") return Boolean(action?.target || action?.body);
2511
+ if (type === "flow") return Boolean(action?.target) && hasAutomation;
2512
+ return false;
2513
+ }
2514
+ /** True when an action is destructive by author signal/heuristic (HITL hint). */
2515
+ actionLooksDestructive(action) {
2516
+ if (action?.ai?.requiresConfirmation !== void 0) return Boolean(action.ai.requiresConfirmation);
2517
+ return Boolean(action?.confirmText || action?.mode === "delete" || action?.variant === "danger");
2518
+ }
2519
+ /** Project an action's declarative metadata into a lean MCP summary. */
2520
+ summarizeAction(action, obj, objectName) {
2521
+ const requiresRecord = Array.isArray(action?.locations) && action.locations.some(
2522
+ (l) => l === "list_item" || l === "record_header" || l === "record_more" || l === "record_related"
2523
+ );
2524
+ const description = (typeof action?.ai?.description === "string" ? action.ai.description : void 0) ?? (typeof action?.label === "string" ? action.label : void 0);
2525
+ const params = this.summarizeActionParams(action, obj);
2526
+ return {
2527
+ name: action.name,
2528
+ objectName,
2529
+ ...typeof action?.label === "string" ? { label: action.label } : {},
2530
+ ...description ? { description } : {},
2531
+ type: action?.type ?? "script",
2532
+ requiresRecord: Boolean(requiresRecord),
2533
+ requiresConfirmation: this.actionLooksDestructive(action),
2534
+ ...params.length > 0 ? { params } : {}
2535
+ };
2536
+ }
2537
+ /** Map an ObjectStack field type to a JSON-Schema primitive (conservative). */
2538
+ jsonTypeOf(t) {
2539
+ switch (t) {
2540
+ case "number":
2541
+ case "currency":
2542
+ case "percent":
2543
+ case "rating":
2544
+ case "slider":
2545
+ case "autonumber":
2546
+ return "number";
2547
+ case "boolean":
2548
+ case "toggle":
2549
+ return "boolean";
2550
+ case "multiselect":
2551
+ case "checkboxes":
2552
+ case "tags":
2553
+ return "array";
2554
+ default:
2555
+ return "string";
2556
+ }
2557
+ }
2558
+ /** Resolve an action's params into LLM-facing summaries (field-backed types resolved). */
2559
+ summarizeActionParams(action, obj) {
2560
+ const fields = obj?.fields ?? {};
2561
+ const out = [];
2562
+ for (const p of Array.isArray(action?.params) ? action.params : []) {
2563
+ const fieldRef = p?.field;
2564
+ const field = fieldRef ? fields[fieldRef] : void 0;
2565
+ const name = p?.name ?? fieldRef;
2566
+ if (!name) continue;
2567
+ const type = this.jsonTypeOf(p?.type ?? field?.type);
2568
+ const label = typeof p?.label === "string" ? p.label : field?.label;
2569
+ const help = p?.helpText ?? field?.description;
2570
+ const description = [label, help].filter(Boolean).join(" \u2014 ") || void 0;
2571
+ const optionSource = p?.options ?? field?.options;
2572
+ const enumVals = Array.isArray(optionSource) ? optionSource.map((o) => typeof o === "string" ? o : o?.value).filter((v) => typeof v === "string") : [];
2573
+ out.push({
2574
+ name,
2575
+ type,
2576
+ required: Boolean(p?.required ?? field?.required ?? false),
2577
+ ...description ? { description } : {},
2578
+ ...enumVals.length > 0 ? { enum: enumVals } : {}
2579
+ });
2580
+ }
2581
+ return out;
2582
+ }
2583
+ /** Slim engine facade matching the ActionContext.engine shape handlers expect. */
2584
+ buildActionEngineFacade(ql) {
2585
+ return {
2586
+ async insert(object, data) {
2587
+ const res = await ql.insert(object, data);
2588
+ const id = (res && res.id) ?? data.id;
2589
+ return { id };
2590
+ },
2591
+ async update(object, id, data) {
2592
+ await ql.update(object, data, { where: { id } });
2593
+ },
2594
+ // Tolerant of both the single-id and array conventions handler suites
2595
+ // use (CRM handlers pass one id; todo handlers pass an id array).
2596
+ async delete(object, idOrIds) {
2597
+ const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
2598
+ for (const id of ids) {
2599
+ if (id != null) await ql.delete(object, { where: { id } });
2600
+ }
2601
+ },
2602
+ async find(object, query) {
2603
+ const opts = query && Object.keys(query).length ? { where: query } : void 0;
2604
+ const rows = await ql.find(object, opts);
2605
+ return Array.isArray(rows) ? rows : rows?.value ?? [];
2606
+ }
2606
2607
  };
2607
2608
  }
2609
+ /**
2610
+ * Resolve + invoke a business action by its declarative name for the MCP
2611
+ * `run_action` tool. Enforces the ADR-0066 D4 capability gate and RLS as the
2612
+ * caller, loads the subject record under RLS for row-context actions, and
2613
+ * dispatches through the framework's `engine.executeAction` (script/body) or
2614
+ * automation flow runner (flow). Throws on denial / not-found / handler
2615
+ * failure so the tool surfaces a clean tool-error. No service-ai dependency.
2616
+ */
2617
+ async invokeBusinessAction(name, input, wiring) {
2618
+ const { driver, envId, ec, getMeta, callData } = wiring;
2619
+ const meta = await getMeta();
2620
+ const params = input?.params && typeof input.params === "object" ? input.params : {};
2621
+ const recordId = typeof input?.recordId === "string" && input.recordId.length > 0 ? input.recordId : void 0;
2622
+ const resolved = await this.resolveActionByName(meta, name, input?.objectName);
2623
+ if (!resolved) {
2624
+ throw new Error(
2625
+ input?.objectName ? `Action '${name}' not found on object '${input.objectName}'` : `Action '${name}' not found`
2626
+ );
2627
+ }
2628
+ const { action, objectName } = resolved;
2629
+ if (isSystemObjectName(objectName)) {
2630
+ throw new Error(`Action '${name}' is on a system object and is not exposed via MCP`);
2631
+ }
2632
+ const hasAutomation = Boolean(await this.resolveService("automation", envId).catch(() => null));
2633
+ if (!this.isHeadlessInvokableAction(action, hasAutomation)) {
2634
+ throw new Error(
2635
+ `Action '${name}' (type='${action?.type ?? "script"}') cannot be invoked via MCP`
2636
+ );
2637
+ }
2638
+ const gateError = this.actionPermissionError(action, ec, objectName);
2639
+ if (gateError) throw new Error(gateError);
2640
+ let record = {};
2641
+ if (recordId && objectName !== "global") {
2642
+ try {
2643
+ const got = await callData("get", { object: objectName, id: recordId }, driver, envId, ec);
2644
+ if (got?.record) record = got.record;
2645
+ } catch {
2646
+ }
2647
+ }
2648
+ if (record && record.id == null && recordId) record.id = recordId;
2649
+ const user = ec?.userId ? { id: ec.userId, name: ec.userName ?? ec.userDisplayName ?? ec.userId } : { id: "system", name: "system" };
2650
+ if (action.type === "flow") {
2651
+ const automation = await this.resolveService("automation", envId).catch(() => null);
2652
+ if (!automation || typeof automation.execute !== "function") {
2653
+ throw new Error(`Action '${name}' is a flow but no automation service is available`);
2654
+ }
2655
+ const result = await automation.execute(action.target, {
2656
+ triggerData: { record, params, user, action: action.name }
2657
+ });
2658
+ if (result && typeof result === "object" && "success" in result && result.success === false) {
2659
+ throw new Error(`Flow '${action.target}' failed: ${result.error ?? "unknown error"}`);
2660
+ }
2661
+ return { ok: true, action: action.name, objectName, ...recordId ? { recordId } : {}, result: result ?? null };
2662
+ }
2663
+ const ql = await this.getObjectQLService(envId);
2664
+ if (!ql || typeof ql.executeAction !== "function") {
2665
+ throw new Error("Data engine not available for action dispatch");
2666
+ }
2667
+ const actionContext = {
2668
+ record,
2669
+ user,
2670
+ engine: this.buildActionEngineFacade(ql),
2671
+ params: { ...params, recordId, objectName }
2672
+ };
2673
+ const primary = action.body ? action.name : action.target || action.name;
2674
+ const candidates = [primary, action.target, action.name].filter(
2675
+ (k, i, a) => typeof k === "string" && a.indexOf(k) === i
2676
+ );
2677
+ const notRegistered = (err) => /Action '.+' on object '.+' not found/i.test(String(err?.message ?? err));
2678
+ for (const obj of [objectName, "*"]) {
2679
+ for (const key of candidates) {
2680
+ try {
2681
+ const result = await ql.executeAction(obj, key, actionContext);
2682
+ return { ok: true, action: action.name, objectName, ...recordId ? { recordId } : {}, result: result ?? null };
2683
+ } catch (err) {
2684
+ if (!notRegistered(err)) throw err;
2685
+ }
2686
+ }
2687
+ }
2688
+ throw new Error(`No handler registered for action '${name}' on '${objectName}'`);
2689
+ }
2690
+ /**
2691
+ * Find an action's declarative definition by name across object metadata,
2692
+ * optionally scoped to a single object. Returns the action plus its owning
2693
+ * object name, or `null`. Throws when the name is ambiguous across objects
2694
+ * and no `objectName` was supplied (so `run_action` can ask for one).
2695
+ */
2696
+ async resolveActionByName(meta, name, objectName) {
2697
+ if (objectName) {
2698
+ const def = await meta?.getObject?.(objectName);
2699
+ const action = Array.isArray(def?.actions) ? def.actions.find((a) => a?.name === name) : void 0;
2700
+ return action ? { action, objectName } : null;
2701
+ }
2702
+ const objs = await meta?.listObjects?.() ?? [];
2703
+ const matches = [];
2704
+ for (const obj of objs) {
2705
+ if (!obj?.name) continue;
2706
+ const action = Array.isArray(obj?.actions) ? obj.actions.find((a) => a?.name === name) : void 0;
2707
+ if (action) matches.push({ action, objectName: obj.name });
2708
+ }
2709
+ if (matches.length === 0) return null;
2710
+ if (matches.length > 1) {
2711
+ const where = matches.map((m) => m.objectName).join(", ");
2712
+ throw new Error(`Action '${name}' exists on multiple objects (${where}); pass objectName to disambiguate`);
2713
+ }
2714
+ return matches[0];
2715
+ }
2608
2716
  /**
2609
2717
  * Generate a `sys_api_key` and return the raw secret EXACTLY ONCE
2610
2718
  * (`POST /keys`). This is the only mint path — the raw key is never stored
@@ -2736,6 +2844,43 @@ var _HttpDispatcher = class _HttpDispatcher {
2736
2844
  * response object that callers should surface directly — no further
2737
2845
  * dispatch happens.
2738
2846
  */
2847
+ /**
2848
+ * ADR-0069 — returns a 403 response when the resolved session is blocked by
2849
+ * an auth-policy gate (expired password / required MFA) on a non-allow-listed
2850
+ * path, else null. Mirrors the REST `enforceAuth` seam so REST + dispatcher
2851
+ * (MCP, GraphQL) enforce consistently. Fails open on any lookup error.
2852
+ */
2853
+ async enforceAuthGate(context, cleanPath) {
2854
+ try {
2855
+ if (isAuthGateAllowlisted(cleanPath)) return null;
2856
+ const authService = await this.resolveService("auth", context.environmentId);
2857
+ if (!authService || typeof authService.isAuthGateActive !== "function" || !authService.isAuthGateActive()) {
2858
+ return null;
2859
+ }
2860
+ let api = authService.api;
2861
+ if (!api && typeof authService.getApi === "function") api = await authService.getApi();
2862
+ if (!api?.getSession) return null;
2863
+ const raw = context?.request?.headers;
2864
+ let headers;
2865
+ if (raw && typeof raw.get === "function") {
2866
+ headers = raw;
2867
+ } else if (raw && typeof raw === "object") {
2868
+ headers = new globalThis.Headers();
2869
+ for (const k of Object.keys(raw)) {
2870
+ const v = raw[k];
2871
+ if (v != null) headers.set(String(k), Array.isArray(v) ? v.join(",") : String(v));
2872
+ }
2873
+ } else {
2874
+ return null;
2875
+ }
2876
+ const session = await api.getSession({ headers }).catch(() => void 0);
2877
+ const gate = evaluateAuthGate(session?.user, cleanPath);
2878
+ if (!gate) return null;
2879
+ return this.error(gate.message, 403, { code: gate.code });
2880
+ } catch {
2881
+ return null;
2882
+ }
2883
+ }
2739
2884
  async enforceProjectMembership(context, path) {
2740
2885
  if (!this.enforceMembership) return null;
2741
2886
  const skipPaths = ["/auth", "/cloud", "/health", "/ready", "/discovery"];
@@ -3585,6 +3730,61 @@ var _HttpDispatcher = class _HttpDispatcher {
3585
3730
  }
3586
3731
  return { handled: true, response: this.error("Draft discarding not supported", 501) };
3587
3732
  }
3733
+ if (parts.length === 2 && parts[1] === "commits" && m === "GET") {
3734
+ const id = decodeURIComponent(parts[0]);
3735
+ const protocol = await this.resolveService("protocol");
3736
+ if (protocol && typeof protocol.listCommits === "function") {
3737
+ try {
3738
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3739
+ const commits = await protocol.listCommits({
3740
+ packageId: id,
3741
+ ...organizationId ? { organizationId } : {}
3742
+ });
3743
+ return { handled: true, response: this.success({ commits }) };
3744
+ } catch (e) {
3745
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3746
+ }
3747
+ }
3748
+ return { handled: true, response: this.error("Commit history not supported", 501) };
3749
+ }
3750
+ if (parts.length === 4 && parts[1] === "commits" && parts[3] === "revert" && m === "POST") {
3751
+ const commitId = decodeURIComponent(parts[2]);
3752
+ const protocol = await this.resolveService("protocol");
3753
+ if (protocol && typeof protocol.revertCommit === "function") {
3754
+ try {
3755
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3756
+ const result = await protocol.revertCommit({
3757
+ commitId,
3758
+ ...organizationId ? { organizationId } : {},
3759
+ ...body?.actor ? { actor: body.actor } : {}
3760
+ });
3761
+ return { handled: true, response: this.success(result) };
3762
+ } catch (e) {
3763
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3764
+ }
3765
+ }
3766
+ return { handled: true, response: this.error("Commit revert not supported", 501) };
3767
+ }
3768
+ if (parts.length === 2 && parts[1] === "rollback" && m === "POST") {
3769
+ const protocol = await this.resolveService("protocol");
3770
+ if (protocol && typeof protocol.rollbackToPackageCommit === "function") {
3771
+ if (!body?.commitId) {
3772
+ return { handled: true, response: this.error("Body { commitId } is required", 400) };
3773
+ }
3774
+ try {
3775
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3776
+ const result = await protocol.rollbackToPackageCommit({
3777
+ commitId: String(body.commitId),
3778
+ ...organizationId ? { organizationId } : {},
3779
+ ...body?.actor ? { actor: body.actor } : {}
3780
+ });
3781
+ return { handled: true, response: this.success(result) };
3782
+ } catch (e) {
3783
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3784
+ }
3785
+ }
3786
+ return { handled: true, response: this.error("Commit rollback not supported", 501) };
3787
+ }
3588
3788
  if (parts.length === 2 && parts[1] === "revert" && m === "POST") {
3589
3789
  const id = decodeURIComponent(parts[0]);
3590
3790
  const metadataService = await this.getService(CoreServiceName.enum.metadata);
@@ -3602,6 +3802,49 @@ var _HttpDispatcher = class _HttpDispatcher {
3602
3802
  }
3603
3803
  return { handled: true, response: this.success(manifest) };
3604
3804
  }
3805
+ if (parts.length === 2 && parts[1] === "adopt-orphans" && m === "POST") {
3806
+ const id = decodeURIComponent(parts[0]);
3807
+ const protocol = await this.resolveService("protocol");
3808
+ if (!protocol || typeof protocol.reassignOrphanedMetadata !== "function") {
3809
+ return { handled: true, response: this.error("Orphan adoption not supported", 501) };
3810
+ }
3811
+ try {
3812
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3813
+ const result = await protocol.reassignOrphanedMetadata({
3814
+ targetPackageId: id,
3815
+ ...organizationId ? { organizationId } : {},
3816
+ ...body?.actor ? { actor: body.actor } : {}
3817
+ });
3818
+ return { handled: true, response: this.success(result) };
3819
+ } catch (e) {
3820
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3821
+ }
3822
+ }
3823
+ if (parts.length === 2 && parts[1] === "duplicate" && m === "POST") {
3824
+ const id = decodeURIComponent(parts[0]);
3825
+ const protocol = await this.resolveService("protocol");
3826
+ if (!protocol || typeof protocol.duplicatePackage !== "function") {
3827
+ return { handled: true, response: this.error("Package duplication not supported", 501) };
3828
+ }
3829
+ const targetPackageId = typeof body?.targetPackageId === "string" ? body.targetPackageId.trim() : "";
3830
+ if (!targetPackageId) {
3831
+ return { handled: true, response: this.error("Body { targetPackageId } is required", 400) };
3832
+ }
3833
+ try {
3834
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3835
+ const result = await protocol.duplicatePackage({
3836
+ sourcePackageId: id,
3837
+ targetPackageId,
3838
+ ...typeof body?.targetName === "string" ? { targetName: body.targetName } : {},
3839
+ ...typeof body?.targetNamespace === "string" ? { targetNamespace: body.targetNamespace } : {},
3840
+ ...organizationId ? { organizationId } : {},
3841
+ ...body?.actor ? { actor: body.actor } : {}
3842
+ });
3843
+ return { handled: true, response: this.success(result) };
3844
+ } catch (e) {
3845
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3846
+ }
3847
+ }
3605
3848
  if (parts.length === 1 && m === "GET") {
3606
3849
  const id = decodeURIComponent(parts[0]);
3607
3850
  const pkg = registry.getPackage(id);
@@ -4005,8 +4248,12 @@ var _HttpDispatcher = class _HttpDispatcher {
4005
4248
  object: objectName,
4006
4249
  event: ctxBody.event ?? "manual"
4007
4250
  };
4008
- const userIdFromAuth = context?.user?.id ?? context?.userId;
4251
+ const ec = context?.executionContext;
4252
+ const userIdFromAuth = context?.user?.id ?? context?.userId ?? ec?.userId;
4009
4253
  if (userIdFromAuth) automationContext.userId = userIdFromAuth;
4254
+ if (Array.isArray(ec?.roles) && ec.roles.length) automationContext.roles = ec.roles;
4255
+ if (Array.isArray(ec?.permissions) && ec.permissions.length) automationContext.permissions = ec.permissions;
4256
+ if (ec?.tenantId) automationContext.tenantId = ec.tenantId;
4010
4257
  const result = await automationService.execute(name, automationContext);
4011
4258
  return { handled: true, response: this.success(result) };
4012
4259
  }
@@ -4187,22 +4434,9 @@ var _HttpDispatcher = class _HttpDispatcher {
4187
4434
  try {
4188
4435
  const actionSchema = (typeof ql.getSchema === "function" ? ql.getSchema(objectName) : void 0) ?? ql.registry?.getObject?.(objectName);
4189
4436
  const actionDef = Array.isArray(actionSchema?.actions) ? actionSchema.actions.find((a) => a?.name === actionName) : void 0;
4190
- const required = Array.isArray(actionDef?.requiredPermissions) ? actionDef.requiredPermissions : [];
4191
- if (required.length > 0) {
4192
- const execCtx = _context?.executionContext;
4193
- if (!execCtx?.isSystem) {
4194
- const held = new Set(execCtx?.systemPermissions ?? []);
4195
- const missing = required.filter((perm) => !held.has(perm));
4196
- if (missing.length > 0) {
4197
- return {
4198
- handled: true,
4199
- response: this.error(
4200
- `Action '${actionName}' on '${objectName}' requires capability [${required.join(", ")}] \u2014 caller is missing [${missing.join(", ")}]`,
4201
- 403
4202
- )
4203
- };
4204
- }
4205
- }
4437
+ const gateError = this.actionPermissionError(actionDef, _context?.executionContext, objectName);
4438
+ if (gateError) {
4439
+ return { handled: true, response: this.error(gateError, 403) };
4206
4440
  }
4207
4441
  } catch {
4208
4442
  }
@@ -4545,11 +4779,31 @@ var _HttpDispatcher = class _HttpDispatcher {
4545
4779
  try {
4546
4780
  context.executionContext = await resolveExecutionContext({
4547
4781
  getService: (n) => this.resolveService(n, context.environmentId),
4548
- getQl: () => Promise.resolve(this.getObjectQLService(context.environmentId)),
4782
+ // Resolve ObjectQL from the per-request kernel DIRECTLY. The scoped
4783
+ // `resolveService('objectql', envId)` factory can return a different
4784
+ // instance that doesn't see THIS env's rows (the gotcha
4785
+ // `handleActions` works around) — which made the api-key lookup miss
4786
+ // `sys_api_key` on the MCP path and reject valid keys with 401, while
4787
+ // REST accepted them (rest-server resolves identity via
4788
+ // `kernel.getServiceAsync('objectql')`). Resolving off `this.kernel`
4789
+ // keeps REST + MCP identity resolution aligned; falls back to the
4790
+ // scoped path when the kernel can't hand back an objectql directly.
4791
+ getQl: async () => {
4792
+ const k = this.kernel;
4793
+ if (k && typeof k.getServiceAsync === "function") {
4794
+ const ql = await k.getServiceAsync("objectql").catch(() => void 0);
4795
+ if (ql && (ql.registry || typeof ql.find === "function")) return ql;
4796
+ }
4797
+ return this.getObjectQLService(context.environmentId);
4798
+ },
4549
4799
  request: context.request
4550
4800
  });
4551
4801
  } catch {
4552
4802
  }
4803
+ const authGated = await this.enforceAuthGate(context, cleanPath);
4804
+ if (authGated) {
4805
+ return { handled: true, response: authGated };
4806
+ }
4553
4807
  const forbidden = await this.enforceProjectMembership(context, cleanPath);
4554
4808
  if (forbidden) {
4555
4809
  return { handled: true, response: forbidden };
@@ -6018,7 +6272,7 @@ import {
6018
6272
  createRestApiPlugin
6019
6273
  } from "@objectstack/rest";
6020
6274
  export * from "@objectstack/core";
6021
- import { readEnvWithDeprecation as readEnvWithDeprecation3, _resetEnvDeprecationWarnings } from "@objectstack/types";
6275
+ import { readEnvWithDeprecation as readEnvWithDeprecation2, _resetEnvDeprecationWarnings } from "@objectstack/types";
6022
6276
  export {
6023
6277
  AppPlugin,
6024
6278
  DEFAULT_RATE_LIMITS,
@@ -6067,7 +6321,7 @@ export {
6067
6321
  mergeRuntimeModule,
6068
6322
  parseTraceparent,
6069
6323
  readArtifactSource,
6070
- readEnvWithDeprecation3 as readEnvWithDeprecation,
6324
+ readEnvWithDeprecation2 as readEnvWithDeprecation,
6071
6325
  resolveDefaultArtifactPath,
6072
6326
  resolveErrorReporter,
6073
6327
  resolveMetrics,