@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.cjs CHANGED
@@ -1278,7 +1278,7 @@ var init_app_plugin = __esm({
1278
1278
  } catch (e) {
1279
1279
  ctx.logger.warn("[Seeder] Failed to register seed-datasets/seed-replayer service", { error: e?.message });
1280
1280
  }
1281
- const multiTenant = String((0, import_types.readEnvWithDeprecation)("OS_MULTI_ORG_ENABLED", "OS_MULTI_TENANT") ?? "false").toLowerCase() !== "false";
1281
+ const multiTenant = (0, import_types.resolveMultiOrgEnabled)();
1282
1282
  if (multiTenant) {
1283
1283
  ctx.logger.info("[Seeder] multi-tenant mode \u2014 skipping inline seed; per-org replay will run on sys_organization insert");
1284
1284
  } else {
@@ -1593,6 +1593,7 @@ async function createStandaloneStack(config) {
1593
1593
  );
1594
1594
  } else {
1595
1595
  const { createDefaultDatasourceDriverFactory } = await import("@objectstack/service-datasource");
1596
+ const factoryDev = cfg.dev ?? process.env.NODE_ENV === "development";
1596
1597
  let driverId;
1597
1598
  let driverConfig;
1598
1599
  if (dbDriver === "memory") {
@@ -1617,7 +1618,7 @@ async function createStandaloneStack(config) {
1617
1618
  }
1618
1619
  let driverHandle;
1619
1620
  try {
1620
- driverHandle = await createDefaultDatasourceDriverFactory().create({ driver: driverId, config: driverConfig });
1621
+ driverHandle = await createDefaultDatasourceDriverFactory({ dev: factoryDev }).create({ driver: driverId, config: driverConfig });
1621
1622
  } catch (err) {
1622
1623
  if (dbDriver === "mongodb") {
1623
1624
  throw new Error(
@@ -1707,7 +1708,14 @@ var init_standalone_stack = __esm({
1707
1708
  * Explicit `databaseUrl` / `OS_DATABASE_URL` / `OS_HOME` still take
1708
1709
  * precedence over this default.
1709
1710
  */
1710
- projectRoot: import_zod.z.string().optional()
1711
+ projectRoot: import_zod.z.string().optional(),
1712
+ /**
1713
+ * Dev gate for the sqlite driver factory's native-better-sqlite3 → wasm →
1714
+ * in-memory step-down (#2229). When omitted, defaults to
1715
+ * `process.env.NODE_ENV === 'development'`. In production a native load
1716
+ * failure is NOT silently swapped for wasm/mingo (fail-closed).
1717
+ */
1718
+ dev: import_zod.z.boolean().optional()
1711
1719
  });
1712
1720
  }
1713
1721
  });
@@ -1728,7 +1736,7 @@ __export(index_exports, {
1728
1736
  NoopMetricsRegistry: () => import_observability.NoopMetricsRegistry,
1729
1737
  OBSERVABILITY_ERRORS_SERVICE: () => import_observability3.OBSERVABILITY_ERRORS_SERVICE,
1730
1738
  OBSERVABILITY_METRICS_SERVICE: () => import_observability3.OBSERVABILITY_METRICS_SERVICE,
1731
- ObjectKernel: () => import_core4.ObjectKernel,
1739
+ ObjectKernel: () => import_core5.ObjectKernel,
1732
1740
  ObservabilityServicePlugin: () => ObservabilityServicePlugin,
1733
1741
  QuickJSScriptRunner: () => QuickJSScriptRunner,
1734
1742
  RUNTIME_METRICS: () => import_observability.RUNTIME_METRICS,
@@ -1770,7 +1778,7 @@ __export(index_exports, {
1770
1778
  resolveRequestId: () => resolveRequestId
1771
1779
  });
1772
1780
  module.exports = __toCommonJS(index_exports);
1773
- var import_core4 = require("@objectstack/core");
1781
+ var import_core5 = require("@objectstack/core");
1774
1782
 
1775
1783
  // src/runtime.ts
1776
1784
  var import_core = require("@objectstack/core");
@@ -2042,7 +2050,7 @@ function safeGet(ctx, name) {
2042
2050
  }
2043
2051
 
2044
2052
  // src/http-dispatcher.ts
2045
- var import_core3 = require("@objectstack/core");
2053
+ var import_core4 = require("@objectstack/core");
2046
2054
  var import_system = require("@objectstack/spec/system");
2047
2055
  var import_shared2 = require("@objectstack/spec/shared");
2048
2056
  init_package_state_store();
@@ -2076,10 +2084,8 @@ function checkApiExposure(def, action) {
2076
2084
  return { allowed: true };
2077
2085
  }
2078
2086
 
2079
- // src/security/api-key.ts
2080
- var import_core2 = require("@objectstack/core");
2081
-
2082
2087
  // src/security/resolve-execution-context.ts
2088
+ var import_core2 = require("@objectstack/core");
2083
2089
  function toHeaders(input) {
2084
2090
  if (!input) return new Headers();
2085
2091
  if (typeof Headers !== "undefined" && input instanceof Headers) return input;
@@ -2095,214 +2101,44 @@ function toHeaders(input) {
2095
2101
  }
2096
2102
  return h;
2097
2103
  }
2098
- function safeJsonParse2(s, fallback) {
2099
- try {
2100
- return JSON.parse(s);
2101
- } catch {
2102
- return fallback;
2103
- }
2104
- }
2105
- async function tryFind(ql, object, where, limit = 100) {
2106
- if (!ql || typeof ql.find !== "function") return [];
2107
- try {
2108
- let rows = await ql.find(object, { where, limit, context: { isSystem: true } });
2109
- if (rows && rows.value) rows = rows.value;
2110
- return Array.isArray(rows) ? rows : [];
2111
- } catch {
2112
- return [];
2113
- }
2114
- }
2115
- function isValidTimeZone(tz) {
2116
- try {
2117
- new Intl.DateTimeFormat("en-US", { timeZone: tz });
2118
- return true;
2119
- } catch {
2120
- return false;
2121
- }
2122
- }
2123
- function coerceTimeZone(value) {
2124
- const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
2125
- return s && isValidTimeZone(s) ? s : void 0;
2126
- }
2127
- function coerceLocale(value) {
2128
- const s = typeof value === "string" ? value.trim() : value != null ? String(value).trim() : "";
2129
- return s || void 0;
2130
- }
2131
- function coerceCurrency(value) {
2132
- const s = typeof value === "string" ? value.trim().toUpperCase() : "";
2133
- return /^[A-Z]{3}$/.test(s) ? s : void 0;
2134
- }
2135
- async function resolveLocalization(opts, ql, sctx) {
2136
- try {
2137
- const settings = await opts.getService("settings");
2138
- if (settings && typeof settings.get === "function") {
2139
- const [tzRes, localeRes, currencyRes] = await Promise.all([
2140
- settings.get("localization", "timezone", sctx).catch(() => void 0),
2141
- settings.get("localization", "locale", sctx).catch(() => void 0),
2142
- settings.get("localization", "currency", sctx).catch(() => void 0)
2143
- ]);
2144
- const tz = coerceTimeZone(tzRes?.value);
2145
- const locale = coerceLocale(localeRes?.value);
2146
- const currency = coerceCurrency(currencyRes?.value);
2147
- if (tz || locale || currency) return { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency };
2148
- }
2149
- } catch {
2150
- }
2151
- const tzRows = await tryFind(ql, "sys_setting", { namespace: "localization", key: "timezone", scope: "tenant" }, 1);
2152
- const localeRows = await tryFind(ql, "sys_setting", { namespace: "localization", key: "locale", scope: "tenant" }, 1);
2153
- const currencyRows = await tryFind(ql, "sys_setting", { namespace: "localization", key: "currency", scope: "tenant" }, 1);
2154
- return {
2155
- timezone: coerceTimeZone(tzRows[0]?.value) ?? "UTC",
2156
- locale: coerceLocale(localeRows[0]?.value) ?? "en-US",
2157
- currency: coerceCurrency(currencyRows[0]?.value)
2158
- };
2159
- }
2160
2104
  async function resolveExecutionContext(opts) {
2161
- const headers = opts.request?.headers;
2162
- const ctx = {
2163
- roles: [],
2164
- permissions: [],
2165
- systemPermissions: [],
2166
- isSystem: false
2167
- };
2168
- let userId;
2169
- let tenantId;
2170
- const keyPrincipal = await (0, import_core2.resolveApiKeyPrincipal)(await opts.getQl(), headers);
2171
- if (keyPrincipal) {
2172
- userId = keyPrincipal.userId;
2173
- tenantId = keyPrincipal.tenantId;
2174
- for (const scope of keyPrincipal.scopes) {
2175
- if (!ctx.permissions.includes(scope)) ctx.permissions.push(scope);
2176
- }
2177
- }
2178
- if (!userId) {
2105
+ const headers = toHeaders(opts.request?.headers);
2106
+ const ql = await opts.getQl();
2107
+ const getSession = async (h) => {
2179
2108
  try {
2180
2109
  const authService = await opts.getService("auth");
2181
2110
  let api = authService?.api;
2182
- if (!api && typeof authService?.getApi === "function") {
2183
- api = await authService.getApi();
2184
- }
2185
- const headersInstance = toHeaders(headers);
2186
- const sessionData = await api?.getSession?.({ headers: headersInstance });
2187
- userId = sessionData?.user?.id ?? sessionData?.session?.userId;
2188
- tenantId = tenantId ?? sessionData?.session?.activeOrganizationId;
2189
- ctx.accessToken = sessionData?.session?.token ?? ctx.accessToken;
2190
- if (sessionData?.user?.email) ctx.email = String(sessionData.user.email);
2111
+ if (!api && typeof authService?.getApi === "function") api = await authService.getApi();
2112
+ return await api?.getSession?.({ headers: h });
2191
2113
  } catch {
2114
+ return void 0;
2192
2115
  }
2193
- }
2194
- if (userId) ctx.userId = userId;
2195
- if (tenantId) ctx.tenantId = tenantId;
2196
- if (!userId) return ctx;
2197
- const ql = await opts.getQl();
2198
- if (!ql) return ctx;
2199
- if (!ctx.email) {
2200
- const userRows = await tryFind(ql, "sys_user", { id: userId }, 1);
2201
- if (userRows[0]?.email) ctx.email = String(userRows[0].email);
2202
- }
2203
- const memberWhere = tenantId ? { user_id: userId, organization_id: tenantId } : { user_id: userId };
2204
- const members = await tryFind(ql, "sys_member", memberWhere, 50);
2205
- for (const m of members) {
2206
- if (m.role && typeof m.role === "string") {
2207
- for (const r of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
2208
- if (!ctx.roles.includes(r)) ctx.roles.push(r);
2209
- }
2210
- }
2211
- }
2212
- const userRoleRows = await tryFind(ql, "sys_user_role", { user_id: userId }, 200);
2213
- for (const ur of userRoleRows) {
2214
- const org = ur.organization_id ?? null;
2215
- if (org && tenantId && org !== tenantId) continue;
2216
- const r = ur.role;
2217
- if (typeof r === "string" && r && !ctx.roles.includes(r)) ctx.roles.push(r);
2218
- }
2219
- if (tenantId) {
2220
- const orgMembers = await tryFind(
2221
- ql,
2222
- "sys_member",
2223
- { organization_id: tenantId },
2224
- 1e3
2225
- );
2226
- const orgUserIds = Array.from(
2227
- new Set(
2228
- orgMembers.map((m) => m.user_id ?? m.userId).filter((v) => typeof v === "string" && v.length > 0)
2229
- )
2230
- );
2231
- if (!orgUserIds.includes(userId)) orgUserIds.push(userId);
2232
- ctx.org_user_ids = orgUserIds;
2233
- } else {
2234
- ctx.org_user_ids = [userId];
2235
- }
2236
- const upsRows = await tryFind(
2237
- ql,
2238
- "sys_user_permission_set",
2239
- tenantId ? { user_id: userId, organization_id: tenantId } : { user_id: userId },
2240
- 100
2241
- );
2242
- const psIds = new Set(
2243
- upsRows.map((r) => r.permission_set_id ?? r.permissionSetId).filter(Boolean)
2244
- );
2245
- if (ctx.roles.length > 0) {
2246
- const roleRows = await tryFind(ql, "sys_role", { name: { $in: ctx.roles } }, 100);
2247
- const roleIds = roleRows.map((r) => r.id).filter(Boolean);
2248
- if (roleIds.length > 0) {
2249
- const rpsRows = await tryFind(
2250
- ql,
2251
- "sys_role_permission_set",
2252
- { role_id: { $in: roleIds } },
2253
- 500
2254
- );
2255
- for (const r of rpsRows) {
2256
- const id = r.permission_set_id ?? r.permissionSetId;
2257
- if (id) psIds.add(id);
2258
- }
2259
- }
2260
- }
2261
- if (psIds.size > 0) {
2262
- const psRows = await tryFind(
2116
+ };
2117
+ const authz = await (0, import_core2.resolveAuthzContext)({ ql, headers, getSession });
2118
+ const ctx = {
2119
+ roles: authz.roles,
2120
+ permissions: authz.permissions,
2121
+ systemPermissions: authz.systemPermissions,
2122
+ isSystem: false
2123
+ };
2124
+ if (authz.userId) ctx.userId = authz.userId;
2125
+ if (authz.tenantId) ctx.tenantId = authz.tenantId;
2126
+ if (authz.email) ctx.email = authz.email;
2127
+ if (authz.accessToken) ctx.accessToken = authz.accessToken;
2128
+ if (authz.tabPermissions) ctx.tabPermissions = authz.tabPermissions;
2129
+ ctx.org_user_ids = authz.org_user_ids;
2130
+ if (authz.userId) {
2131
+ const settings = await Promise.resolve(opts.getService("settings")).catch(() => void 0);
2132
+ const localization = await (0, import_core2.resolveLocalizationContext)({
2263
2133
  ql,
2264
- "sys_permission_set",
2265
- { id: { $in: Array.from(psIds) } },
2266
- 500
2267
- );
2268
- const tabRank = {
2269
- hidden: 0,
2270
- default_off: 1,
2271
- default_on: 2,
2272
- visible: 3
2273
- };
2274
- const mergedTabs = {};
2275
- for (const ps of psRows) {
2276
- if (ps.name && !ctx.permissions.includes(ps.name)) {
2277
- ctx.permissions.push(ps.name);
2278
- }
2279
- const sysPerms = typeof ps.system_permissions === "string" ? safeJsonParse2(ps.system_permissions, []) : ps.system_permissions ?? ps.systemPermissions;
2280
- if (Array.isArray(sysPerms)) {
2281
- for (const p of sysPerms) {
2282
- if (typeof p === "string" && !ctx.systemPermissions.includes(p)) {
2283
- ctx.systemPermissions.push(p);
2284
- }
2285
- }
2286
- }
2287
- const tabs = typeof ps.tab_permissions === "string" ? safeJsonParse2(ps.tab_permissions, {}) : ps.tab_permissions ?? ps.tabPermissions;
2288
- if (tabs && typeof tabs === "object") {
2289
- for (const [app, val] of Object.entries(tabs)) {
2290
- if (typeof val !== "string" || !(val in tabRank)) continue;
2291
- const cur = mergedTabs[app];
2292
- if (!cur || tabRank[val] > tabRank[cur]) {
2293
- mergedTabs[app] = val;
2294
- }
2295
- }
2296
- }
2297
- }
2298
- if (Object.keys(mergedTabs).length > 0) {
2299
- ctx.tabPermissions = mergedTabs;
2300
- }
2134
+ settings,
2135
+ tenantId: authz.tenantId,
2136
+ userId: authz.userId
2137
+ });
2138
+ ctx.timezone = localization.timezone;
2139
+ ctx.locale = localization.locale;
2140
+ if (localization.currency) ctx.currency = localization.currency;
2301
2141
  }
2302
- const localization = await resolveLocalization(opts, ql, { tenantId, userId });
2303
- ctx.timezone = localization.timezone;
2304
- ctx.locale = localization.locale;
2305
- if (localization.currency) ctx.currency = localization.currency;
2306
2142
  return ctx;
2307
2143
  }
2308
2144
  function isPermissionDeniedError(e) {
@@ -2311,6 +2147,9 @@ function isPermissionDeniedError(e) {
2311
2147
  return anyE.name === "PermissionDeniedError" || anyE.code === "PERMISSION_DENIED" || typeof anyE.message === "string" && anyE.message.startsWith("[Security] Access denied");
2312
2148
  }
2313
2149
 
2150
+ // src/security/api-key.ts
2151
+ var import_core3 = require("@objectstack/core");
2152
+
2314
2153
  // src/http-dispatcher.ts
2315
2154
  function randomUUID() {
2316
2155
  if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
@@ -2322,6 +2161,9 @@ function randomUUID() {
2322
2161
  return v.toString(16);
2323
2162
  });
2324
2163
  }
2164
+ function isSystemObjectName(name) {
2165
+ return /^sys_/i.test(name);
2166
+ }
2325
2167
  var _HttpDispatcher = class _HttpDispatcher {
2326
2168
  /**
2327
2169
  * @param _envRegistryIgnored — RETIRED (ADR-0006 Phase 5). Environment
@@ -2439,7 +2281,10 @@ var _HttpDispatcher = class _HttpDispatcher {
2439
2281
  return extra ? { ...base, ...extra } : qlOpts ? base : void 0;
2440
2282
  };
2441
2283
  if (action === "create") {
2442
- if (ql) {
2284
+ if (protocol && typeof protocol.createData === "function") {
2285
+ return await protocol.createData({ object: params.object, data: params.data, ...scopeId ? { environmentId: scopeId } : {}, context: executionContext });
2286
+ }
2287
+ if (ql && typeof ql.insert === "function") {
2443
2288
  const res = await ql.insert(params.object, params.data, qlOpts);
2444
2289
  const record = { ...params.data, ...res };
2445
2290
  return { object: params.object, id: record.id, record };
@@ -2460,7 +2305,10 @@ var _HttpDispatcher = class _HttpDispatcher {
2460
2305
  throw { statusCode: 503, message: "Data service not available" };
2461
2306
  }
2462
2307
  if (action === "update") {
2463
- if (ql && params.id) {
2308
+ if (protocol && typeof protocol.updateData === "function") {
2309
+ return await protocol.updateData({ object: params.object, id: params.id, data: params.data, ...scopeId ? { environmentId: scopeId } : {}, context: executionContext });
2310
+ }
2311
+ if (ql && params.id && typeof ql.update === "function") {
2464
2312
  let all = await ql.find(params.object, findOpts({ where: { id: params.id }, limit: 1 }));
2465
2313
  if (all && all.value) all = all.value;
2466
2314
  if (!all) all = [];
@@ -2472,7 +2320,10 @@ var _HttpDispatcher = class _HttpDispatcher {
2472
2320
  throw { statusCode: 503, message: "Data service not available" };
2473
2321
  }
2474
2322
  if (action === "delete") {
2475
- if (ql) {
2323
+ if (protocol && typeof protocol.deleteData === "function") {
2324
+ return await protocol.deleteData({ object: params.object, id: params.id, ...scopeId ? { environmentId: scopeId } : {}, context: executionContext });
2325
+ }
2326
+ if (ql && typeof ql.delete === "function") {
2476
2327
  await ql.delete(params.object, findOpts({ where: { id: params.id } }));
2477
2328
  return { object: params.object, id: params.id, deleted: true };
2478
2329
  }
@@ -2664,9 +2515,269 @@ var _HttpDispatcher = class _HttpDispatcher {
2664
2515
  },
2665
2516
  create: async (object, data) => await callData("create", { object, data }, driver, envId, ec),
2666
2517
  update: async (object, id, data) => await callData("update", { object, id, data }, driver, envId, ec),
2667
- remove: async (object, id) => await callData("delete", { object, id }, driver, envId, ec)
2518
+ remove: async (object, id) => await callData("delete", { object, id }, driver, envId, ec),
2519
+ // ── Business-action surface (McpActionBridge) ──────────────
2520
+ // Resolution + dispatch flow through the framework's own action
2521
+ // mechanism (engine.executeAction / automation flow runner) bound to
2522
+ // THIS request's ExecutionContext — the same permission + RLS path
2523
+ // the REST `/actions/...` route uses. No `@objectstack/service-ai`.
2524
+ listActions: async () => {
2525
+ const meta = await getMeta();
2526
+ const objs = await meta?.listObjects?.() ?? [];
2527
+ const hasAutomation = Boolean(
2528
+ await this.resolveService("automation", envId).catch(() => null)
2529
+ );
2530
+ const out = [];
2531
+ for (const obj of objs) {
2532
+ const objectName = obj?.name;
2533
+ if (!objectName || isSystemObjectName(objectName)) continue;
2534
+ const actions = Array.isArray(obj?.actions) ? obj.actions : [];
2535
+ for (const action of actions) {
2536
+ if (!action || typeof action.name !== "string") continue;
2537
+ if (!this.isHeadlessInvokableAction(action, hasAutomation)) continue;
2538
+ if (this.actionPermissionError(action, ec)) continue;
2539
+ out.push(this.summarizeAction(action, obj, objectName));
2540
+ }
2541
+ }
2542
+ return out;
2543
+ },
2544
+ runAction: async (name, input) => this.invokeBusinessAction(name, input ?? {}, { driver, envId, ec, getMeta, callData })
2668
2545
  };
2669
2546
  }
2547
+ // ── MCP action bridge helpers ──────────────────────────────────────
2548
+ /**
2549
+ * [ADR-0066 D4] Shared capability gate for an action invocation. Returns a
2550
+ * human-readable error string when the caller's `systemPermissions` don't
2551
+ * cover the action's declared `requiredPermissions`, or `null` when allowed.
2552
+ * System/engine self-invocation (`isSystem`) bypasses; an action without
2553
+ * `requiredPermissions` is ungated. Single-sourced so the REST `/actions/...`
2554
+ * route and the MCP `run_action` bridge enforce the SAME declaration.
2555
+ */
2556
+ actionPermissionError(actionDef, ec, objectName) {
2557
+ const required = Array.isArray(actionDef?.requiredPermissions) ? actionDef.requiredPermissions : [];
2558
+ if (required.length === 0) return null;
2559
+ if (ec?.isSystem) return null;
2560
+ const held = new Set(ec?.systemPermissions ?? []);
2561
+ const missing = required.filter((perm) => !held.has(perm));
2562
+ if (missing.length === 0) return null;
2563
+ const on = objectName ? ` on '${objectName}'` : "";
2564
+ return `Action '${actionDef?.name ?? "unknown"}'${on} requires capability [${required.join(", ")}] \u2014 caller is missing [${missing.join(", ")}]`;
2565
+ }
2566
+ /**
2567
+ * Whether an action has a headless invocation path (so MCP can run it).
2568
+ * Mirrors the supported-type set of the (now cloud-side) action-tools
2569
+ * bridge: `script` needs a handler binding (`target`) or an inline `body`;
2570
+ * `flow` needs a `target` and an automation service. UI-only types
2571
+ * (`url`, `modal`, `form`) and `api` have no server dispatch here.
2572
+ */
2573
+ isHeadlessInvokableAction(action, hasAutomation) {
2574
+ const type = action?.type ?? "script";
2575
+ if (type === "script") return Boolean(action?.target || action?.body);
2576
+ if (type === "flow") return Boolean(action?.target) && hasAutomation;
2577
+ return false;
2578
+ }
2579
+ /** True when an action is destructive by author signal/heuristic (HITL hint). */
2580
+ actionLooksDestructive(action) {
2581
+ if (action?.ai?.requiresConfirmation !== void 0) return Boolean(action.ai.requiresConfirmation);
2582
+ return Boolean(action?.confirmText || action?.mode === "delete" || action?.variant === "danger");
2583
+ }
2584
+ /** Project an action's declarative metadata into a lean MCP summary. */
2585
+ summarizeAction(action, obj, objectName) {
2586
+ const requiresRecord = Array.isArray(action?.locations) && action.locations.some(
2587
+ (l) => l === "list_item" || l === "record_header" || l === "record_more" || l === "record_related"
2588
+ );
2589
+ const description = (typeof action?.ai?.description === "string" ? action.ai.description : void 0) ?? (typeof action?.label === "string" ? action.label : void 0);
2590
+ const params = this.summarizeActionParams(action, obj);
2591
+ return {
2592
+ name: action.name,
2593
+ objectName,
2594
+ ...typeof action?.label === "string" ? { label: action.label } : {},
2595
+ ...description ? { description } : {},
2596
+ type: action?.type ?? "script",
2597
+ requiresRecord: Boolean(requiresRecord),
2598
+ requiresConfirmation: this.actionLooksDestructive(action),
2599
+ ...params.length > 0 ? { params } : {}
2600
+ };
2601
+ }
2602
+ /** Map an ObjectStack field type to a JSON-Schema primitive (conservative). */
2603
+ jsonTypeOf(t) {
2604
+ switch (t) {
2605
+ case "number":
2606
+ case "currency":
2607
+ case "percent":
2608
+ case "rating":
2609
+ case "slider":
2610
+ case "autonumber":
2611
+ return "number";
2612
+ case "boolean":
2613
+ case "toggle":
2614
+ return "boolean";
2615
+ case "multiselect":
2616
+ case "checkboxes":
2617
+ case "tags":
2618
+ return "array";
2619
+ default:
2620
+ return "string";
2621
+ }
2622
+ }
2623
+ /** Resolve an action's params into LLM-facing summaries (field-backed types resolved). */
2624
+ summarizeActionParams(action, obj) {
2625
+ const fields = obj?.fields ?? {};
2626
+ const out = [];
2627
+ for (const p of Array.isArray(action?.params) ? action.params : []) {
2628
+ const fieldRef = p?.field;
2629
+ const field = fieldRef ? fields[fieldRef] : void 0;
2630
+ const name = p?.name ?? fieldRef;
2631
+ if (!name) continue;
2632
+ const type = this.jsonTypeOf(p?.type ?? field?.type);
2633
+ const label = typeof p?.label === "string" ? p.label : field?.label;
2634
+ const help = p?.helpText ?? field?.description;
2635
+ const description = [label, help].filter(Boolean).join(" \u2014 ") || void 0;
2636
+ const optionSource = p?.options ?? field?.options;
2637
+ const enumVals = Array.isArray(optionSource) ? optionSource.map((o) => typeof o === "string" ? o : o?.value).filter((v) => typeof v === "string") : [];
2638
+ out.push({
2639
+ name,
2640
+ type,
2641
+ required: Boolean(p?.required ?? field?.required ?? false),
2642
+ ...description ? { description } : {},
2643
+ ...enumVals.length > 0 ? { enum: enumVals } : {}
2644
+ });
2645
+ }
2646
+ return out;
2647
+ }
2648
+ /** Slim engine facade matching the ActionContext.engine shape handlers expect. */
2649
+ buildActionEngineFacade(ql) {
2650
+ return {
2651
+ async insert(object, data) {
2652
+ const res = await ql.insert(object, data);
2653
+ const id = (res && res.id) ?? data.id;
2654
+ return { id };
2655
+ },
2656
+ async update(object, id, data) {
2657
+ await ql.update(object, data, { where: { id } });
2658
+ },
2659
+ // Tolerant of both the single-id and array conventions handler suites
2660
+ // use (CRM handlers pass one id; todo handlers pass an id array).
2661
+ async delete(object, idOrIds) {
2662
+ const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
2663
+ for (const id of ids) {
2664
+ if (id != null) await ql.delete(object, { where: { id } });
2665
+ }
2666
+ },
2667
+ async find(object, query) {
2668
+ const opts = query && Object.keys(query).length ? { where: query } : void 0;
2669
+ const rows = await ql.find(object, opts);
2670
+ return Array.isArray(rows) ? rows : rows?.value ?? [];
2671
+ }
2672
+ };
2673
+ }
2674
+ /**
2675
+ * Resolve + invoke a business action by its declarative name for the MCP
2676
+ * `run_action` tool. Enforces the ADR-0066 D4 capability gate and RLS as the
2677
+ * caller, loads the subject record under RLS for row-context actions, and
2678
+ * dispatches through the framework's `engine.executeAction` (script/body) or
2679
+ * automation flow runner (flow). Throws on denial / not-found / handler
2680
+ * failure so the tool surfaces a clean tool-error. No service-ai dependency.
2681
+ */
2682
+ async invokeBusinessAction(name, input, wiring) {
2683
+ const { driver, envId, ec, getMeta, callData } = wiring;
2684
+ const meta = await getMeta();
2685
+ const params = input?.params && typeof input.params === "object" ? input.params : {};
2686
+ const recordId = typeof input?.recordId === "string" && input.recordId.length > 0 ? input.recordId : void 0;
2687
+ const resolved = await this.resolveActionByName(meta, name, input?.objectName);
2688
+ if (!resolved) {
2689
+ throw new Error(
2690
+ input?.objectName ? `Action '${name}' not found on object '${input.objectName}'` : `Action '${name}' not found`
2691
+ );
2692
+ }
2693
+ const { action, objectName } = resolved;
2694
+ if (isSystemObjectName(objectName)) {
2695
+ throw new Error(`Action '${name}' is on a system object and is not exposed via MCP`);
2696
+ }
2697
+ const hasAutomation = Boolean(await this.resolveService("automation", envId).catch(() => null));
2698
+ if (!this.isHeadlessInvokableAction(action, hasAutomation)) {
2699
+ throw new Error(
2700
+ `Action '${name}' (type='${action?.type ?? "script"}') cannot be invoked via MCP`
2701
+ );
2702
+ }
2703
+ const gateError = this.actionPermissionError(action, ec, objectName);
2704
+ if (gateError) throw new Error(gateError);
2705
+ let record = {};
2706
+ if (recordId && objectName !== "global") {
2707
+ try {
2708
+ const got = await callData("get", { object: objectName, id: recordId }, driver, envId, ec);
2709
+ if (got?.record) record = got.record;
2710
+ } catch {
2711
+ }
2712
+ }
2713
+ if (record && record.id == null && recordId) record.id = recordId;
2714
+ const user = ec?.userId ? { id: ec.userId, name: ec.userName ?? ec.userDisplayName ?? ec.userId } : { id: "system", name: "system" };
2715
+ if (action.type === "flow") {
2716
+ const automation = await this.resolveService("automation", envId).catch(() => null);
2717
+ if (!automation || typeof automation.execute !== "function") {
2718
+ throw new Error(`Action '${name}' is a flow but no automation service is available`);
2719
+ }
2720
+ const result = await automation.execute(action.target, {
2721
+ triggerData: { record, params, user, action: action.name }
2722
+ });
2723
+ if (result && typeof result === "object" && "success" in result && result.success === false) {
2724
+ throw new Error(`Flow '${action.target}' failed: ${result.error ?? "unknown error"}`);
2725
+ }
2726
+ return { ok: true, action: action.name, objectName, ...recordId ? { recordId } : {}, result: result ?? null };
2727
+ }
2728
+ const ql = await this.getObjectQLService(envId);
2729
+ if (!ql || typeof ql.executeAction !== "function") {
2730
+ throw new Error("Data engine not available for action dispatch");
2731
+ }
2732
+ const actionContext = {
2733
+ record,
2734
+ user,
2735
+ engine: this.buildActionEngineFacade(ql),
2736
+ params: { ...params, recordId, objectName }
2737
+ };
2738
+ const primary = action.body ? action.name : action.target || action.name;
2739
+ const candidates = [primary, action.target, action.name].filter(
2740
+ (k, i, a) => typeof k === "string" && a.indexOf(k) === i
2741
+ );
2742
+ const notRegistered = (err) => /Action '.+' on object '.+' not found/i.test(String(err?.message ?? err));
2743
+ for (const obj of [objectName, "*"]) {
2744
+ for (const key of candidates) {
2745
+ try {
2746
+ const result = await ql.executeAction(obj, key, actionContext);
2747
+ return { ok: true, action: action.name, objectName, ...recordId ? { recordId } : {}, result: result ?? null };
2748
+ } catch (err) {
2749
+ if (!notRegistered(err)) throw err;
2750
+ }
2751
+ }
2752
+ }
2753
+ throw new Error(`No handler registered for action '${name}' on '${objectName}'`);
2754
+ }
2755
+ /**
2756
+ * Find an action's declarative definition by name across object metadata,
2757
+ * optionally scoped to a single object. Returns the action plus its owning
2758
+ * object name, or `null`. Throws when the name is ambiguous across objects
2759
+ * and no `objectName` was supplied (so `run_action` can ask for one).
2760
+ */
2761
+ async resolveActionByName(meta, name, objectName) {
2762
+ if (objectName) {
2763
+ const def = await meta?.getObject?.(objectName);
2764
+ const action = Array.isArray(def?.actions) ? def.actions.find((a) => a?.name === name) : void 0;
2765
+ return action ? { action, objectName } : null;
2766
+ }
2767
+ const objs = await meta?.listObjects?.() ?? [];
2768
+ const matches = [];
2769
+ for (const obj of objs) {
2770
+ if (!obj?.name) continue;
2771
+ const action = Array.isArray(obj?.actions) ? obj.actions.find((a) => a?.name === name) : void 0;
2772
+ if (action) matches.push({ action, objectName: obj.name });
2773
+ }
2774
+ if (matches.length === 0) return null;
2775
+ if (matches.length > 1) {
2776
+ const where = matches.map((m) => m.objectName).join(", ");
2777
+ throw new Error(`Action '${name}' exists on multiple objects (${where}); pass objectName to disambiguate`);
2778
+ }
2779
+ return matches[0];
2780
+ }
2670
2781
  /**
2671
2782
  * Generate a `sys_api_key` and return the raw secret EXACTLY ONCE
2672
2783
  * (`POST /keys`). This is the only mint path — the raw key is never stored
@@ -2713,7 +2824,7 @@ var _HttpDispatcher = class _HttpDispatcher {
2713
2824
  if (!ql || typeof ql.insert !== "function") {
2714
2825
  return { handled: true, response: this.error("Data service not available", 503) };
2715
2826
  }
2716
- const generated = (0, import_core2.generateApiKey)();
2827
+ const generated = (0, import_core3.generateApiKey)();
2717
2828
  const row = {
2718
2829
  name,
2719
2830
  key: generated.hash,
@@ -2955,7 +3066,7 @@ var _HttpDispatcher = class _HttpDispatcher {
2955
3066
  return {
2956
3067
  name: "ObjectOS",
2957
3068
  version: "1.0.0",
2958
- environment: (0, import_core3.getEnv)("NODE_ENV", "development"),
3069
+ environment: (0, import_core4.getEnv)("NODE_ENV", "development"),
2959
3070
  routes,
2960
3071
  endpoints: routes,
2961
3072
  // Alias for backward compatibility with some clients
@@ -3460,7 +3571,7 @@ var _HttpDispatcher = class _HttpDispatcher {
3460
3571
  let translations = i18nService.getTranslations(locale);
3461
3572
  if (Object.keys(translations).length === 0) {
3462
3573
  const availableLocales = typeof i18nService.getLocales === "function" ? i18nService.getLocales() : [];
3463
- const resolved = (0, import_core3.resolveLocale)(locale, availableLocales);
3574
+ const resolved = (0, import_core4.resolveLocale)(locale, availableLocales);
3464
3575
  if (resolved && resolved !== locale) {
3465
3576
  translations = i18nService.getTranslations(resolved);
3466
3577
  return { handled: true, response: this.success({ locale: resolved, requestedLocale: locale, translations }) };
@@ -3473,7 +3584,7 @@ var _HttpDispatcher = class _HttpDispatcher {
3473
3584
  let locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale;
3474
3585
  if (!locale) return { handled: true, response: this.error("Missing locale parameter", 400) };
3475
3586
  const availableLocales = typeof i18nService.getLocales === "function" ? i18nService.getLocales() : [];
3476
- const resolved = (0, import_core3.resolveLocale)(locale, availableLocales);
3587
+ const resolved = (0, import_core4.resolveLocale)(locale, availableLocales);
3477
3588
  if (resolved) locale = resolved;
3478
3589
  if (typeof i18nService.getFieldLabels === "function") {
3479
3590
  const labels2 = i18nService.getFieldLabels(objectName, locale);
@@ -3647,6 +3758,61 @@ var _HttpDispatcher = class _HttpDispatcher {
3647
3758
  }
3648
3759
  return { handled: true, response: this.error("Draft discarding not supported", 501) };
3649
3760
  }
3761
+ if (parts.length === 2 && parts[1] === "commits" && m === "GET") {
3762
+ const id = decodeURIComponent(parts[0]);
3763
+ const protocol = await this.resolveService("protocol");
3764
+ if (protocol && typeof protocol.listCommits === "function") {
3765
+ try {
3766
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3767
+ const commits = await protocol.listCommits({
3768
+ packageId: id,
3769
+ ...organizationId ? { organizationId } : {}
3770
+ });
3771
+ return { handled: true, response: this.success({ commits }) };
3772
+ } catch (e) {
3773
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3774
+ }
3775
+ }
3776
+ return { handled: true, response: this.error("Commit history not supported", 501) };
3777
+ }
3778
+ if (parts.length === 4 && parts[1] === "commits" && parts[3] === "revert" && m === "POST") {
3779
+ const commitId = decodeURIComponent(parts[2]);
3780
+ const protocol = await this.resolveService("protocol");
3781
+ if (protocol && typeof protocol.revertCommit === "function") {
3782
+ try {
3783
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3784
+ const result = await protocol.revertCommit({
3785
+ commitId,
3786
+ ...organizationId ? { organizationId } : {},
3787
+ ...body?.actor ? { actor: body.actor } : {}
3788
+ });
3789
+ return { handled: true, response: this.success(result) };
3790
+ } catch (e) {
3791
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3792
+ }
3793
+ }
3794
+ return { handled: true, response: this.error("Commit revert not supported", 501) };
3795
+ }
3796
+ if (parts.length === 2 && parts[1] === "rollback" && m === "POST") {
3797
+ const protocol = await this.resolveService("protocol");
3798
+ if (protocol && typeof protocol.rollbackToPackageCommit === "function") {
3799
+ if (!body?.commitId) {
3800
+ return { handled: true, response: this.error("Body { commitId } is required", 400) };
3801
+ }
3802
+ try {
3803
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3804
+ const result = await protocol.rollbackToPackageCommit({
3805
+ commitId: String(body.commitId),
3806
+ ...organizationId ? { organizationId } : {},
3807
+ ...body?.actor ? { actor: body.actor } : {}
3808
+ });
3809
+ return { handled: true, response: this.success(result) };
3810
+ } catch (e) {
3811
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3812
+ }
3813
+ }
3814
+ return { handled: true, response: this.error("Commit rollback not supported", 501) };
3815
+ }
3650
3816
  if (parts.length === 2 && parts[1] === "revert" && m === "POST") {
3651
3817
  const id = decodeURIComponent(parts[0]);
3652
3818
  const metadataService = await this.getService(import_system.CoreServiceName.enum.metadata);
@@ -3664,6 +3830,49 @@ var _HttpDispatcher = class _HttpDispatcher {
3664
3830
  }
3665
3831
  return { handled: true, response: this.success(manifest) };
3666
3832
  }
3833
+ if (parts.length === 2 && parts[1] === "adopt-orphans" && m === "POST") {
3834
+ const id = decodeURIComponent(parts[0]);
3835
+ const protocol = await this.resolveService("protocol");
3836
+ if (!protocol || typeof protocol.reassignOrphanedMetadata !== "function") {
3837
+ return { handled: true, response: this.error("Orphan adoption not supported", 501) };
3838
+ }
3839
+ try {
3840
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3841
+ const result = await protocol.reassignOrphanedMetadata({
3842
+ targetPackageId: id,
3843
+ ...organizationId ? { organizationId } : {},
3844
+ ...body?.actor ? { actor: body.actor } : {}
3845
+ });
3846
+ return { handled: true, response: this.success(result) };
3847
+ } catch (e) {
3848
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3849
+ }
3850
+ }
3851
+ if (parts.length === 2 && parts[1] === "duplicate" && m === "POST") {
3852
+ const id = decodeURIComponent(parts[0]);
3853
+ const protocol = await this.resolveService("protocol");
3854
+ if (!protocol || typeof protocol.duplicatePackage !== "function") {
3855
+ return { handled: true, response: this.error("Package duplication not supported", 501) };
3856
+ }
3857
+ const targetPackageId = typeof body?.targetPackageId === "string" ? body.targetPackageId.trim() : "";
3858
+ if (!targetPackageId) {
3859
+ return { handled: true, response: this.error("Body { targetPackageId } is required", 400) };
3860
+ }
3861
+ try {
3862
+ const organizationId = await this.resolveActiveOrganizationId(_context);
3863
+ const result = await protocol.duplicatePackage({
3864
+ sourcePackageId: id,
3865
+ targetPackageId,
3866
+ ...typeof body?.targetName === "string" ? { targetName: body.targetName } : {},
3867
+ ...typeof body?.targetNamespace === "string" ? { targetNamespace: body.targetNamespace } : {},
3868
+ ...organizationId ? { organizationId } : {},
3869
+ ...body?.actor ? { actor: body.actor } : {}
3870
+ });
3871
+ return { handled: true, response: this.success(result) };
3872
+ } catch (e) {
3873
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
3874
+ }
3875
+ }
3667
3876
  if (parts.length === 1 && m === "GET") {
3668
3877
  const id = decodeURIComponent(parts[0]);
3669
3878
  const pkg = registry.getPackage(id);
@@ -4067,8 +4276,12 @@ var _HttpDispatcher = class _HttpDispatcher {
4067
4276
  object: objectName,
4068
4277
  event: ctxBody.event ?? "manual"
4069
4278
  };
4070
- const userIdFromAuth = context?.user?.id ?? context?.userId;
4279
+ const ec = context?.executionContext;
4280
+ const userIdFromAuth = context?.user?.id ?? context?.userId ?? ec?.userId;
4071
4281
  if (userIdFromAuth) automationContext.userId = userIdFromAuth;
4282
+ if (Array.isArray(ec?.roles) && ec.roles.length) automationContext.roles = ec.roles;
4283
+ if (Array.isArray(ec?.permissions) && ec.permissions.length) automationContext.permissions = ec.permissions;
4284
+ if (ec?.tenantId) automationContext.tenantId = ec.tenantId;
4072
4285
  const result = await automationService.execute(name, automationContext);
4073
4286
  return { handled: true, response: this.success(result) };
4074
4287
  }
@@ -4246,6 +4459,15 @@ var _HttpDispatcher = class _HttpDispatcher {
4246
4459
  if (!ql || typeof ql.executeAction !== "function") {
4247
4460
  return { handled: true, response: this.error("Data engine not available", 503) };
4248
4461
  }
4462
+ try {
4463
+ const actionSchema = (typeof ql.getSchema === "function" ? ql.getSchema(objectName) : void 0) ?? ql.registry?.getObject?.(objectName);
4464
+ const actionDef = Array.isArray(actionSchema?.actions) ? actionSchema.actions.find((a) => a?.name === actionName) : void 0;
4465
+ const gateError = this.actionPermissionError(actionDef, _context?.executionContext, objectName);
4466
+ if (gateError) {
4467
+ return { handled: true, response: this.error(gateError, 403) };
4468
+ }
4469
+ } catch {
4470
+ }
4249
4471
  const tryExecute = async (obj) => {
4250
4472
  return ql.executeAction(obj, actionName, actionContext);
4251
4473
  };
@@ -4585,7 +4807,23 @@ var _HttpDispatcher = class _HttpDispatcher {
4585
4807
  try {
4586
4808
  context.executionContext = await resolveExecutionContext({
4587
4809
  getService: (n) => this.resolveService(n, context.environmentId),
4588
- getQl: () => Promise.resolve(this.getObjectQLService(context.environmentId)),
4810
+ // Resolve ObjectQL from the per-request kernel DIRECTLY. The scoped
4811
+ // `resolveService('objectql', envId)` factory can return a different
4812
+ // instance that doesn't see THIS env's rows (the gotcha
4813
+ // `handleActions` works around) — which made the api-key lookup miss
4814
+ // `sys_api_key` on the MCP path and reject valid keys with 401, while
4815
+ // REST accepted them (rest-server resolves identity via
4816
+ // `kernel.getServiceAsync('objectql')`). Resolving off `this.kernel`
4817
+ // keeps REST + MCP identity resolution aligned; falls back to the
4818
+ // scoped path when the kernel can't hand back an objectql directly.
4819
+ getQl: async () => {
4820
+ const k = this.kernel;
4821
+ if (k && typeof k.getServiceAsync === "function") {
4822
+ const ql = await k.getServiceAsync("objectql").catch(() => void 0);
4823
+ if (ql && (ql.registry || typeof ql.find === "function")) return ql;
4824
+ }
4825
+ return this.getObjectQLService(context.environmentId);
4826
+ },
4589
4827
  request: context.request
4590
4828
  });
4591
4829
  } catch {
@@ -5298,6 +5536,14 @@ function createDispatcherPlugin(config = {}) {
5298
5536
  errorResponse(err, res);
5299
5537
  }
5300
5538
  });
5539
+ server.get(`${prefix}/ready`, async (_req, res) => {
5540
+ try {
5541
+ const result = await dispatcher.dispatch("GET", "/ready", void 0, {}, { request: _req });
5542
+ sendResult(result, res);
5543
+ } catch (err) {
5544
+ errorResponse(err, res);
5545
+ }
5546
+ });
5301
5547
  server.post(`${prefix}/auth/login`, async (req, res) => {
5302
5548
  try {
5303
5549
  const result = await dispatcher.handleAuth("login", "POST", req.body, { request: req });