# Aprobaciones

> Cómo OpenWAF Tasks deja las escrituras en manos de una persona. La guardia de solo lectura, los pasos tipados, el hash, la fase de actuar, la caducidad y el webhook.

Una tarea nunca escribe sola en un sitio. Propone una acción, una persona aprueba esa acción exacta y solo entonces el runner la realiza, sin tu código ni el modelo.

## La guardia de solo lectura

Mientras una ejecución prepara, el runner mantiene su propia conexión DevTools con el navegador, enganchada a cada página, marco y worker. Hace fallar toda petición cuyo método no sea `GET`, `HEAD` u `OPTIONS` y la registra como evento `blocked` (se cuenta en `blocked_requests` de la ejecución). Ni tu script ni el agente pueden quitarla: es una conexión aparte que nunca ven.

Un envío de formulario, un `fetch` con `POST` o un `DELETE` desde la página fallan en esta fase. Las páginas que necesitan un `POST` solo para leerse no funcionan en una tarea.

## Pasos

Una acción es una lista de 1 a 50 pasos tipados. El primero debe ser `goto`. Los selectores son selectores de Playwright (`css=`, `text=`, `role=`…; CSS atraviesa las shadow roots abiertas).

| `op` | Campos | Qué hace |
|---|---|---|
| `goto` | `url` | Abre una URL `https://` de uno de los dominios permitidos de la tarea |
| `click` | `selector` | Hace clic en el elemento |
| `fill` | `selector`, `text` | Fija el valor de un campo o área de texto |
| `type` | `selector`, `text` | Escribe el texto tecla a tecla |
| `press` | `key`, `selector` opcional | Pulsa una tecla, por ejemplo `Enter` |
| `select` | `selector`, `value` | Elige una opción de un `<select>` |
| `check` | `selector` | Marca una casilla o botón de opción |
| `wait_for` | `selector` o `text` | Espera a que aparezca |
| `wait` | `ms` | Espera de 1 a 10000 ms |
| `assert_url` | `url` | Se para con error salvo que la URL de la página empiece así (`https://`, dominio permitido) |
| `assert_text` | `text` | Se para con error salvo que la página lo muestre |

Los textos admiten hasta 10000 caracteres, los selectores y valores hasta 2000 y las URL hasta 4000. Una propuesta lleva además un `summary` de una línea (hasta 300 caracteres), el `target` (la URL `https://` donde cae la escritura; su host es el **dominio** de la acción) y una captura de la página tal como la vio la tarea.

Usa `assert_url` y `assert_text` para que una acción falle en vez de hacer otra cosa si la página ha cambiado desde que se propuso.

## El hash de la carga

OpenWAF valida los pasos contra los dominios permitidos de la tarea y guarda `payload_sha256`: el SHA-256 en hexadecimal del JSON canónico de `{"steps": [...]}`, con los campos en el orden de la tabla (`op`, `url`, `selector`, `text`, `key`, `value`, `ms`), sin los campos vacíos, sin escapar HTML y sin salto de línea final.

Para aprobar devuelves ese hash con `POST /v1/orgs/{org}/task-approvals/{id}/approve`. Si no coincide con el guardado la respuesta es `409 payload_changed`: apruebas lo que has visto y nada más. El runner vuelve a calcular el hash antes de actuar.

## La fase de actuar

Tras la aprobación el runner abre un navegador nuevo con la clave y el perfil de la tarea, comprueba el hash y repite él mismo los pasos. No se ejecuta ni tu script ni el modelo. Las peticiones con métodos de escritura solo se permiten hacia el dominio de la acción y sus subdominios. La aprobación recibe `executed_at`, una captura final y un `result`: `ok`, `final_url` y `error`.

Si un [límite por dominio](https://developers.openwaf.dev/es/tasks/limits/#per-domain) aún no permite la acción, la aprobación sigue `approved` con `not_before` y se ejecuta entonces.

## Estados

| Aprobación | Significado |
|---|---|
| `pending` | Espera a una persona |
| `approved` | Aprobada; espera al runner o a `not_before` |
| `executed` | Los pasos se ejecutaron hasta el final |
| `failed` | Falló un paso; `result.error` dice cuál |
| `rejected` | Una persona la rechazó |
| `expired` | Nadie decidió a tiempo |

Una ejecución con propuestas sigue en `awaiting_approval` hasta que no queda ninguna pendiente. Entonces termina `succeeded` si se ejecutó alguna acción, `failed` si alguna falló, `rejected` si se rechazaron todas o `expired` si caducaron.

## Caducidad

Una aprobación caduca tras `approval.expire_minutes`: 120 minutos (2 horas) por defecto, entre 10 y 1440.

## Políticas

| `approval.policy` | Significado |
|---|---|
| `required` | Cada acción espera a una persona. Es la única política que puedes fijar. |
| `relaxed` | Las propuestas hacia `relaxed_domains` se aprueban solas. Solo la fija un operador, con motivo, y solo para destinos internos no públicos. |

## Webhook

Dale a la tarea un `webhook.url` (una URL `https://` pública) para recibir los eventos `approval.requested` y `run.finished`. El secreto (`owts_…`) se muestra una vez al fijar el webhook; `"rotate_webhook_secret": true` en un `PATCH` genera otro.

Cada entrega es un `POST` con un cuerpo JSON y estas cabeceras:

```http
X-OpenWAF-Signature: t=1761735600,v1=5d2f…
X-OpenWAF-Event: approval.requested
X-OpenWAF-Delivery: 0b9d5c3e-…
```

```json
{"id": "0b9d5c3e-…", "type": "approval.requested", "created_at": "2026-10-29T10:00:00Z", "org_id": "…",
 "task": {"id": "…", "name": "guestbook"},
 "run": {"id": "…", "number": 12, "status": "awaiting_approval", "error": "", "finished_at": null},
 "approval": {"id": "…", "status": "pending", "summary": "Sign the guestbook…", "target": "https://example.com/guestbook",
              "domain": "example.com", "payload_sha256": "9c1e…", "expires_at": "2026-10-29T12:00:00Z"}}
```

`approval` es `null` en `run.finished`. Una respuesta `2xx` cuenta como entregada; si no, la entrega se reintenta 3 veces, tras 1, 5 y 30 minutos. `X-OpenWAF-Delivery` no cambia entre reintentos, así que puedes descartar duplicados.

`t` es la hora Unix de la entrega y `v1` el HMAC-SHA256 en hexadecimal, con el secreto como clave, de `t`, un punto y el cuerpo tal cual. Recalcúlalo, compara en tiempo constante y rechaza entregas de más de 5 minutos:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(header, rawBody, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const want = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const got = Buffer.from(parts.v1 ?? "", "hex");
  return got.length === want.length && timingSafeEqual(got, want);
}
```
