Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/runtime-actions-mcp-extraction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/runtime": minor
---

feat(runtime): extract the /actions and /mcp dispatcher domain bodies — ADR-0076 D11 step ③, PR-9 (#2462)

The two deep-coupled domains ride the PR-8 action-execution subsystem out
of the dispatcher: `domains/actions.ts` (ADR-0066 D4 permission gate +
ADR-0104 param contract) and `domains/mcp.ts` (JSON-RPC transport,
`/mcp/skill` download, OAuth resource-metadata, the principal-bound tool
bridge). Env-resolution state stays behind two new deps seams —
`getDefaultEnvironmentId` and `resolveProjectKernelObjectQL` (the ADR-0006
direct-caller kernel swap, side effect dispatcher-owned). The legacy
`/mcp/skill`-before-`/mcp` precedence is reproduced with ordered registry
entries incl. the `?` forms; the actions redundant trailing-slash regex
(the CodeQL polynomial-redos twin) is dropped for split+filter. The authz
identity pin for `buildMcpBridge(context)` follows the body to
`domains/mcp.ts`. Zero behavior change — runtime 649, http-conformance 41,
dogfood 351 green.
2 changes: 1 addition & 1 deletion packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
// is wired; the real gap is the opt-in stdio transport.)
{ id: 'mcp-http-identity', summary: 'MCP HTTP surface (/api/v1/mcp) admits the caller identity — anonymous denied, OAuth scope-gated, caller ExecutionContext threaded to every tool\'s data op', state: 'enforced',
enforcement: 'runtime/http-dispatcher.ts handleMcp — requires ec.userId||ec.isSystem (401 else, RFC 9728 WWW-Authenticate advertised when the OAuth track is live); OAuth-token provenance narrows the exposed tool families to the granted MCP scopes (403 on none, #2698); buildMcpBridge(context) threads the caller ExecutionContext into every bridge op (callData(..., ec)), and mcp-server-runtime.ts handleHttpRequest builds a fresh per-request McpServer from that principal-bound bridge (registerObjectTools/registerActionTools) — so RLS / FLS / tenant apply exactly as on REST /data',
covers: ['mcp:http-dispatcher.ts:handleMcp', 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)'],
covers: ['mcp:http-dispatcher.ts:handleMcp', 'mcp:domains/mcp.ts:buildMcpBridge(context-threaded)'],
proof: 'showcase-mcp-http-identity.dogfood.test.ts',
note: 'The per-request principal-bound tool server is isolated from the long-lived UNSCOPED stdio server (see mcp-stdio-authority). HIGH-RISK, proven end-to-end (#3167 PR-B): the proof boots the real showcase + security + MCP plugin and drives POST /api/v1/mcp — an anonymous tools/call is 401 before any tool runs, and a member\'s query_records over the owner-private showcase_private_note returns ONLY their own rows (if the tool ran unscoped/system — the stdio posture — the other owner\'s rows would leak). Dropping the buildMcpBridge(context) threading (or building an unscoped/system bridge for HTTP) makes the context-threaded key STALE → red CI; a new sibling MCP data handler appears as an UNCLASSIFIED surface until a row covers it. Dispatcher-level unit coverage: http-dispatcher.mcp.test.ts (401, EC-to-bridge) + http-dispatcher.mcp-oauth.test.ts (scope 403).' },
{ id: 'mcp-stdio-authority', summary: 'MCP stdio transport admits an env-supplied API-key principal — RLS/FLS/tenant applied to record reads, fail-closed on a missing/invalid key, no `system` bypass (opt-in: autoStart / OS_MCP_STDIO_ENABLED=true + OS_MCP_STDIO_API_KEY)', state: 'enforced',
Expand Down
8 changes: 4 additions & 4 deletions packages/qa/dogfood/test/authz-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,9 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray
key: () => 'mcp:http-dispatcher.ts:handleMcp',
},
{
file: 'packages/runtime/src/http-dispatcher.ts',
re: /this\.buildMcpBridge\(context\)/g,
key: () => 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)',
file: 'packages/runtime/src/domains/mcp.ts',
re: /buildMcpBridge\(deps, context\)/g,
key: () => 'mcp:domains/mcp.ts:buildMcpBridge(context-threaded)',
},
// (3) The stdio transport's PRINCIPAL binding (ADR-0101): the long-lived
// server reads record data only under an OS_MCP_STDIO_API_KEY identity,
Expand Down Expand Up @@ -219,7 +219,7 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => {

// ── ADR-0096 / #3167 — the MCP identity pins bite too ──────────────────
it('(f) dropping the MCP HTTP context-thread → STALE covers failure (#3167)', () => {
const threaded = 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)';
const threaded = 'mcp:domains/mcp.ts:buildMcpBridge(context-threaded)';
// Baseline sanity: the HTTP `/mcp` handler threads the caller EC today.
expect(discoverAnonymousDenySurfaces().has(threaded)).toBe(true);
const problems = checkLedger(
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel';
import { checkApiExposure } from './api-exposure.js';

/** A `sys_`-prefixed object is a system table — off-limits to external MCP agents. */
function isSystemObjectName(name: string): boolean {
export function isSystemObjectName(name: string): boolean {
return /^sys_/i.test(name);
}

Expand Down
12 changes: 12 additions & 0 deletions packages/runtime/src/domain-handler-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ export interface DomainHandlerDeps {
announceKernelEvent(event: string, payload: unknown): Promise<void>;
/** Host logger when one is attached to the dispatcher; domains fall back to console. */
logger?: any;
/** Single-environment default environment id (createSingleEnvironmentPlugin), if registered. */
getDefaultEnvironmentId(): string | undefined;
/**
* Direct-caller kernel swap (ADR-0006 Phase 5): when a host KernelResolver
* is present and the context names a non-platform environment, resolve and
* SWAP to the per-project kernel (side effect owned by the dispatcher) and
* return that kernel's own ObjectQL — bypassing the control-plane scoped
* factory, which would hand back an instance without the project bundle's
* actions/hooks. Returns null when no swap happened. Idempotent on
* dispatch()-routed requests (they already swapped).
*/
resolveProjectKernelObjectQL(context: HttpProtocolContext): Promise<any | null>;
/** The deployment's `requireAuth` posture (lazily read — construction-order safe). */
isAuthRequired(): boolean;
/**
Expand Down
217 changes: 217 additions & 0 deletions packages/runtime/src/domains/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `/actions` domain — extracted dispatcher body (ADR-0076 D11 step ③,
* PR-9). Server-registered business-action invocation over HTTP
* (ADR-0066 D4 permission gate + ADR-0104 param contract), running on the
* action-execution subsystem (PR-8). Env-resolution state stays behind the
* deps seam: `resolveProjectKernelObjectQL` owns the direct-caller kernel
* swap (ADR-0006 Phase 5). The legacy leading/trailing-slash regex was
* dropped — `split('/').filter(Boolean)` already covers it (the CodeQL
* polynomial-redos twin flagged in #2462).
*
* - `POST /actions/:object/:action` — record-scoped action
* - `POST /actions/:object/:action/:recordId` — record-scoped action with id in URL
* - `POST /actions/global/:action` — wildcard ("*") action
*/

import * as actionExec from '../action-execution.js';
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';

export function createActionsDomain(deps: DomainHandlerDeps): DomainRoute {
return {
prefix: '/actions',
handler: (req, context) =>
handleActionsRequest(deps, req.path.substring(8), req.method, req.body, context),
};
}

/**
* Handle action invocation routes (`/actions/...`).
*
* Dispatches a named, server-registered action handler (registered via
* `engine.registerAction(objectName, actionName, handler)`) over HTTP.
* Three URL shapes are accepted to keep the client contract flexible:
*
* - `POST /actions/:object/:action` — record-scoped action
* - `POST /actions/:object/:action/:recordId` — record-scoped action with id in URL
* - `POST /actions/global/:action` — wildcard ("*") action
*
* Body shape: `{ recordId?: string, params?: Record<string, unknown> }`.
* The handler is invoked with an `ActionContext` of:
* `{ record, user, engine, params }`
* where `engine` exposes the slimmed CRUD surface used by CRM handlers
* (`insert`, `update`, `delete`, `find`).
*/
export async function handleActionsRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
if (method.toUpperCase() !== 'POST') {
return { handled: true, response: deps.error('Method not allowed', 405) };
}
const parts = path.split('/').filter(Boolean);
if (parts.length < 2) {
return { handled: true, response: deps.error('Path must be /actions/:object/:action', 400) };
}
const objectName = parts[0];
const actionName = parts[1];
const recordIdFromPath = parts[2];

// Resolve project scope so the right project kernel's ObjectQL is
// used (single-environment default when unset), then let the host
// swap to the per-project kernel for DIRECT callers — dispatch()-
// routed requests already did both, so this is idempotent there.
// The kernel-swap side effect stays behind the deps seam (env-
// resolution state never lives in a domain module).
if (!_context.environmentId) {
const def = deps.getDefaultEnvironmentId();
if (def) _context.environmentId = def;
}
const projectQl: any = await deps.resolveProjectKernelObjectQL(_context);

const ql: any = projectQl ?? await deps.getObjectQL(_context?.environmentId);
if (!ql || typeof ql.executeAction !== 'function') {
return { handled: true, response: deps.error('Data engine not available', 503) };
}

// [ADR-0066 D4] Dual-surface action gate — the server is the source of
// truth. Resolve the action's declared `requiredPermissions` from the
// object schema and reject (403) when the caller's systemPermissions
// don't cover them. The objectui ActionRunner hides/disables the same
// action from the identical declaration, so a UI-hidden action is also
// server-closed (and the inverse footgun is removed). System/engine
// self-invocation (isSystem) bypasses; an unauthenticated caller holds
// no capabilities and is therefore denied for a gated action.
// Resolve the object schema + this action's declaration once — both the
// permission gate (ADR-0066 D4) and the param contract (ADR-0104 D2)
// read it.
let actionSchema: any;
let actionDef: any;
try {
actionSchema =
(typeof ql.getSchema === 'function' ? ql.getSchema(objectName) : undefined) ??
ql.registry?.getObject?.(objectName);
actionDef = Array.isArray(actionSchema?.actions)
? actionSchema.actions.find((a: any) => a?.name === actionName)
: undefined;
const gateError = actionExec.actionPermissionError(deps, actionDef, _context?.executionContext, objectName);
if (gateError) {
return { handled: true, response: deps.error(gateError, 403) };
}
} catch {
/* schema unresolved → no declared gate to enforce (handler-only action) */
}

// Resolve the handler — fall back to wildcard '*' if the object-specific key is missing.
// Since engine.executeAction throws when the key is unknown, we probe via the internal
// map by attempting the call inside a try/catch and rotating to '*'.
const tryExecute = async (obj: string) => {
return ql.executeAction(obj, actionName, actionContext);
};

const reqBody = body && typeof body === 'object' ? body : {};
const recordId = recordIdFromPath ?? reqBody.recordId;
const reqParams = (reqBody.params && typeof reqBody.params === 'object') ? reqBody.params : {};

// [ADR-0104 D2] Enforce the declared param contract before the handler
// runs — required/option/multiple/reference-id shape + unknown keys.
// Warn-first unless OS_ACTION_PARAMS_STRICT_ENABLED=1 (then a 400).
const paramError = actionExec.enforceActionParams(deps, actionDef, actionSchema, reqParams, { objectName, actionName });
if (paramError) {
return { handled: true, response: deps.error(paramError, 400) };
}

// Load the record (best-effort) so handlers can rely on `ctx.record`.
let record: Record<string, unknown> = {};
if (recordId && objectName !== 'global') {
try {
const got = await actionExec.callData(deps, 'get', { object: objectName, id: recordId }, _context.dataDriver, _context.environmentId, _context.executionContext);
if (got?.record) record = got.record;
} catch { /* record may not exist for new-record actions; pass empty */ }
}
if (record && (record as any).id == null && recordId) (record as any).id = recordId;

// Slim engine facade matching the ActionContext.engine shape used by CRM
// handlers. ⚠️ TRUSTED — context-less, RLS/FLS-bypassing by design; see
// buildActionEngineFacade for the full security-model rationale (#2849).
const engineFacade = {
async insert(object: string, data: Record<string, unknown>): Promise<{ id: string }> {
const res = await ql.insert(object, data);
const id = (res && (res as any).id) ?? (data as any).id;
return { id };
},
async update(object: string, id: string, data: Record<string, unknown>): Promise<void> {
await ql.update(object, data, { where: { id } });
},
async delete(object: string, id: string): Promise<void> {
await ql.delete(object, { where: { id } });
},
async find(object: string, query: Record<string, unknown>): Promise<Array<Record<string, unknown>>> {
const opts = query && Object.keys(query).length ? { where: query } : undefined;
const rows = await ql.find(object, opts as any);
return Array.isArray(rows) ? rows : ((rows as any)?.value ?? []);
},
};

// Resolve the caller identity from the request's ExecutionContext — the
// single source `dispatch()` populates via `resolveExecutionContext`,
// the same envelope the MCP `runAction` and record-change trigger paths
// read. The action body sandbox receives the operator's id and business
// roles (ADR-0090 `positions`, formerly `roles`) so a handler can branch
// on identity and enforce ownership. Falls back to a `system` principal
// only for a genuinely anonymous / self-invoked call (#2701).
const ec: any = _context?.executionContext;
const userFromAuth = ec?.userId
? {
id: ec.userId,
name: ec.userId,
email: ec.email,
roles: Array.isArray(ec.positions) ? ec.positions : [],
positions: Array.isArray(ec.positions) ? ec.positions : [],
permissions: Array.isArray(ec.permissions) ? ec.permissions : [],
// `organizationId` is the blessed developer-facing name for the
// caller's active org (matches columns + `current_user.organizationId`).
// The deprecated `tenantId` alias (#3280) was removed in v11 (#3290).
organizationId: ec.tenantId,
}
: { id: 'system', name: 'system', roles: [], positions: [], permissions: [] };

const actionContext: any = {
record,
user: userFromAuth,
session: actionExec.buildActionSession(deps, ec),
engine: engineFacade,
params: { ...reqParams, recordId, objectName },
};

// [#2849] Same trusted-mode elevation as the MCP path — keep it audible.
console.info(
`[action-audit] REST action '${objectName}/${actionName}' — body executes TRUSTED ` +
`(context-less engine, RLS/FLS-bypassing) for user '${userFromAuth.id}'`,
);

try {
// Try object-specific first; on "not found" error, fall back to wildcard.
let result: any;
try {
result = await tryExecute(objectName);
} catch (err: any) {
const msg = String(err?.message ?? err ?? '');
if (/not found/i.test(msg) && objectName !== '*') {
result = await tryExecute('*');
} else {
throw err;
}
}
return { handled: true, response: deps.success({ success: true, data: result }) };
} catch (err: any) {
const full = err?.message ?? String(err);
// The sandbox wraps a user throw as `<kind> '<name>' threw: <msg>` for
// server logs; surface only the business `<msg>` (SandboxError.innerMessage)
// to the client so an action's error toast reads as plain text instead of
// leaking the debug prefix. Keep the full wrapper in the log for debugging.
const inner: unknown = err?.innerMessage;
const clientMsg = (typeof inner === 'string' && inner) ? inner : full;
if (clientMsg !== full) console.error(`[action ${objectName}/${actionName}] ${full}`);
return { handled: true, response: deps.success({ success: false, error: clientMsg }) };
}
}
Loading