@@ -10,6 +10,7 @@ import { CoreServiceName } from '@objectstack/spec/system';
1010import { readServiceSelfInfo } from '@objectstack/spec/api' ;
1111import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai' ;
1212import { pluralToSingular , PLURAL_TO_SINGULAR } from '@objectstack/spec/shared' ;
13+ import { validateActionParams , type ResolvedActionParam } from '@objectstack/spec/ui' ;
1314import type { ExecutionContext } from '@objectstack/spec/kernel' ;
1415import { setPackageDisabled } from './package-state-store.js' ;
1516import { checkApiExposure } from './api-exposure.js' ;
@@ -1001,6 +1002,79 @@ export class HttpDispatcher {
10011002 return out ;
10021003 }
10031004
1005+ /**
1006+ * Resolve an action's declared `params[]` to their effective value-shape
1007+ * inputs (ADR-0104 D2). A field-backed param inherits type/multiple/
1008+ * options/required from the referenced object field; an inline param
1009+ * carries them directly (inline overrides win). `obj` is the action's
1010+ * parent object schema (holds `.fields`); pass `undefined` for a global
1011+ * action with only inline params.
1012+ */
1013+ private resolveDeclaredActionParams ( action : any , obj : any ) : ResolvedActionParam [ ] {
1014+ const fields : Record < string , any > = obj ?. fields ?? { } ;
1015+ const out : ResolvedActionParam [ ] = [ ] ;
1016+ for ( const p of ( Array . isArray ( action ?. params ) ? action . params : [ ] ) ) {
1017+ const fieldRef : string | undefined = p ?. field ;
1018+ const field = fieldRef ? fields [ fieldRef ] : undefined ;
1019+ const name : string | undefined = p ?. name ?? fieldRef ;
1020+ if ( ! name ) continue ;
1021+ out . push ( {
1022+ name,
1023+ type : p ?. type ?? field ?. type ,
1024+ multiple : p ?. multiple ?? field ?. multiple ,
1025+ required : Boolean ( p ?. required ?? field ?. required ?? false ) ,
1026+ options : p ?. options ?? field ?. options ,
1027+ } ) ;
1028+ }
1029+ return out ;
1030+ }
1031+
1032+ /**
1033+ * Enforce an action's declared param contract against the request bag
1034+ * BEFORE the handler runs (ADR-0104 D2). Returns a `400`-worthy error
1035+ * message when the contract is violated AND strict mode is on
1036+ * (`OS_ACTION_PARAMS_STRICT_ENABLED=1`); otherwise returns `null`, logging
1037+ * a one-time warning per (object/action) so the drift is visible without
1038+ * breaking callers whose params were silently wrong before (warn-first, R3).
1039+ *
1040+ * Actions that declare no `params` keep today's pass-through — there is
1041+ * nothing to validate against, so existing param-less actions are untouched.
1042+ */
1043+ private enforceActionParams (
1044+ action : any ,
1045+ obj : any ,
1046+ bag : Record < string , unknown > ,
1047+ where : { objectName ?: string ; actionName ?: string } ,
1048+ ) : string | null {
1049+ if ( ! Array . isArray ( action ?. params ) || action . params . length === 0 ) return null ;
1050+ const resolved = this . resolveDeclaredActionParams ( action , obj ) ;
1051+ const issues = validateActionParams ( resolved , bag ) ;
1052+ if ( issues . length === 0 ) return null ;
1053+ const summary = issues . map ( ( i ) => i . message ) . join ( '; ' ) ;
1054+ if ( HttpDispatcher . actionParamsStrict ( ) ) {
1055+ return `Invalid action params: ${ summary } ` ;
1056+ }
1057+ const key = `${ where . objectName ?? 'global' } /${ where . actionName ?? action ?. name ?? 'action' } ` ;
1058+ HttpDispatcher . warnActionParamsOnce (
1059+ key ,
1060+ `[action-params] ${ key } : ${ summary } — accepted for now (ADR-0104 D2 warn-first; ` +
1061+ `set OS_ACTION_PARAMS_STRICT_ENABLED=1 to reject with 400)` ,
1062+ ) ;
1063+ return null ;
1064+ }
1065+
1066+ /** Strict action-param enforcement opt-in (ADR-0104 D2 warn-first rollout). */
1067+ private static actionParamsStrict ( ) : boolean {
1068+ return typeof process !== 'undefined' && process . env ?. OS_ACTION_PARAMS_STRICT_ENABLED === '1' ;
1069+ }
1070+
1071+ private static readonly _warnedActionParams = new Set < string > ( ) ;
1072+ private static warnActionParamsOnce ( key : string , message : string ) : void {
1073+ if ( HttpDispatcher . _warnedActionParams . has ( key ) ) return ;
1074+ HttpDispatcher . _warnedActionParams . add ( key ) ;
1075+ console . warn ( message ) ;
1076+ }
1077+
10041078 /**
10051079 * Slim engine facade matching the ActionContext.engine shape handlers expect.
10061080 *
@@ -1102,7 +1176,7 @@ export class HttpDispatcher {
11021176 : `Action '${ name } ' not found` ,
11031177 ) ;
11041178 }
1105- const { action, objectName } = resolved ;
1179+ const { action, objectName, obj } = resolved ;
11061180
11071181 // Fail-closed on system-object actions (mirrors the object-tool guard).
11081182 if ( isSystemObjectName ( objectName ) ) {
@@ -1125,6 +1199,13 @@ export class HttpDispatcher {
11251199 const gateError = this . actionPermissionError ( action , ec , objectName ) ;
11261200 if ( gateError ) throw new Error ( gateError ) ;
11271201
1202+ // [ADR-0104 D2] Declared param contract — same enforcement as the REST
1203+ // route. AI/MCP is the caller most likely to send a plausible-but-wrong
1204+ // bag, so the check belongs here too. Warn-first unless
1205+ // OS_ACTION_PARAMS_STRICT_ENABLED=1 (then throws → surfaced as an error).
1206+ const paramError = this . enforceActionParams ( action , obj , params , { objectName, actionName : name } ) ;
1207+ if ( paramError ) throw new Error ( paramError ) ;
1208+
11281209 // Load the subject record under RLS when row-context (engages the same
11291210 // permission path as get_record — an unseen record reads as not-found).
11301211 let record : Record < string , unknown > = { } ;
@@ -1229,19 +1310,19 @@ export class HttpDispatcher {
12291310 meta : any ,
12301311 name : string ,
12311312 objectName ?: string ,
1232- ) : Promise < { action : any ; objectName : string } | null > {
1313+ ) : Promise < { action : any ; objectName : string ; obj : any } | null > {
12331314 const decls = await this . collectActionDeclarations ( meta ) ;
12341315 if ( objectName ) {
12351316 const hit = decls . find ( ( d ) => d . objectName === objectName && d . action ?. name === name ) ;
1236- return hit ? { action : hit . action , objectName } : null ;
1317+ return hit ? { action : hit . action , objectName, obj : hit . obj } : null ;
12371318 }
12381319 const matches = decls . filter ( ( d ) => d . action ?. name === name ) ;
12391320 if ( matches . length === 0 ) return null ;
12401321 if ( matches . length > 1 ) {
12411322 const where = matches . map ( ( m ) => m . objectName ) . join ( ', ' ) ;
12421323 throw new Error ( `Action '${ name } ' exists on multiple objects (${ where } ); pass objectName to disambiguate` ) ;
12431324 }
1244- return { action : matches [ 0 ] . action , objectName : matches [ 0 ] . objectName } ;
1325+ return { action : matches [ 0 ] . action , objectName : matches [ 0 ] . objectName , obj : matches [ 0 ] . obj } ;
12451326 }
12461327
12471328 /**
@@ -3855,11 +3936,16 @@ export class HttpDispatcher {
38553936 // server-closed (and the inverse footgun is removed). System/engine
38563937 // self-invocation (isSystem) bypasses; an unauthenticated caller holds
38573938 // no capabilities and is therefore denied for a gated action.
3939+ // Resolve the object schema + this action's declaration once — both the
3940+ // permission gate (ADR-0066 D4) and the param contract (ADR-0104 D2)
3941+ // read it.
3942+ let actionSchema : any ;
3943+ let actionDef : any ;
38583944 try {
3859- const actionSchema : any =
3945+ actionSchema =
38603946 ( typeof ql . getSchema === 'function' ? ql . getSchema ( objectName ) : undefined ) ??
38613947 ql . registry ?. getObject ?.( objectName ) ;
3862- const actionDef : any = Array . isArray ( actionSchema ?. actions )
3948+ actionDef = Array . isArray ( actionSchema ?. actions )
38633949 ? actionSchema . actions . find ( ( a : any ) => a ?. name === actionName )
38643950 : undefined ;
38653951 const gateError = this . actionPermissionError ( actionDef , _context ?. executionContext , objectName ) ;
@@ -3881,6 +3967,14 @@ export class HttpDispatcher {
38813967 const recordId = recordIdFromPath ?? reqBody . recordId ;
38823968 const reqParams = ( reqBody . params && typeof reqBody . params === 'object' ) ? reqBody . params : { } ;
38833969
3970+ // [ADR-0104 D2] Enforce the declared param contract before the handler
3971+ // runs — required/option/multiple/reference-id shape + unknown keys.
3972+ // Warn-first unless OS_ACTION_PARAMS_STRICT_ENABLED=1 (then a 400).
3973+ const paramError = this . enforceActionParams ( actionDef , actionSchema , reqParams , { objectName, actionName } ) ;
3974+ if ( paramError ) {
3975+ return { handled : true , response : this . error ( paramError , 400 ) } ;
3976+ }
3977+
38843978 // Load the record (best-effort) so handlers can rely on `ctx.record`.
38853979 let record : Record < string , unknown > = { } ;
38863980 if ( recordId && objectName !== 'global' ) {
0 commit comments