From f611684b30313bc4b0c9e41b9f5fea00173cd455 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 12:34:29 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20enforce=20declared=20action-param=20con?= =?UTF-8?q?tract=20at=20dispatch=20=E2=80=94=20ADR-0104=20phase=202=20(D2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An action's declared params[] (type/required/multiple/options/reference) was a complete value contract that only informed the client dialog — the server passed reqBody.params straight to the handler unvalidated (REST handleActions + MCP invokeBusinessAction), and handlers read an untyped bag. - @objectstack/spec/ui: validateActionParams (+ ResolvedActionParam, ActionParamIssue, ACTION_PARAM_BUILTIN_KEYS) — pure check reusing the D1 valueSchemaFor so option membership / multiple arrays / reference-id shape ride the one value contract; plus typed authoring surface ActionHandler / ActionHandlerContext / ActionEngineFacade. - runtime: both REST and MCP action paths resolve declared params (field-backed resolved through the referenced object field) and validate the request bag before the handler runs — required/shape/unknown-key; recordId/objectName allowlisted. Warn-first unless OS_ACTION_PARAMS_STRICT_ENABLED=1 (then 400). Actions with no declared params are untouched. - ScriptContext.input doc + registerAction JSDoc state the validated contract. Tests: spec 6847 (incl. 9 action-params units), runtime 587, objectql 1043, new action-params dogfood 3 (warn-first passes / strict 400 / conformant passes) — all green. API-surface snapshot updated (+7 additive exports). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd --- .../adr-0104-d2-typed-action-handlers.md | 38 +++++ packages/objectql/src/engine.ts | 6 +- .../action-params-contract.dogfood.test.ts | 69 ++++++++ packages/runtime/src/http-dispatcher.ts | 106 +++++++++++- packages/runtime/src/sandbox/script-runner.ts | 8 + packages/spec/api-surface.json | 7 + packages/spec/src/ui/action-params.test.ts | 76 +++++++++ packages/spec/src/ui/action-params.zod.ts | 159 ++++++++++++++++++ packages/spec/src/ui/index.ts | 2 + 9 files changed, 464 insertions(+), 7 deletions(-) create mode 100644 .changeset/adr-0104-d2-typed-action-handlers.md create mode 100644 packages/qa/dogfood/test/action-params-contract.dogfood.test.ts create mode 100644 packages/spec/src/ui/action-params.test.ts create mode 100644 packages/spec/src/ui/action-params.zod.ts diff --git a/.changeset/adr-0104-d2-typed-action-handlers.md b/.changeset/adr-0104-d2-typed-action-handlers.md new file mode 100644 index 0000000000..36e450de6e --- /dev/null +++ b/.changeset/adr-0104-d2-typed-action-handlers.md @@ -0,0 +1,38 @@ +--- +"@objectstack/spec": minor +"@objectstack/runtime": minor +--- + +feat: enforce declared action-param contract at dispatch — ADR-0104 phase 2 (D2) + +An action's declared `params[]` (`type` / `required` / `multiple` / `options` / +`reference`) was a complete value contract that only ever informed the client +dialog — the server passed `reqBody.params` straight to the handler unvalidated +(REST `handleActions` and the MCP `invokeBusinessAction` path), and handlers +read an untyped bag. D2 makes the declaration enforced and typed. + +- **`@objectstack/spec/ui`** now exports `validateActionParams` (+ + `ResolvedActionParam`, `ActionParamIssue`, `ACTION_PARAM_BUILTIN_KEYS`): a + pure check that validates a params bag against resolved param declarations, + reusing the D1 `valueSchemaFor` so option membership, `multiple` arrays and + reference-id shape all ride the one value contract. Also exports the typed + authoring surface `ActionHandler` / `ActionHandlerContext` / + `ActionEngineFacade` — annotate a handler with `ActionHandler` instead of + `(ctx: any)`. +- **Dispatch (runtime)**: both the REST and MCP action paths resolve the + action's declared params (field-backed params resolved through the referenced + object field) and validate the request bag **before the handler runs** — + required presence, per-type value shape, and unknown keys (the dispatcher's + own `recordId` / `objectName` are allowlisted). + +**Warn-first rollout (ADR-0104 R3).** A violation is **logged and passes** by +default — params that were silently wrong before keep working while the drift +becomes visible. Set `OS_ACTION_PARAMS_STRICT_ENABLED=1` to reject with a +`400 VALIDATION` (REST) / an error (MCP). Actions that declare no `params` are +untouched (nothing to validate against). The flip to strict-by-default rides a +later minor once telemetry is quiet. + +Not included: file/image params becoming `sys_file` references — that depends +on file-as-reference (ADR-0104 D3). Per-name static typing of `ctx.params` from +the literal `params` array is a deferred DX nicety; the runtime guarantee holds +regardless. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index e818e33c94..5be5537f67 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -672,7 +672,11 @@ export class ObjectQL implements IDataEngine { * * @param objectName Target object * @param actionName Unique action name within the object - * @param handler Handler function + * @param handler Handler function. Authoring sites should annotate it with + * `ActionHandler` from `@objectstack/spec/ui` (ADR-0104 D2) rather than an + * inline `(ctx: any)`; the params on `ctx` are validated against the + * action's declared param contract at dispatch before the handler runs. + * The seam itself stays untyped so existing untyped handlers keep working. * @param packageName Optional package owner (for cleanup) */ registerAction(objectName: string, actionName: string, handler: (ctx: any) => Promise | any, packageName?: string): void { diff --git a/packages/qa/dogfood/test/action-params-contract.dogfood.test.ts b/packages/qa/dogfood/test/action-params-contract.dogfood.test.ts new file mode 100644 index 0000000000..55b164dd9e --- /dev/null +++ b/packages/qa/dogfood/test/action-params-contract.dogfood.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0104 D2 — action param contract enforced at dispatch, over real HTTP. +// +// The showcase `showcase_action_param_gallery` action (on field-zoo) declares +// a required `p_text`, an option-bearing `p_priority` select, and an inline +// lookup `p_account`. Its body echoes the received param keys. We drive the +// real `/actions/:object/:action` route to prove the declared contract is +// enforced BEFORE the body runs: +// - warn-first (default): a malformed bag still passes (legacy callers keep +// working; the drift is logged, not fatal). +// - strict (OS_ACTION_PARAMS_STRICT_ENABLED=1): the same bag is rejected 400 +// before the handler runs; a conformant bag passes. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const ACTION_PATH = '/actions/showcase_field_zoo/showcase_action_param_gallery'; + +describe('dogfood: action param contract enforced at dispatch (ADR-0104 D2)', () => { + let stack: VerifyStack; + let token: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + token = await stack.signIn(); + }, 60_000); + + afterAll(async () => { + delete process.env.OS_ACTION_PARAMS_STRICT_ENABLED; + await stack?.stop(); + }); + + // A bag that violates the declaration three ways: missing required `p_text`, + // a `p_priority` outside its options, and an undeclared `bogus` key. + const badBag = { p_priority: 'NOT_AN_OPTION', bogus: 123 }; + const goodBag = { p_text: 'Hello', p_priority: 'high' }; + + it('warn-first (default): a malformed param bag still passes and the body runs', async () => { + delete process.env.OS_ACTION_PARAMS_STRICT_ENABLED; + const res = await stack.apiAs(token, 'POST', ACTION_PATH, { params: badBag }); + expect(res.status, `expected pass-through, got ${res.status}: ${await res.clone().text()}`).toBeLessThan(300); + }); + + it('strict: the same malformed bag is rejected 400 before the handler runs', async () => { + process.env.OS_ACTION_PARAMS_STRICT_ENABLED = '1'; + try { + const res = await stack.apiAs(token, 'POST', ACTION_PATH, { params: badBag }); + expect(res.status).toBe(400); + const text = await res.text(); + expect(text).toMatch(/p_text/); // required + expect(text).toMatch(/p_priority/); // bad option + expect(text).toMatch(/bogus/); // unknown key + } finally { + delete process.env.OS_ACTION_PARAMS_STRICT_ENABLED; + } + }); + + it('strict: a conformant bag passes (dispatcher built-in keys are allowlisted)', async () => { + process.env.OS_ACTION_PARAMS_STRICT_ENABLED = '1'; + try { + const res = await stack.apiAs(token, 'POST', ACTION_PATH, { params: goodBag }); + expect(res.status, `expected pass, got ${res.status}: ${await res.clone().text()}`).toBeLessThan(300); + } finally { + delete process.env.OS_ACTION_PARAMS_STRICT_ENABLED; + } + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index b070b21d8e..5c93a37b4a 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -10,6 +10,7 @@ import { CoreServiceName } from '@objectstack/spec/system'; import { readServiceSelfInfo } from '@objectstack/spec/api'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import { pluralToSingular, PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { validateActionParams, type ResolvedActionParam } from '@objectstack/spec/ui'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { setPackageDisabled } from './package-state-store.js'; import { checkApiExposure } from './api-exposure.js'; @@ -1001,6 +1002,79 @@ export class HttpDispatcher { return out; } + /** + * Resolve an action's declared `params[]` to their effective value-shape + * inputs (ADR-0104 D2). A field-backed param inherits type/multiple/ + * options/required from the referenced object field; an inline param + * carries them directly (inline overrides win). `obj` is the action's + * parent object schema (holds `.fields`); pass `undefined` for a global + * action with only inline params. + */ + private resolveDeclaredActionParams(action: any, obj: any): ResolvedActionParam[] { + const fields: Record = obj?.fields ?? {}; + const out: ResolvedActionParam[] = []; + for (const p of (Array.isArray(action?.params) ? action.params : [])) { + const fieldRef: string | undefined = p?.field; + const field = fieldRef ? fields[fieldRef] : undefined; + const name: string | undefined = p?.name ?? fieldRef; + if (!name) continue; + out.push({ + name, + type: p?.type ?? field?.type, + multiple: p?.multiple ?? field?.multiple, + required: Boolean(p?.required ?? field?.required ?? false), + options: p?.options ?? field?.options, + }); + } + return out; + } + + /** + * Enforce an action's declared param contract against the request bag + * BEFORE the handler runs (ADR-0104 D2). Returns a `400`-worthy error + * message when the contract is violated AND strict mode is on + * (`OS_ACTION_PARAMS_STRICT_ENABLED=1`); otherwise returns `null`, logging + * a one-time warning per (object/action) so the drift is visible without + * breaking callers whose params were silently wrong before (warn-first, R3). + * + * Actions that declare no `params` keep today's pass-through — there is + * nothing to validate against, so existing param-less actions are untouched. + */ + private enforceActionParams( + action: any, + obj: any, + bag: Record, + where: { objectName?: string; actionName?: string }, + ): string | null { + if (!Array.isArray(action?.params) || action.params.length === 0) return null; + const resolved = this.resolveDeclaredActionParams(action, obj); + const issues = validateActionParams(resolved, bag); + if (issues.length === 0) return null; + const summary = issues.map((i) => i.message).join('; '); + if (HttpDispatcher.actionParamsStrict()) { + return `Invalid action params: ${summary}`; + } + const key = `${where.objectName ?? 'global'}/${where.actionName ?? action?.name ?? 'action'}`; + HttpDispatcher.warnActionParamsOnce( + key, + `[action-params] ${key}: ${summary} — accepted for now (ADR-0104 D2 warn-first; ` + + `set OS_ACTION_PARAMS_STRICT_ENABLED=1 to reject with 400)`, + ); + return null; + } + + /** Strict action-param enforcement opt-in (ADR-0104 D2 warn-first rollout). */ + private static actionParamsStrict(): boolean { + return typeof process !== 'undefined' && process.env?.OS_ACTION_PARAMS_STRICT_ENABLED === '1'; + } + + private static readonly _warnedActionParams = new Set(); + private static warnActionParamsOnce(key: string, message: string): void { + if (HttpDispatcher._warnedActionParams.has(key)) return; + HttpDispatcher._warnedActionParams.add(key); + console.warn(message); + } + /** * Slim engine facade matching the ActionContext.engine shape handlers expect. * @@ -1102,7 +1176,7 @@ export class HttpDispatcher { : `Action '${name}' not found`, ); } - const { action, objectName } = resolved; + const { action, objectName, obj } = resolved; // Fail-closed on system-object actions (mirrors the object-tool guard). if (isSystemObjectName(objectName)) { @@ -1125,6 +1199,13 @@ export class HttpDispatcher { const gateError = this.actionPermissionError(action, ec, objectName); if (gateError) throw new Error(gateError); + // [ADR-0104 D2] Declared param contract — same enforcement as the REST + // route. AI/MCP is the caller most likely to send a plausible-but-wrong + // bag, so the check belongs here too. Warn-first unless + // OS_ACTION_PARAMS_STRICT_ENABLED=1 (then throws → surfaced as an error). + const paramError = this.enforceActionParams(action, obj, params, { objectName, actionName: name }); + if (paramError) throw new Error(paramError); + // Load the subject record under RLS when row-context (engages the same // permission path as get_record — an unseen record reads as not-found). let record: Record = {}; @@ -1229,11 +1310,11 @@ export class HttpDispatcher { meta: any, name: string, objectName?: string, - ): Promise<{ action: any; objectName: string } | null> { + ): Promise<{ action: any; objectName: string; obj: any } | null> { const decls = await this.collectActionDeclarations(meta); if (objectName) { const hit = decls.find((d) => d.objectName === objectName && d.action?.name === name); - return hit ? { action: hit.action, objectName } : null; + return hit ? { action: hit.action, objectName, obj: hit.obj } : null; } const matches = decls.filter((d) => d.action?.name === name); if (matches.length === 0) return null; @@ -1241,7 +1322,7 @@ export class HttpDispatcher { const where = matches.map((m) => m.objectName).join(', '); throw new Error(`Action '${name}' exists on multiple objects (${where}); pass objectName to disambiguate`); } - return { action: matches[0].action, objectName: matches[0].objectName }; + return { action: matches[0].action, objectName: matches[0].objectName, obj: matches[0].obj }; } /** @@ -3855,11 +3936,16 @@ export class HttpDispatcher { // 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 { - const actionSchema: any = + actionSchema = (typeof ql.getSchema === 'function' ? ql.getSchema(objectName) : undefined) ?? ql.registry?.getObject?.(objectName); - const actionDef: any = Array.isArray(actionSchema?.actions) + actionDef = Array.isArray(actionSchema?.actions) ? actionSchema.actions.find((a: any) => a?.name === actionName) : undefined; const gateError = this.actionPermissionError(actionDef, _context?.executionContext, objectName); @@ -3881,6 +3967,14 @@ export class HttpDispatcher { 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 = this.enforceActionParams(actionDef, actionSchema, reqParams, { objectName, actionName }); + if (paramError) { + return { handled: true, response: this.error(paramError, 400) }; + } + // Load the record (best-effort) so handlers can rely on `ctx.record`. let record: Record = {}; if (recordId && objectName !== 'global') { diff --git a/packages/runtime/src/sandbox/script-runner.ts b/packages/runtime/src/sandbox/script-runner.ts index 427bc77ed0..3c79118b53 100644 --- a/packages/runtime/src/sandbox/script-runner.ts +++ b/packages/runtime/src/sandbox/script-runner.ts @@ -57,6 +57,14 @@ export interface ScriptOrigin { * actually wired up. */ export interface ScriptContext { + /** + * The script input. For an action body this is the action's params bag, + * validated against the action's declared param contract at dispatch before + * the body runs (ADR-0104 D2) — so its shape conforms to the declaration. + * Typed `unknown` because the sandbox is a single generic seam over hooks + * (record) and action bodies (params); the guarantee is enforced upstream, + * not by this type. + */ input: unknown; previous?: unknown; user?: unknown; diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index ff6c8b0ba1..eb15bb7859 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3076,16 +3076,21 @@ ], "./ui": [ "ACTION_LOCATIONS (const)", + "ACTION_PARAM_BUILTIN_KEYS (const)", "AIChatWindowProps (const)", "Action (type)", "ActionAi (type)", "ActionAiSchema (const)", + "ActionEngineFacade (interface)", + "ActionHandler (type)", + "ActionHandlerContext (interface)", "ActionInput (type)", "ActionLocation (type)", "ActionLocationSchema (const)", "ActionNavItem (type)", "ActionNavItemSchema (const)", "ActionParam (type)", + "ActionParamIssue (interface)", "ActionParamSchema (const)", "ActionSchema (const)", "ActionType (const)", @@ -3374,6 +3379,7 @@ "ReportNavItemSchema (const)", "ReportSchema (const)", "ReportType (const)", + "ResolvedActionParam (interface)", "ResponsiveConfig (type)", "ResponsiveConfigSchema (const)", "ResponsiveStyles (type)", @@ -3487,6 +3493,7 @@ "normalizeFilterOperator (function)", "pageForm (const)", "reportForm (const)", + "validateActionParams (function)", "viewForm (const)" ], "./contracts": [ diff --git a/packages/spec/src/ui/action-params.test.ts b/packages/spec/src/ui/action-params.test.ts new file mode 100644 index 0000000000..6cbffce8d0 --- /dev/null +++ b/packages/spec/src/ui/action-params.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateActionParams, + ACTION_PARAM_BUILTIN_KEYS, + type ResolvedActionParam, +} from './action-params.zod'; + +const codes = (issues: ReturnType) => issues.map((i) => i.code).sort(); + +describe('validateActionParams (ADR-0104 D2)', () => { + it('accepts a conformant bag (no issues)', () => { + const resolved: ResolvedActionParam[] = [ + { name: 'title', type: 'text', required: true }, + { name: 'amount', type: 'currency' }, + { name: 'assignee', type: 'lookup' }, + { name: 'labels', type: 'multiselect', options: [{ value: 'a' }, { value: 'b' }] }, + ]; + const bag = { title: 'Hi', amount: 12.5, assignee: 'usr_1', labels: ['a', 'b'] }; + expect(validateActionParams(resolved, bag)).toEqual([]); + }); + + it('flags a missing required param', () => { + const resolved: ResolvedActionParam[] = [{ name: 'title', type: 'text', required: true }]; + const issues = validateActionParams(resolved, {}); + expect(codes(issues)).toEqual(['required']); + expect(issues[0].param).toBe('title'); + }); + + it('does not flag a missing OPTIONAL param', () => { + const resolved: ResolvedActionParam[] = [{ name: 'note', type: 'text' }]; + expect(validateActionParams(resolved, {})).toEqual([]); + }); + + it('flags a bad option value and a wrong-typed value (invalid_shape)', () => { + const resolved: ResolvedActionParam[] = [ + { name: 'priority', type: 'select', options: [{ value: 'high' }, { value: 'low' }] }, + { name: 'count', type: 'number' }, + ]; + const issues = validateActionParams(resolved, { priority: 'HIGH', count: 'not-a-number' }); + expect(codes(issues)).toEqual(['invalid_shape', 'invalid_shape']); + }); + + it('enforces reference id shape (expanded object rejected at the stored position)', () => { + const resolved: ResolvedActionParam[] = [{ name: 'account', type: 'lookup' }]; + const issues = validateActionParams(resolved, { account: { id: 'acc_1', name: 'Acme' } }); + expect(codes(issues)).toEqual(['invalid_shape']); + }); + + it('enforces multiple → array', () => { + const resolved: ResolvedActionParam[] = [{ name: 'watchers', type: 'user', multiple: true }]; + expect(validateActionParams(resolved, { watchers: ['u1', 'u2'] })).toEqual([]); + expect(codes(validateActionParams(resolved, { watchers: 'u1' }))).toEqual(['invalid_shape']); + }); + + it('flags unknown params, but allows the dispatcher built-in keys', () => { + const resolved: ResolvedActionParam[] = [{ name: 'title', type: 'text' }]; + const issues = validateActionParams(resolved, { title: 'x', bogus: 1, recordId: 'r1', objectName: 'o' }); + expect(codes(issues)).toEqual(['unknown_param']); + expect(issues[0].param).toBe('bogus'); + expect(ACTION_PARAM_BUILTIN_KEYS).toContain('recordId'); + expect(ACTION_PARAM_BUILTIN_KEYS).toContain('objectName'); + }); + + it('leaves the value shape OPEN when the resolved type is unknown (field-backed param whose field is gone)', () => { + const resolved: ResolvedActionParam[] = [{ name: 'freeform' /* no type */ }]; + expect(validateActionParams(resolved, { freeform: { anything: [1, 2, 3] } })).toEqual([]); + }); + + it('custom builtinKeys override the default allowlist', () => { + const resolved: ResolvedActionParam[] = [{ name: 'title', type: 'text' }]; + const issues = validateActionParams(resolved, { title: 'x', ctxToken: 'z' }, { builtinKeys: ['ctxToken'] }); + expect(issues).toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/action-params.zod.ts b/packages/spec/src/ui/action-params.zod.ts new file mode 100644 index 0000000000..89d16622b3 --- /dev/null +++ b/packages/spec/src/ui/action-params.zod.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Action-param VALUE validation (ADR-0104 D2). + * + * An action's declared `params[]` is a complete value contract — `type`, + * `required`, `multiple`, `options`, `reference` — but before this it only + * informed the client dialog: the server passed `reqBody.params` straight to + * the handler, unvalidated (`http-dispatcher.ts`). This module is the pure + * contract that lets the REST and MCP dispatch paths enforce that declaration + * BEFORE the handler runs, reusing the D1 field value-shape contract + * (`valueSchemaFor`). + * + * Purity: schema derivation only (Prime Directive #2). Field-backed params are + * resolved to their effective value-shape inputs by the CALLER (the runtime, + * which holds the object metadata registry); this module validates the already + * resolved descriptors. + */ + +import { valueSchemaFor } from '../data/field-value.zod'; + +/** + * A declared action param resolved to its effective value-shape inputs. A + * field-backed param (`{ field: 'x' }`) is resolved by the caller through the + * referenced object field (type/multiple/options/required inherited); an + * inline param carries them directly. + */ +export interface ResolvedActionParam { + /** Request-body key (inline `name`, or the resolved field name). */ + name: string; + /** Effective field type; when unknown the value shape is left open. */ + type?: string; + /** Array-valued when true (or an inherently-multi type). */ + multiple?: boolean; + /** Whether a value must be present. */ + required?: boolean; + /** Option set for select-like params (membership is enforced). */ + options?: Array<{ value: string | number } | string | number>; +} + +export interface ActionParamIssue { + /** The offending param key. */ + param: string; + code: 'required' | 'invalid_shape' | 'unknown_param'; + message: string; +} + +/** + * Keys the dispatcher merges into the params bag itself (never authored by the + * caller) — always permitted, never flagged as unknown. + */ +export const ACTION_PARAM_BUILTIN_KEYS: readonly string[] = ['recordId', 'objectName']; + +function isPresent(v: unknown): boolean { + return v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === ''); +} + +/** + * Validate a params bag against an action's resolved param declarations. + * Returns the list of issues (empty = conformant). Does NOT throw — the caller + * decides warn-vs-reject (ADR-0104 D2 warn-first rollout, R3). + * + * Enforced: `required` presence, per-type value shape (via the D1 + * `valueSchemaFor`, so option membership / `multiple` arrays / reference-id + * shape all ride the one contract), and unknown keys (not declared, not a + * built-in). A param with no resolvable `type` leaves its value shape open. + */ +export function validateActionParams( + resolved: ResolvedActionParam[], + bag: Record, + opts?: { builtinKeys?: readonly string[] }, +): ActionParamIssue[] { + const issues: ActionParamIssue[] = []; + const declared = new Map(); + for (const p of resolved) if (p?.name) declared.set(p.name, p); + const allow = new Set(opts?.builtinKeys ?? ACTION_PARAM_BUILTIN_KEYS); + + for (const p of declared.values()) { + const value = bag[p.name]; + if (!isPresent(value)) { + if (p.required) { + issues.push({ param: p.name, code: 'required', message: `Action param "${p.name}" is required` }); + } + continue; + } + // `type ?? ''` → the value contract's open default for an unresolvable type + // (field-backed param whose field is gone, or an inline param with no type). + const schema = valueSchemaFor({ type: p.type ?? '', multiple: p.multiple, options: p.options }, 'stored'); + const result = schema.safeParse(value); + if (!result.success) { + const detail = result.error.issues[0]?.message ?? 'invalid value'; + issues.push({ + param: p.name, + code: 'invalid_shape', + message: `Action param "${p.name}"${p.type ? ` (${p.type})` : ''}: ${detail}`, + }); + } + } + + for (const key of Object.keys(bag)) { + if (declared.has(key) || allow.has(key)) continue; + issues.push({ + param: key, + code: 'unknown_param', + message: `Unknown action param "${key}" — not declared on this action`, + }); + } + + return issues; +} + +/** + * The slim engine facade an action handler's `ctx.engine` exposes. TRUSTED — + * context-less, RLS/FLS-bypassing by design (#2849); the boundary is enforced + * at invoke time (`ai.exposed` + the ADR-0066 D4 capability gate), not here. + */ +export interface ActionEngineFacade { + insert(object: string, data: Record): Promise<{ id: string }>; + update(object: string, id: string, data: Record): Promise; + delete(object: string, id: string): Promise; + find(object: string, query: Record): Promise>>; +} + +/** + * The runtime context an action handler receives (ADR-0104 D2). `params` is + * validated against the action's declared param contract at dispatch BEFORE + * the handler runs, so its values conform to the declared value shapes — it is + * typed as a bag because resolving per-name value types would require the + * literal `params` array the registration seam does not carry (a DX nicety + * deferred; the runtime guarantee holds regardless). + * + * Authoring an action handler? Annotate it with `ActionHandler` instead of an + * inline `(ctx: any)` — that is what retires the untyped-bag pattern this + * design targets. + */ +export interface ActionHandlerContext< + TParams extends Record = Record, + TRecord extends Record = Record, +> { + /** The subject record (loaded under the caller's read scope), or `{}`. */ + record: TRecord; + /** The validated action params (plus dispatcher-injected recordId/objectName). */ + params: TParams; + /** The invoking principal. */ + user: { id: string; name?: string; email?: string; organizationId?: string; [k: string]: unknown }; + /** Caller session (active org / roles), mirroring the hook `ctx.session`. */ + session?: { userId?: string; organizationId?: string; roles?: string[]; [k: string]: unknown }; + /** Trusted engine facade for cross-object writes (see {@link ActionEngineFacade}). */ + engine: ActionEngineFacade; +} + +/** + * A typed action handler. Replaces the `(ctx: any) => any` shape at authoring + * sites; the objectql registration seam still accepts untyped handlers, so + * this is opt-in and non-breaking. + */ +export type ActionHandler< + TParams extends Record = Record, +> = (ctx: ActionHandlerContext) => unknown | Promise; diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index 730fbebbb6..231e298beb 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -26,6 +26,8 @@ export { datasetForm } from './dataset.form'; export { actionForm } from './action.form'; export { pageForm } from './page.form'; export * from './action.zod'; +// Action-param value validation (ADR-0104 D2) +export * from './action-params.zod'; export * from './page.zod'; export * from './widget.zod'; export * from './component.zod';