feat(submit): submit v3 con validación de integridad server-side [4G]

RPC transaccional SECURITY DEFINER (rpc_submit_response) como único camino
de escritura. Dropea las policies WITH CHECK(true): cierra la falla donde
anon podía insertar cualquier cosa. Validación server-side (7 checks)
reusando el motor. Persistencia atómica con instance_id por instancia.
Verificado contra Postgres real: instancias separadas, atomicidad, RLS.
Riesgo residual de bypass por REST documentado (SEC-004).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
markosbenitez
2026-06-24 21:27:49 -03:00
parent 61676e1423
commit 2e6fbd7801
10 changed files with 701 additions and 91 deletions

150
lib/submit-validation.ts Normal file
View File

@@ -0,0 +1,150 @@
// Validación de integridad server-side del submit (Etapa 4G).
// REUSA lib/form-engine.ts (resolveVisibility, resolveScreens, expandInstances) —
// no reimplementa lógica de flujo. Corre en el servidor (Server Action), nunca
// en el cliente: "la validación de cliente es UX, NO seguridad" (BRIEF decisión 15).
//
// Lo que esta función NO hace: persistir nada. Solo decide si el payload recibido
// es coherente con el esquema + el propio flujo declarado por el payload, y si es
// así, devuelve las filas listas para insertar (mapeadas a question_id).
import type { AnswerValue, FormAnswers, FormSchema, Question } from './form-engine.types'
// Extensión .ts explícita (no solo en .mts): a diferencia de form-engine.ts —
// que solo importa *tipos* de form-engine.types (se borran en runtime, Node
// nunca necesita resolverlos) — este archivo importa FUNCIONES reales y
// corre tanto bajo Next.js (Server Action) como bajo Node puro (tests). Node
// exige extensión explícita para resolver imports de valor; tsconfig.json
// habilita allowImportingTsExtensions para permitir esto sin romper tsc.
import { answerKey, expandInstances, isAnswerEmpty, resolveScreens, resolveVisibility } from './form-engine.ts'
export interface SubmitAnswerEntry {
code: string
/** 'default' para preguntas normales; 'familiar_N' para preguntas de instancia. */
instanceId: string
value: AnswerValue
}
export interface ValidatedRow {
questionId: string
instanceId: string
value: AnswerValue
}
export type ValidationResult =
| { ok: true; rows: ValidatedRow[] }
| { ok: false; errors: string[] }
function validateValueShape(question: Question, value: AnswerValue): string | null {
switch (question.type) {
case 'yes_no':
return value === 'true' || value === 'false' ? null : 'invalid_yes_no_value'
case 'single_choice': {
if (typeof value !== 'string') return 'expected_string_value'
if (question.options && !question.options.some((o) => o.value === value)) {
return 'value_not_in_options'
}
return null
}
case 'multiple_choice': {
if (!Array.isArray(value)) return 'expected_array_value'
if (!value.every((v) => typeof v === 'string')) return 'expected_string_array'
if (question.max_select !== null && value.length > question.max_select) {
return 'exceeds_max_select'
}
if (question.options) {
const valid = new Set(question.options.map((o) => o.value))
if (!value.every((v) => valid.has(v as string))) return 'value_not_in_options'
}
return null
}
case 'text_short':
case 'text_long':
return typeof value === 'string' ? null : 'expected_string_value'
// rating/nps/matrix/ranking/date/slider: no usados por el instrumento v3 hoy.
// No se valida su forma específica — si en el futuro se usan, agregar el caso.
default:
return null
}
}
/**
* Corre las 7 validaciones del punto 4 de ETAPA_4G_PROMPT.md contra el
* esquema cargado de la DB. Si CUALQUIERA falla, devuelve ok:false con la
* lista completa de errores encontrados (no solo el primero) — el caller
* rechaza el submit entero, sin insertar nada.
*/
export function validateSubmission(
schema: FormSchema,
entries: SubmitAnswerEntry[],
consentOperativo: boolean
): ValidationResult {
const errors: string[] = []
const byCode = new Map(schema.questions.map((q) => [q.code, q] as const))
// Reconstruye el mismo FormAnswers que tuvo el wizard — las funciones del
// motor lo necesitan para evaluar reglas/instancias/visibilidad.
const answers: FormAnswers = {}
for (const e of entries) answers[answerKey(e.code, e.instanceId)] = e.value
// 6. Consentimiento operativo requerido presente
if (!consentOperativo) errors.push('consent_operativo_required')
const nonEmpty = entries.filter((e) => !isAnswerEmpty(e.value))
// 2. Preguntas legítimas · 3. Valores válidos · 5. Instancias legítimas
for (const e of nonEmpty) {
const q = byCode.get(e.code)
if (!q) { errors.push(`unknown_question:${e.code}`); continue }
if (q.type === 'section') { errors.push(`section_is_not_answerable:${e.code}`); continue }
if (q.instance_of) {
const generator = byCode.get(q.instance_of)
const instances = generator ? expandInstances(generator, answers) : []
if (!instances.some((i) => i.instanceId === e.instanceId)) {
errors.push(`invalid_instance:${e.code}:${e.instanceId}`)
continue
}
} else if (e.instanceId !== 'default') {
errors.push(`unexpected_instance_id:${e.code}:${e.instanceId}`)
continue
}
const shapeError = validateValueShape(q, e.value)
if (shapeError) errors.push(`${shapeError}:${e.code}`)
}
if (errors.length > 0) return { ok: false, errors }
// 4. Visibilidad coherente — cada respuesta corresponde a algo que el
// flujo (con las respuestas recibidas) debía mostrar. Usa resolveVisibility
// del motor directamente — ninguna lógica de show/hide se repite acá.
for (const e of nonEmpty) {
const q = byCode.get(e.code)!
if (!resolveVisibility(q, answers)) {
errors.push(`question_should_be_hidden:${e.code}:${e.instanceId}`)
}
}
if (errors.length > 0) return { ok: false, errors }
// 7. Requeridas presentes — recorre las pantallas que el motor resuelve
// como visibles dadas estas respuestas, exige valor donde required=true.
const screens = resolveScreens(schema, answers)
for (const s of screens) {
if (s.kind !== 'question' || !s.question?.required) continue
const v = answers[answerKey(s.question.code, s.instanceId ?? undefined)]
if (isAnswerEmpty(v)) {
errors.push(`required_missing:${s.question.code}:${s.instanceId ?? 'default'}`)
}
}
if (errors.length > 0) return { ok: false, errors }
const rows: ValidatedRow[] = nonEmpty.map((e) => ({
questionId: byCode.get(e.code)!.id,
instanceId: e.instanceId,
value: e.value,
}))
return { ok: true, rows }
}