|
| 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