@objectstack/runtime 10.2.0 → 11.0.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")?.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;
@@ -1574,6 +1574,7 @@ async function createStandaloneStack(config) {
1574
1574
  );
1575
1575
  } else {
1576
1576
  const { createDefaultDatasourceDriverFactory } = await import("@objectstack/service-datasource");
1577
+ const factoryDev = cfg.dev ?? process.env.NODE_ENV === "development";
1577
1578
  let driverId;
1578
1579
  let driverConfig;
1579
1580
  if (dbDriver === "memory") {
@@ -1598,7 +1599,7 @@ async function createStandaloneStack(config) {
1598
1599
  }
1599
1600
  let driverHandle;
1600
1601
  try {
1601
- driverHandle = await createDefaultDatasourceDriverFactory().create({ driver: driverId, config: driverConfig });
1602
+ driverHandle = await createDefaultDatasourceDriverFactory({ dev: factoryDev }).create({ driver: driverId, config: driverConfig });
1602
1603
  } catch (err) {
1603
1604
  if (dbDriver === "mongodb") {
1604
1605
  throw new Error(
@@ -1683,7 +1684,14 @@ var init_standalone_stack = __esm({
1683
1684
  * Explicit `databaseUrl` / `OS_DATABASE_URL` / `OS_HOME` still take
1684
1685
  * precedence over this default.
1685
1686
  */
1686
- projectRoot: z.string().optional()
1687
+ projectRoot: z.string().optional(),
1688
+ /**
1689
+ * Dev gate for the sqlite driver factory's native-better-sqlite3 → wasm →
1690
+ * in-memory step-down (#2229). When omitted, defaults to
1691
+ * `process.env.NODE_ENV === 'development'`. In production a native load
1692
+ * failure is NOT silently swapped for wasm/mingo (fail-closed).
1693
+ */
1694
+ dev: z.boolean().optional()
1687
1695
  });
1688
1696
  }
1689
1697
  });
@@ -2000,18 +2008,11 @@ function checkApiExposure(def, action) {
2000
2008
  return { allowed: true };
2001
2009
  }
2002
2010
 
2003
- // src/security/api-key.ts
2011
+ // src/security/resolve-execution-context.ts
2004
2012
  import {
2005
- API_KEY_PREFIX,
2006
- hashApiKey,
2007
- generateApiKey,
2008
- extractApiKey,
2009
- parseScopes,
2010
- isExpired,
2011
- resolveApiKeyPrincipal
2013
+ resolveAuthzContext,
2014
+ resolveLocalizationContext
2012
2015
  } from "@objectstack/core";
2013
-
2014
- // src/security/resolve-execution-context.ts
2015
2016
  function toHeaders(input) {
2016
2017
  if (!input) return new Headers();
2017
2018
  if (typeof Headers !== "undefined" && input instanceof Headers) return input;
@@ -2027,214 +2028,44 @@ function toHeaders(input) {
2027
2028
  }
2028
2029
  return h;
2029
2030
  }
2030
- function safeJsonParse2(s, fallback) {
2031
- try {
2032
- return JSON.parse(s);
2033
- } catch {
2034
- return fallback;
2035
- }
2036
- }
2037
- async function tryFind(ql, object, where, limit = 100) {
2038
- if (!ql || typeof ql.find !== "function") return [];
2039
- try {
2040
- let rows = await ql.find(object, { where, limit, context: { isSystem: true } });
2041
- if (rows && rows.value) rows = rows.value;
2042
- return Array.isArray(rows) ? rows : [];
2043
- } catch {
2044
- return [];
2045
- }
2046
- }
2047
- function isValidTimeZone(tz) {
2048
- try {
2049
- new Intl.DateTimeFormat("en-US", { timeZone: tz });
2050
- return true;
2051
- } catch {
2052
- return false;
2053
- }
2054
- }
2055
- function coerceTimeZone(value) {
2056
- const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
2057
- return s && isValidTimeZone(s) ? s : void 0;
2058
- }
2059
- function coerceLocale(value) {
2060
- const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
2061
- return s || void 0;
2062
- }
2063
- function coerceCurrency(value) {
2064
- const s = typeof value === "string" ? value.trim().toUpperCase() : "";
2065
- return /^[A-Z]{3}$/.test(s) ? s : void 0;
2066
- }
2067
- async function resolveLocalization(opts, ql, sctx) {
2068
- try {
2069
- const settings = await opts.getService("settings");
2070
- if (settings && typeof settings.get === "function") {
2071
- const [tzRes, localeRes, currencyRes] = await Promise.all([
2072
- settings.get("localization", "timezone", sctx).catch(() => void 0),
2073
- settings.get("localization", "locale", sctx).catch(() => void 0),
2074
- settings.get("localization", "currency", sctx).catch(() => void 0)
2075
- ]);
2076
- const tz = coerceTimeZone(tzRes?.value);
2077
- const locale = coerceLocale(localeRes?.value);
2078
- const currency = coerceCurrency(currencyRes?.value);
2079
- if (tz || locale || currency) return { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency };
2080
- }
2081
- } catch {
2082
- }
2083
- const tzRows = await tryFind(ql, "sys_setting", { namespace: "localization", key: "timezone", scope: "tenant" }, 1);
2084
- const localeRows = await tryFind(ql, "sys_setting", { namespace: "localization", key: "locale", scope: "tenant" }, 1);
2085
- const currencyRows = await tryFind(ql, "sys_setting", { namespace: "localization", key: "currency", scope: "tenant" }, 1);
2086
- return {
2087
- timezone: coerceTimeZone(tzRows[0]?.value) ?? "UTC",
2088
- locale: coerceLocale(localeRows[0]?.value) ?? "en-US",
2089
- currency: coerceCurrency(currencyRows[0]?.value)
2090
- };
2091
- }
2092
2031
  async function resolveExecutionContext(opts) {
2093
- const headers = opts.request?.headers;
2094
- const ctx = {
2095
- roles: [],
2096
- permissions: [],
2097
- systemPermissions: [],
2098
- isSystem: false
2099
- };
2100
- let userId;
2101
- let tenantId;
2102
- const keyPrincipal = await resolveApiKeyPrincipal(await opts.getQl(), headers);
2103
- if (keyPrincipal) {
2104
- userId = keyPrincipal.userId;
2105
- tenantId = keyPrincipal.tenantId;
2106
- for (const scope of keyPrincipal.scopes) {
2107
- if (!ctx.permissions.includes(scope)) ctx.permissions.push(scope);
2108
- }
2109
- }
2110
- if (!userId) {
2032
+ const headers = toHeaders(opts.request?.headers);
2033
+ const ql = await opts.getQl();
2034
+ const getSession = async (h) => {
2111
2035
  try {
2112
2036
  const authService = await opts.getService("auth");
2113
2037
  let api = authService?.api;
2114
- if (!api && typeof authService?.getApi === "function") {
2115
- api = await authService.getApi();
2116
- }
2117
- const headersInstance = toHeaders(headers);
2118
- const sessionData = await api?.getSession?.({ headers: headersInstance });
2119
- userId = sessionData?.user?.id ?? sessionData?.session?.userId;
2120
- tenantId = tenantId ?? sessionData?.session?.activeOrganizationId;
2121
- ctx.accessToken = sessionData?.session?.token ?? ctx.accessToken;
2122
- 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 });
2123
2040
  } catch {
2041
+ return void 0;
2124
2042
  }
2125
- }
2126
- if (userId) ctx.userId = userId;
2127
- if (tenantId) ctx.tenantId = tenantId;
2128
- if (!userId) return ctx;
2129
- const ql = await opts.getQl();
2130
- if (!ql) return ctx;
2131
- if (!ctx.email) {
2132
- const userRows = await tryFind(ql, "sys_user", { id: userId }, 1);
2133
- if (userRows[0]?.email) ctx.email = String(userRows[0].email);
2134
- }
2135
- const memberWhere = tenantId ? { user_id: userId, organization_id: tenantId } : { user_id: userId };
2136
- const members = await tryFind(ql, "sys_member", memberWhere, 50);
2137
- for (const m of members) {
2138
- if (m.role && typeof m.role === "string") {
2139
- for (const r of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
2140
- if (!ctx.roles.includes(r)) ctx.roles.push(r);
2141
- }
2142
- }
2143
- }
2144
- const userRoleRows = await tryFind(ql, "sys_user_role", { user_id: userId }, 200);
2145
- for (const ur of userRoleRows) {
2146
- const org = ur.organization_id ?? null;
2147
- if (org && tenantId && org !== tenantId) continue;
2148
- const r = ur.role;
2149
- if (typeof r === "string" && r && !ctx.roles.includes(r)) ctx.roles.push(r);
2150
- }
2151
- if (tenantId) {
2152
- const orgMembers = await tryFind(
2153
- ql,
2154
- "sys_member",
2155
- { organization_id: tenantId },
2156
- 1e3
2157
- );
2158
- const orgUserIds = Array.from(
2159
- new Set(
2160
- orgMembers.map((m) => m.user_id ?? m.userId).filter((v) => typeof v === "string" && v.length > 0)
2161
- )
2162
- );
2163
- if (!orgUserIds.includes(userId)) orgUserIds.push(userId);
2164
- ctx.org_user_ids = orgUserIds;
2165
- } else {
2166
- ctx.org_user_ids = [userId];
2167
- }
2168
- const upsRows = await tryFind(
2169
- ql,
2170
- "sys_user_permission_set",
2171
- tenantId ? { user_id: userId, organization_id: tenantId } : { user_id: userId },
2172
- 100
2173
- );
2174
- const psIds = new Set(
2175
- upsRows.map((r) => r.permission_set_id ?? r.permissionSetId).filter(Boolean)
2176
- );
2177
- if (ctx.roles.length > 0) {
2178
- const roleRows = await tryFind(ql, "sys_role", { name: { $in: ctx.roles } }, 100);
2179
- const roleIds = roleRows.map((r) => r.id).filter(Boolean);
2180
- if (roleIds.length > 0) {
2181
- const rpsRows = await tryFind(
2182
- ql,
2183
- "sys_role_permission_set",
2184
- { role_id: { $in: roleIds } },
2185
- 500
2186
- );
2187
- for (const r of rpsRows) {
2188
- const id = r.permission_set_id ?? r.permissionSetId;
2189
- if (id) psIds.add(id);
2190
- }
2191
- }
2192
- }
2193
- if (psIds.size > 0) {
2194
- 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({
2195
2060
  ql,
2196
- "sys_permission_set",
2197
- { id: { $in: Array.from(psIds) } },
2198
- 500
2199
- );
2200
- const tabRank = {
2201
- hidden: 0,
2202
- default_off: 1,
2203
- default_on: 2,
2204
- visible: 3
2205
- };
2206
- const mergedTabs = {};
2207
- for (const ps of psRows) {
2208
- if (ps.name && !ctx.permissions.includes(ps.name)) {
2209
- ctx.permissions.push(ps.name);
2210
- }
2211
- const sysPerms = typeof ps.system_permissions === "string" ? safeJsonParse2(ps.system_permissions, []) : ps.system_permissions ?? ps.systemPermissions;
2212
- if (Array.isArray(sysPerms)) {
2213
- for (const p of sysPerms) {
2214
- if (typeof p === "string" && !ctx.systemPermissions.includes(p)) {
2215
- ctx.systemPermissions.push(p);
2216
- }
2217
- }
2218
- }
2219
- const tabs = typeof ps.tab_permissions === "string" ? safeJsonParse2(ps.tab_permissions, {}) : ps.tab_permissions ?? ps.tabPermissions;
2220
- if (tabs && typeof tabs === "object") {
2221
- for (const [app, val] of Object.entries(tabs)) {
2222
- if (typeof val !== "string" || !(val in tabRank)) continue;
2223
- const cur = mergedTabs[app];
2224
- if (!cur || tabRank[val] > tabRank[cur]) {
2225
- mergedTabs[app] = val;
2226
- }
2227
- }
2228
- }
2229
- }
2230
- if (Object.keys(mergedTabs).length > 0) {
2231
- ctx.tabPermissions = mergedTabs;
2232
- }
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;
2233
2068
  }
2234
- const localization = await resolveLocalization(opts, ql, { tenantId, userId });
2235
- ctx.timezone = localization.timezone;
2236
- ctx.locale = localization.locale;
2237
- if (localization.currency) ctx.currency = localization.currency;
2238
2069
  return ctx;
2239
2070
  }
2240
2071
  function isPermissionDeniedError(e) {
@@ -2243,6 +2074,17 @@ function isPermissionDeniedError(e) {
2243
2074
  return anyE.name === "PermissionDeniedError" || anyE.code === "PERMISSION_DENIED" || typeof anyE.message === "string" && anyE.message.startsWith("[Security] Access denied");
2244
2075
  }
2245
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
+
2246
2088
  // src/http-dispatcher.ts
2247
2089
  function randomUUID() {
2248
2090
  if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
@@ -2254,6 +2096,9 @@ function randomUUID() {
2254
2096
  return v.toString(16);
2255
2097
  });
2256
2098
  }
2099
+ function isSystemObjectName(name) {
2100
+ return /^sys_/i.test(name);
2101
+ }
2257
2102
  var _HttpDispatcher = class _HttpDispatcher {
2258
2103
  /**
2259
2104
  * @param _envRegistryIgnored — RETIRED (ADR-0006 Phase 5). Environment
@@ -2371,7 +2216,10 @@ var _HttpDispatcher = class _HttpDispatcher {
2371
2216
  return extra ? { ...base, ...extra } : qlOpts ? base : void 0;
2372
2217
  };
2373
2218
  if (action === "create") {
2374
- 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") {
2375
2223
  const res = await ql.insert(params.object, params.data, qlOpts);
2376
2224
  const record = { ...params.data, ...res };
2377
2225
  return { object: params.object, id: record.id, record };
@@ -2392,7 +2240,10 @@ var _HttpDispatcher = class _HttpDispatcher {
2392
2240
  throw { statusCode: 503, message: "Data service not available" };
2393
2241
  }
2394
2242
  if (action === "update") {
2395
- 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") {
2396
2247
  let all = await ql.find(params.object, findOpts({ where: { id: params.id }, limit: 1 }));
2397
2248
  if (all && all.value) all = all.value;
2398
2249
  if (!all) all = [];
@@ -2404,7 +2255,10 @@ var _HttpDispatcher = class _HttpDispatcher {
2404
2255
  throw { statusCode: 503, message: "Data service not available" };
2405
2256
  }
2406
2257
  if (action === "delete") {
2407
- 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") {
2408
2262
  await ql.delete(params.object, findOpts({ where: { id: params.id } }));
2409
2263
  return { object: params.object, id: params.id, deleted: true };
2410
2264
  }
@@ -2596,9 +2450,269 @@ var _HttpDispatcher = class _HttpDispatcher {
2596
2450
  },
2597
2451
  create: async (object, data) => await callData("create", { object, data }, driver, envId, ec),
2598
2452
  update: async (object, id, data) => await callData("update", { object, id, data }, driver, envId, ec),
2599
- 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 })
2600
2480
  };
2601
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
+ }
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
+ }
2602
2716
  /**
2603
2717
  * Generate a `sys_api_key` and return the raw secret EXACTLY ONCE
2604
2718
  * (`POST /keys`). This is the only mint path — the raw key is never stored
@@ -3579,6 +3693,61 @@ var _HttpDispatcher = class _HttpDispatcher {
3579
3693
  }
3580
3694
  return { handled: true, response: this.error("Draft discarding not supported", 501) };
3581
3695
  }
3696
+ if (parts.length === 2 && parts[1] === "commits" && m === "GET") {
3697
+ const id = decodeURIComponent(parts[0]);
3698
+ const protocol = await this.resolveService("protocol");
3699
+ if (protocol && typeof protocol.listCommits === "function") {
3700
+ try {
3701
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3702
+ const commits = await protocol.listCommits({
3703
+ packageId: id,
3704
+ ...organizationId ? { organizationId } : {}
3705
+ });
3706
+ return { handled: true, response: this.success({ commits }) };
3707
+ } catch (e) {
3708
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3709
+ }
3710
+ }
3711
+ return { handled: true, response: this.error("Commit history not supported", 501) };
3712
+ }
3713
+ if (parts.length === 4 && parts[1] === "commits" && parts[3] === "revert" && m === "POST") {
3714
+ const commitId = decodeURIComponent(parts[2]);
3715
+ const protocol = await this.resolveService("protocol");
3716
+ if (protocol && typeof protocol.revertCommit === "function") {
3717
+ try {
3718
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3719
+ const result = await protocol.revertCommit({
3720
+ commitId,
3721
+ ...organizationId ? { organizationId } : {},
3722
+ ...body?.actor ? { actor: body.actor } : {}
3723
+ });
3724
+ return { handled: true, response: this.success(result) };
3725
+ } catch (e) {
3726
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3727
+ }
3728
+ }
3729
+ return { handled: true, response: this.error("Commit revert not supported", 501) };
3730
+ }
3731
+ if (parts.length === 2 && parts[1] === "rollback" && m === "POST") {
3732
+ const protocol = await this.resolveService("protocol");
3733
+ if (protocol && typeof protocol.rollbackToPackageCommit === "function") {
3734
+ if (!body?.commitId) {
3735
+ return { handled: true, response: this.error("Body { commitId } is required", 400) };
3736
+ }
3737
+ try {
3738
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3739
+ const result = await protocol.rollbackToPackageCommit({
3740
+ commitId: String(body.commitId),
3741
+ ...organizationId ? { organizationId } : {},
3742
+ ...body?.actor ? { actor: body.actor } : {}
3743
+ });
3744
+ return { handled: true, response: this.success(result) };
3745
+ } catch (e) {
3746
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3747
+ }
3748
+ }
3749
+ return { handled: true, response: this.error("Commit rollback not supported", 501) };
3750
+ }
3582
3751
  if (parts.length === 2 && parts[1] === "revert" && m === "POST") {
3583
3752
  const id = decodeURIComponent(parts[0]);
3584
3753
  const metadataService = await this.getService(CoreServiceName.enum.metadata);
@@ -3596,6 +3765,49 @@ var _HttpDispatcher = class _HttpDispatcher {
3596
3765
  }
3597
3766
  return { handled: true, response: this.success(manifest) };
3598
3767
  }
3768
+ if (parts.length === 2 && parts[1] === "adopt-orphans" && m === "POST") {
3769
+ const id = decodeURIComponent(parts[0]);
3770
+ const protocol = await this.resolveService("protocol");
3771
+ if (!protocol || typeof protocol.reassignOrphanedMetadata !== "function") {
3772
+ return { handled: true, response: this.error("Orphan adoption not supported", 501) };
3773
+ }
3774
+ try {
3775
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3776
+ const result = await protocol.reassignOrphanedMetadata({
3777
+ targetPackageId: id,
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
+ if (parts.length === 2 && parts[1] === "duplicate" && m === "POST") {
3787
+ const id = decodeURIComponent(parts[0]);
3788
+ const protocol = await this.resolveService("protocol");
3789
+ if (!protocol || typeof protocol.duplicatePackage !== "function") {
3790
+ return { handled: true, response: this.error("Package duplication not supported", 501) };
3791
+ }
3792
+ const targetPackageId = typeof body?.targetPackageId === "string" ? body.targetPackageId.trim() : "";
3793
+ if (!targetPackageId) {
3794
+ return { handled: true, response: this.error("Body { targetPackageId } is required", 400) };
3795
+ }
3796
+ try {
3797
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3798
+ const result = await protocol.duplicatePackage({
3799
+ sourcePackageId: id,
3800
+ targetPackageId,
3801
+ ...typeof body?.targetName === "string" ? { targetName: body.targetName } : {},
3802
+ ...typeof body?.targetNamespace === "string" ? { targetNamespace: body.targetNamespace } : {},
3803
+ ...organizationId ? { organizationId } : {},
3804
+ ...body?.actor ? { actor: body.actor } : {}
3805
+ });
3806
+ return { handled: true, response: this.success(result) };
3807
+ } catch (e) {
3808
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3809
+ }
3810
+ }
3599
3811
  if (parts.length === 1 && m === "GET") {
3600
3812
  const id = decodeURIComponent(parts[0]);
3601
3813
  const pkg = registry.getPackage(id);
@@ -3999,8 +4211,12 @@ var _HttpDispatcher = class _HttpDispatcher {
3999
4211
  object: objectName,
4000
4212
  event: ctxBody.event ?? "manual"
4001
4213
  };
4002
- const userIdFromAuth = context?.user?.id ?? context?.userId;
4214
+ const ec = context?.executionContext;
4215
+ const userIdFromAuth = context?.user?.id ?? context?.userId ?? ec?.userId;
4003
4216
  if (userIdFromAuth) automationContext.userId = userIdFromAuth;
4217
+ if (Array.isArray(ec?.roles) && ec.roles.length) automationContext.roles = ec.roles;
4218
+ if (Array.isArray(ec?.permissions) && ec.permissions.length) automationContext.permissions = ec.permissions;
4219
+ if (ec?.tenantId) automationContext.tenantId = ec.tenantId;
4004
4220
  const result = await automationService.execute(name, automationContext);
4005
4221
  return { handled: true, response: this.success(result) };
4006
4222
  }
@@ -4178,6 +4394,15 @@ var _HttpDispatcher = class _HttpDispatcher {
4178
4394
  if (!ql || typeof ql.executeAction !== "function") {
4179
4395
  return { handled: true, response: this.error("Data engine not available", 503) };
4180
4396
  }
4397
+ try {
4398
+ const actionSchema = (typeof ql.getSchema === "function" ? ql.getSchema(objectName) : void 0) ?? ql.registry?.getObject?.(objectName);
4399
+ const actionDef = Array.isArray(actionSchema?.actions) ? actionSchema.actions.find((a) => a?.name === actionName) : void 0;
4400
+ const gateError = this.actionPermissionError(actionDef, _context?.executionContext, objectName);
4401
+ if (gateError) {
4402
+ return { handled: true, response: this.error(gateError, 403) };
4403
+ }
4404
+ } catch {
4405
+ }
4181
4406
  const tryExecute = async (obj) => {
4182
4407
  return ql.executeAction(obj, actionName, actionContext);
4183
4408
  };
@@ -4517,7 +4742,23 @@ var _HttpDispatcher = class _HttpDispatcher {
4517
4742
  try {
4518
4743
  context.executionContext = await resolveExecutionContext({
4519
4744
  getService: (n) => this.resolveService(n, context.environmentId),
4520
- getQl: () => Promise.resolve(this.getObjectQLService(context.environmentId)),
4745
+ // Resolve ObjectQL from the per-request kernel DIRECTLY. The scoped
4746
+ // `resolveService('objectql', envId)` factory can return a different
4747
+ // instance that doesn't see THIS env's rows (the gotcha
4748
+ // `handleActions` works around) — which made the api-key lookup miss
4749
+ // `sys_api_key` on the MCP path and reject valid keys with 401, while
4750
+ // REST accepted them (rest-server resolves identity via
4751
+ // `kernel.getServiceAsync('objectql')`). Resolving off `this.kernel`
4752
+ // keeps REST + MCP identity resolution aligned; falls back to the
4753
+ // scoped path when the kernel can't hand back an objectql directly.
4754
+ getQl: async () => {
4755
+ const k = this.kernel;
4756
+ if (k && typeof k.getServiceAsync === "function") {
4757
+ const ql = await k.getServiceAsync("objectql").catch(() => void 0);
4758
+ if (ql && (ql.registry || typeof ql.find === "function")) return ql;
4759
+ }
4760
+ return this.getObjectQLService(context.environmentId);
4761
+ },
4521
4762
  request: context.request
4522
4763
  });
4523
4764
  } catch {
@@ -5240,6 +5481,14 @@ function createDispatcherPlugin(config = {}) {
5240
5481
  errorResponse(err, res);
5241
5482
  }
5242
5483
  });
5484
+ server.get(`${prefix}/ready`, async (_req, res) => {
5485
+ try {
5486
+ const result = await dispatcher.dispatch("GET", "/ready", void 0, {}, { request: _req });
5487
+ sendResult(result, res);
5488
+ } catch (err) {
5489
+ errorResponse(err, res);
5490
+ }
5491
+ });
5243
5492
  server.post(`${prefix}/auth/login`, async (req, res) => {
5244
5493
  try {
5245
5494
  const result = await dispatcher.handleAuth("login", "POST", req.body, { request: req });
@@ -5982,7 +6231,7 @@ import {
5982
6231
  createRestApiPlugin
5983
6232
  } from "@objectstack/rest";
5984
6233
  export * from "@objectstack/core";
5985
- import { readEnvWithDeprecation as readEnvWithDeprecation3, _resetEnvDeprecationWarnings } from "@objectstack/types";
6234
+ import { readEnvWithDeprecation as readEnvWithDeprecation2, _resetEnvDeprecationWarnings } from "@objectstack/types";
5986
6235
  export {
5987
6236
  AppPlugin,
5988
6237
  DEFAULT_RATE_LIMITS,
@@ -6031,7 +6280,7 @@ export {
6031
6280
  mergeRuntimeModule,
6032
6281
  parseTraceparent,
6033
6282
  readArtifactSource,
6034
- readEnvWithDeprecation3 as readEnvWithDeprecation,
6283
+ readEnvWithDeprecation2 as readEnvWithDeprecation,
6035
6284
  resolveDefaultArtifactPath,
6036
6285
  resolveErrorReporter,
6037
6286
  resolveMetrics,