Skip to content

Commit f5bfac8

Browse files
os-zhuangclaude
andauthored
feat(runtime): extract /actions and /mcp dispatcher domains — ADR-0076 D11 step ③ PR-9 (#2462) (#3568)
The deep-coupling batch rides the PR-8 subsystem out: - domains/actions.ts: ADR-0066 D4 gate + ADR-0104 param contract on actionExec.*; the env pre-resolution block becomes two deps seams — getDefaultEnvironmentId + resolveProjectKernelObjectQL (the ADR-0006 Phase-5 direct-caller kernel swap; the this.kernel write side effect stays dispatcher-owned). The redundant leading/trailing-slash regex (CodeQL redos twin flagged in #2462) drops for split+filter. - domains/mcp.ts: transport + /mcp/skill + OAuth resource-metadata + the principal-bound bridge; legacy /mcp/skill-before-/mcp precedence reproduced with ordered entries incl. '?' forms. buildMcpBridge kept as a public thin delegate for direct callers (tests). - Statics gotcha round 2: HttpDispatcher.isMcpEnabled — extracted body referenced the class static; inlined to isMcpServerEnabled() at both sites and the wrapper dropped. - The authz-conformance identity pin for buildMcpBridge(context) follows the body: discover file/re/key now point at domains/mcp.ts. Verified: runtime 649, http-conformance 41, dogfood 351, full build green. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 474fe39 commit f5bfac8

8 files changed

Lines changed: 687 additions & 616 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): extract the /actions and /mcp dispatcher domain bodies — ADR-0076 D11 step ③, PR-9 (#2462)
6+
7+
The two deep-coupled domains ride the PR-8 action-execution subsystem out
8+
of the dispatcher: `domains/actions.ts` (ADR-0066 D4 permission gate +
9+
ADR-0104 param contract) and `domains/mcp.ts` (JSON-RPC transport,
10+
`/mcp/skill` download, OAuth resource-metadata, the principal-bound tool
11+
bridge). Env-resolution state stays behind two new deps seams —
12+
`getDefaultEnvironmentId` and `resolveProjectKernelObjectQL` (the ADR-0006
13+
direct-caller kernel swap, side effect dispatcher-owned). The legacy
14+
`/mcp/skill`-before-`/mcp` precedence is reproduced with ordered registry
15+
entries incl. the `?` forms; the actions redundant trailing-slash regex
16+
(the CodeQL polynomial-redos twin) is dropped for split+filter. The authz
17+
identity pin for `buildMcpBridge(context)` follows the body to
18+
`domains/mcp.ts`. Zero behavior change — runtime 649, http-conformance 41,
19+
dogfood 351 green.

packages/qa/dogfood/test/authz-conformance.matrix.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
107107
// is wired; the real gap is the opt-in stdio transport.)
108108
{ 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',
109109
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',
110-
covers: ['mcp:http-dispatcher.ts:handleMcp', 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)'],
110+
covers: ['mcp:http-dispatcher.ts:handleMcp', 'mcp:domains/mcp.ts:buildMcpBridge(context-threaded)'],
111111
proof: 'showcase-mcp-http-identity.dogfood.test.ts',
112112
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).' },
113113
{ 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',

packages/qa/dogfood/test/authz-conformance.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,9 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray
112112
key: () => 'mcp:http-dispatcher.ts:handleMcp',
113113
},
114114
{
115-
file: 'packages/runtime/src/http-dispatcher.ts',
116-
re: /this\.buildMcpBridge\(context\)/g,
117-
key: () => 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)',
115+
file: 'packages/runtime/src/domains/mcp.ts',
116+
re: /buildMcpBridge\(deps, context\)/g,
117+
key: () => 'mcp:domains/mcp.ts:buildMcpBridge(context-threaded)',
118118
},
119119
// (3) The stdio transport's PRINCIPAL binding (ADR-0101): the long-lived
120120
// server reads record data only under an OS_MCP_STDIO_API_KEY identity,
@@ -219,7 +219,7 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => {
219219

220220
// ── ADR-0096 / #3167 — the MCP identity pins bite too ──────────────────
221221
it('(f) dropping the MCP HTTP context-thread → STALE covers failure (#3167)', () => {
222-
const threaded = 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)';
222+
const threaded = 'mcp:domains/mcp.ts:buildMcpBridge(context-threaded)';
223223
// Baseline sanity: the HTTP `/mcp` handler threads the caller EC today.
224224
expect(discoverAnonymousDenySurfaces().has(threaded)).toBe(true);
225225
const problems = checkLedger(

packages/runtime/src/action-execution.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel';
2020
import { checkApiExposure } from './api-exposure.js';
2121

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

packages/runtime/src/domain-handler-registry.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ export interface DomainHandlerDeps {
119119
announceKernelEvent(event: string, payload: unknown): Promise<void>;
120120
/** Host logger when one is attached to the dispatcher; domains fall back to console. */
121121
logger?: any;
122+
/** Single-environment default environment id (createSingleEnvironmentPlugin), if registered. */
123+
getDefaultEnvironmentId(): string | undefined;
124+
/**
125+
* Direct-caller kernel swap (ADR-0006 Phase 5): when a host KernelResolver
126+
* is present and the context names a non-platform environment, resolve and
127+
* SWAP to the per-project kernel (side effect owned by the dispatcher) and
128+
* return that kernel's own ObjectQL — bypassing the control-plane scoped
129+
* factory, which would hand back an instance without the project bundle's
130+
* actions/hooks. Returns null when no swap happened. Idempotent on
131+
* dispatch()-routed requests (they already swapped).
132+
*/
133+
resolveProjectKernelObjectQL(context: HttpProtocolContext): Promise<any | null>;
122134
/** The deployment's `requireAuth` posture (lazily read — construction-order safe). */
123135
isAuthRequired(): boolean;
124136
/**
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `/actions` domain — extracted dispatcher body (ADR-0076 D11 step ③,
5+
* PR-9). Server-registered business-action invocation over HTTP
6+
* (ADR-0066 D4 permission gate + ADR-0104 param contract), running on the
7+
* action-execution subsystem (PR-8). Env-resolution state stays behind the
8+
* deps seam: `resolveProjectKernelObjectQL` owns the direct-caller kernel
9+
* swap (ADR-0006 Phase 5). The legacy leading/trailing-slash regex was
10+
* dropped — `split('/').filter(Boolean)` already covers it (the CodeQL
11+
* polynomial-redos twin flagged in #2462).
12+
*
13+
* - `POST /actions/:object/:action` — record-scoped action
14+
* - `POST /actions/:object/:action/:recordId` — record-scoped action with id in URL
15+
* - `POST /actions/global/:action` — wildcard ("*") action
16+
*/
17+
18+
import * as actionExec from '../action-execution.js';
19+
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
20+
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
21+
22+
export function createActionsDomain(deps: DomainHandlerDeps): DomainRoute {
23+
return {
24+
prefix: '/actions',
25+
handler: (req, context) =>
26+
handleActionsRequest(deps, req.path.substring(8), req.method, req.body, context),
27+
};
28+
}
29+
30+
/**
31+
* Handle action invocation routes (`/actions/...`).
32+
*
33+
* Dispatches a named, server-registered action handler (registered via
34+
* `engine.registerAction(objectName, actionName, handler)`) over HTTP.
35+
* Three URL shapes are accepted to keep the client contract flexible:
36+
*
37+
* - `POST /actions/:object/:action` — record-scoped action
38+
* - `POST /actions/:object/:action/:recordId` — record-scoped action with id in URL
39+
* - `POST /actions/global/:action` — wildcard ("*") action
40+
*
41+
* Body shape: `{ recordId?: string, params?: Record<string, unknown> }`.
42+
* The handler is invoked with an `ActionContext` of:
43+
* `{ record, user, engine, params }`
44+
* where `engine` exposes the slimmed CRUD surface used by CRM handlers
45+
* (`insert`, `update`, `delete`, `find`).
46+
*/
47+
export async function handleActionsRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
48+
if (method.toUpperCase() !== 'POST') {
49+
return { handled: true, response: deps.error('Method not allowed', 405) };
50+
}
51+
const parts = path.split('/').filter(Boolean);
52+
if (parts.length < 2) {
53+
return { handled: true, response: deps.error('Path must be /actions/:object/:action', 400) };
54+
}
55+
const objectName = parts[0];
56+
const actionName = parts[1];
57+
const recordIdFromPath = parts[2];
58+
59+
// Resolve project scope so the right project kernel's ObjectQL is
60+
// used (single-environment default when unset), then let the host
61+
// swap to the per-project kernel for DIRECT callers — dispatch()-
62+
// routed requests already did both, so this is idempotent there.
63+
// The kernel-swap side effect stays behind the deps seam (env-
64+
// resolution state never lives in a domain module).
65+
if (!_context.environmentId) {
66+
const def = deps.getDefaultEnvironmentId();
67+
if (def) _context.environmentId = def;
68+
}
69+
const projectQl: any = await deps.resolveProjectKernelObjectQL(_context);
70+
71+
const ql: any = projectQl ?? await deps.getObjectQL(_context?.environmentId);
72+
if (!ql || typeof ql.executeAction !== 'function') {
73+
return { handled: true, response: deps.error('Data engine not available', 503) };
74+
}
75+
76+
// [ADR-0066 D4] Dual-surface action gate — the server is the source of
77+
// truth. Resolve the action's declared `requiredPermissions` from the
78+
// object schema and reject (403) when the caller's systemPermissions
79+
// don't cover them. The objectui ActionRunner hides/disables the same
80+
// action from the identical declaration, so a UI-hidden action is also
81+
// server-closed (and the inverse footgun is removed). System/engine
82+
// self-invocation (isSystem) bypasses; an unauthenticated caller holds
83+
// no capabilities and is therefore denied for a gated action.
84+
// Resolve the object schema + this action's declaration once — both the
85+
// permission gate (ADR-0066 D4) and the param contract (ADR-0104 D2)
86+
// read it.
87+
let actionSchema: any;
88+
let actionDef: any;
89+
try {
90+
actionSchema =
91+
(typeof ql.getSchema === 'function' ? ql.getSchema(objectName) : undefined) ??
92+
ql.registry?.getObject?.(objectName);
93+
actionDef = Array.isArray(actionSchema?.actions)
94+
? actionSchema.actions.find((a: any) => a?.name === actionName)
95+
: undefined;
96+
const gateError = actionExec.actionPermissionError(deps, actionDef, _context?.executionContext, objectName);
97+
if (gateError) {
98+
return { handled: true, response: deps.error(gateError, 403) };
99+
}
100+
} catch {
101+
/* schema unresolved → no declared gate to enforce (handler-only action) */
102+
}
103+
104+
// Resolve the handler — fall back to wildcard '*' if the object-specific key is missing.
105+
// Since engine.executeAction throws when the key is unknown, we probe via the internal
106+
// map by attempting the call inside a try/catch and rotating to '*'.
107+
const tryExecute = async (obj: string) => {
108+
return ql.executeAction(obj, actionName, actionContext);
109+
};
110+
111+
const reqBody = body && typeof body === 'object' ? body : {};
112+
const recordId = recordIdFromPath ?? reqBody.recordId;
113+
const reqParams = (reqBody.params && typeof reqBody.params === 'object') ? reqBody.params : {};
114+
115+
// [ADR-0104 D2] Enforce the declared param contract before the handler
116+
// runs — required/option/multiple/reference-id shape + unknown keys.
117+
// Warn-first unless OS_ACTION_PARAMS_STRICT_ENABLED=1 (then a 400).
118+
const paramError = actionExec.enforceActionParams(deps, actionDef, actionSchema, reqParams, { objectName, actionName });
119+
if (paramError) {
120+
return { handled: true, response: deps.error(paramError, 400) };
121+
}
122+
123+
// Load the record (best-effort) so handlers can rely on `ctx.record`.
124+
let record: Record<string, unknown> = {};
125+
if (recordId && objectName !== 'global') {
126+
try {
127+
const got = await actionExec.callData(deps, 'get', { object: objectName, id: recordId }, _context.dataDriver, _context.environmentId, _context.executionContext);
128+
if (got?.record) record = got.record;
129+
} catch { /* record may not exist for new-record actions; pass empty */ }
130+
}
131+
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
132+
133+
// Slim engine facade matching the ActionContext.engine shape used by CRM
134+
// handlers. ⚠️ TRUSTED — context-less, RLS/FLS-bypassing by design; see
135+
// buildActionEngineFacade for the full security-model rationale (#2849).
136+
const engineFacade = {
137+
async insert(object: string, data: Record<string, unknown>): Promise<{ id: string }> {
138+
const res = await ql.insert(object, data);
139+
const id = (res && (res as any).id) ?? (data as any).id;
140+
return { id };
141+
},
142+
async update(object: string, id: string, data: Record<string, unknown>): Promise<void> {
143+
await ql.update(object, data, { where: { id } });
144+
},
145+
async delete(object: string, id: string): Promise<void> {
146+
await ql.delete(object, { where: { id } });
147+
},
148+
async find(object: string, query: Record<string, unknown>): Promise<Array<Record<string, unknown>>> {
149+
const opts = query && Object.keys(query).length ? { where: query } : undefined;
150+
const rows = await ql.find(object, opts as any);
151+
return Array.isArray(rows) ? rows : ((rows as any)?.value ?? []);
152+
},
153+
};
154+
155+
// Resolve the caller identity from the request's ExecutionContext — the
156+
// single source `dispatch()` populates via `resolveExecutionContext`,
157+
// the same envelope the MCP `runAction` and record-change trigger paths
158+
// read. The action body sandbox receives the operator's id and business
159+
// roles (ADR-0090 `positions`, formerly `roles`) so a handler can branch
160+
// on identity and enforce ownership. Falls back to a `system` principal
161+
// only for a genuinely anonymous / self-invoked call (#2701).
162+
const ec: any = _context?.executionContext;
163+
const userFromAuth = ec?.userId
164+
? {
165+
id: ec.userId,
166+
name: ec.userId,
167+
email: ec.email,
168+
roles: Array.isArray(ec.positions) ? ec.positions : [],
169+
positions: Array.isArray(ec.positions) ? ec.positions : [],
170+
permissions: Array.isArray(ec.permissions) ? ec.permissions : [],
171+
// `organizationId` is the blessed developer-facing name for the
172+
// caller's active org (matches columns + `current_user.organizationId`).
173+
// The deprecated `tenantId` alias (#3280) was removed in v11 (#3290).
174+
organizationId: ec.tenantId,
175+
}
176+
: { id: 'system', name: 'system', roles: [], positions: [], permissions: [] };
177+
178+
const actionContext: any = {
179+
record,
180+
user: userFromAuth,
181+
session: actionExec.buildActionSession(deps, ec),
182+
engine: engineFacade,
183+
params: { ...reqParams, recordId, objectName },
184+
};
185+
186+
// [#2849] Same trusted-mode elevation as the MCP path — keep it audible.
187+
console.info(
188+
`[action-audit] REST action '${objectName}/${actionName}' — body executes TRUSTED ` +
189+
`(context-less engine, RLS/FLS-bypassing) for user '${userFromAuth.id}'`,
190+
);
191+
192+
try {
193+
// Try object-specific first; on "not found" error, fall back to wildcard.
194+
let result: any;
195+
try {
196+
result = await tryExecute(objectName);
197+
} catch (err: any) {
198+
const msg = String(err?.message ?? err ?? '');
199+
if (/not found/i.test(msg) && objectName !== '*') {
200+
result = await tryExecute('*');
201+
} else {
202+
throw err;
203+
}
204+
}
205+
return { handled: true, response: deps.success({ success: true, data: result }) };
206+
} catch (err: any) {
207+
const full = err?.message ?? String(err);
208+
// The sandbox wraps a user throw as `<kind> '<name>' threw: <msg>` for
209+
// server logs; surface only the business `<msg>` (SandboxError.innerMessage)
210+
// to the client so an action's error toast reads as plain text instead of
211+
// leaking the debug prefix. Keep the full wrapper in the log for debugging.
212+
const inner: unknown = err?.innerMessage;
213+
const clientMsg = (typeof inner === 'string' && inner) ? inner : full;
214+
if (clientMsg !== full) console.error(`[action ${objectName}/${actionName}] ${full}`);
215+
return { handled: true, response: deps.success({ success: false, error: clientMsg }) };
216+
}
217+
}

0 commit comments

Comments
 (0)