From 03a75c32a5459e61201342f41e0751807cd55643 Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 19:47:45 +0200 Subject: [PATCH 1/4] docs(notifications): Notifications page under Automations (EN+ES), links from automations, secrets and nan-cli Channels (Slack, Teams, Discord, HTTPS) and where to get each webhook URL, events and defaults, digests and rate limits, auto-disable, verifying the HTTPS signature, payload schema, security, portal/CLI/API management and troubleshooting. Webhooks, Examples and Apps move one slot down. Co-Authored-By: Claude Opus 5.5 --- src/content/docs-es/apps.md | 2 +- src/content/docs-es/automations.mdx | 8 +- src/content/docs-es/examples.md | 2 +- src/content/docs-es/nan-cli.mdx | 2 + src/content/docs-es/notifications.mdx | 462 ++++++++++++++++++ src/content/docs-es/secrets.mdx | 2 + src/content/docs-es/webhooks.mdx | 2 +- src/content/docs/apps.md | 2 +- src/content/docs/automations.mdx | 8 +- src/content/docs/examples.md | 2 +- src/content/docs/nan-cli.mdx | 2 + src/content/docs/notifications.mdx | 462 ++++++++++++++++++ src/content/docs/secrets.mdx | 2 + src/content/docs/webhooks.mdx | 2 +- .../notifications/discord_run_failed.json | 32 ++ .../notifications/https_approval_pending.json | 21 + .../https_automation_disabled.json | 15 + .../fixtures/notifications/https_digest.json | 60 +++ .../notifications/https_run_failed.json | 21 + .../notifications/https_run_finished.json | 21 + .../notifications/https_run_timed_out.json | 21 + .../fixtures/notifications/https_test.json | 8 + .../notifications/https_trigger_failing.json | 18 + .../notifications/slack_run_failed.json | 37 ++ .../notifications/teams_run_failed.json | 60 +++ src/tests/layouts/automationsDocs.test.ts | 8 +- .../layouts/docsNavNesting.render.test.ts | 23 +- src/tests/layouts/docsNavPage.test.ts | 19 +- src/tests/layouts/notificationsDocs.test.ts | 335 +++++++++++++ src/tests/layouts/prGatekeeperDocs.test.ts | 5 +- src/tests/layouts/secretsDocs.test.ts | 5 +- 31 files changed, 1644 insertions(+), 25 deletions(-) create mode 100644 src/content/docs-es/notifications.mdx create mode 100644 src/content/docs/notifications.mdx create mode 100644 src/tests/fixtures/notifications/discord_run_failed.json create mode 100644 src/tests/fixtures/notifications/https_approval_pending.json create mode 100644 src/tests/fixtures/notifications/https_automation_disabled.json create mode 100644 src/tests/fixtures/notifications/https_digest.json create mode 100644 src/tests/fixtures/notifications/https_run_failed.json create mode 100644 src/tests/fixtures/notifications/https_run_finished.json create mode 100644 src/tests/fixtures/notifications/https_run_timed_out.json create mode 100644 src/tests/fixtures/notifications/https_test.json create mode 100644 src/tests/fixtures/notifications/https_trigger_failing.json create mode 100644 src/tests/fixtures/notifications/slack_run_failed.json create mode 100644 src/tests/fixtures/notifications/teams_run_failed.json create mode 100644 src/tests/layouts/notificationsDocs.test.ts diff --git a/src/content/docs-es/apps.md b/src/content/docs-es/apps.md index 0591bb3e..5cd89072 100644 --- a/src/content/docs-es/apps.md +++ b/src/content/docs-es/apps.md @@ -1,7 +1,7 @@ --- title: Apps description: Despliega tus apps desde GitHub a NaN Cloud en minutos. -order: 25 +order: 26 group: Guías --- diff --git a/src/content/docs-es/automations.mdx b/src/content/docs-es/automations.mdx index 404d9fad..553a02eb 100644 --- a/src/content/docs-es/automations.mdx +++ b/src/content/docs-es/automations.mdx @@ -21,6 +21,8 @@ Qué puede lanzar una automatización: El agente de una automatización no hace push ni publica nada por su cuenta. **Propone acciones** (hacer push de una rama, abrir una pull request, comentar, revisar, añadir etiquetas) y NaN las aplica con un token que guardas aparte del agente. Tú decides qué tipos de acción esperan tu aprobación y cuáles se aplican solas: todas, ninguna o cualquier combinación. +Para enterarte en Slack, Microsoft Teams, Discord o tu propio endpoint cuando un run espera tu aprobación o falla, añade un [canal de notificaciones](/es/docs/notifications). + ## Antes de empezar - **Un workspace encendido**, con el agente que quieras instalado (Pi, Pi + Gentle Shell o Hermes), como para cualquier [run](/es/docs/runs#elige-el-agente). @@ -475,8 +477,10 @@ Un token de plataforma lleva **scopes**: lo que puede hacer. Los eliges al crear | `automations:write` | Crear, cambiar, desactivar y borrar automatizaciones y sus triggers. **Puede darle cualquiera de tus secretos a un agente.** | | `secrets:read` | Leer los metadatos de tus secretos: nombres, pistas, descripciones y qué automatizaciones los usan. Nunca los valores. | | `secrets:write` | Crear secretos, reemplazar sus valores, cambiar sus descripciones y borrarlos. **Puede reemplazar cualquiera de tus secretos.** | +| `notifications:read` | Leer tus [canales de notificaciones](/es/docs/notifications) y sus envíos. Nunca sus URLs. | +| `notifications:write` | Crear, cambiar, probar y borrar canales de notificaciones y rotar sus secretos de firma. **Puede enviar tus notificaciones a cualquier URL.** | -Los tokens nuevos llevan `runs` y `workspaces:read` por defecto. Un token con un scope `automations:` o `secrets:` solo se puede crear en el portal, con sesión iniciada en el navegador, no desde la CLI. Los tres últimos scopes aparecen ahí solo si tu cuenta tiene la API de escritura, con un aviso. Los scopes son independientes: `secrets:write` no incluye `secrets:read`, ni `automations:write` incluye `automations:read`, así que un token que gestiona secretos desde la CLI suele llevar los dos, y uno que cambia automatizaciones por su nombre también necesita `automations:read` para buscar el nombre. Un token sin el scope que necesita un endpoint recibe `403 token_scope_denied`. +Los tokens nuevos llevan `runs` y `workspaces:read` por defecto. Un token con un scope `automations:`, `secrets:` o `notifications:` solo se puede crear en el portal, con sesión iniciada en el navegador, no desde la CLI. `automations:write` y los scopes `secrets:` aparecen ahí solo si tu cuenta tiene la API de escritura, y los scopes `notifications:` solo si además tiene notificaciones, con un aviso. Los scopes son independientes: `secrets:write` no incluye `secrets:read`, ni `automations:write` incluye `automations:read`, así que un token que gestiona secretos desde la CLI suele llevar los dos, y uno que cambia automatizaciones por su nombre también necesita `automations:read` para buscar el nombre. Un token sin el scope que necesita un endpoint recibe `403 token_scope_denied`. Un agente o un script con un token con `secrets:write` o `automations:write` puede cambiar tus secretos o tus automatizaciones. Con `automations:write` puede crear una automatización que reciba cualquiera de tus secretos y se ejecute sin esperar tu aprobación, lo que equivale a leerlos todos. Da estos scopes solo a los tokens que los necesiten, mantenlos fuera de los workspaces, ponles caducidad y revócalos cuando acabes. @@ -512,7 +516,7 @@ Al borrarla con **Delete**, sus triggers se detienen al momento y se cancelan su Una automatización decide cuáles de tus secretos recibe un agente y qué puede hacer en tu repositorio sin preguntar. Estas acciones siempre te necesitan a ti, con sesión iniciada en el navegador: - **Aprobar una propuesta.** Aprobar es tu revisión de un cambio concreto que hizo un agente. Si una credencial que puede tener un script o un agente pudiera aprobar, un agente podría aprobar su propia propuesta. -- **Crear un token con un scope `automations:` o `secrets:`.** Así una sesión de la CLI robada no puede convertirse en un token de larga duración, y cada uno de esos tokens lo creaste tú, después del aviso. +- **Crear un token con un scope `automations:`, `secrets:` o `notifications:`.** Así una sesión de la CLI robada no puede convertirse en un token de larga duración, y cada uno de esos tokens lo creaste tú, después del aviso. Crear, cambiar o borrar automatizaciones, triggers y secretos también necesita el navegador mientras tu cuenta no tenga la API de escritura. Con ella, tu sesión de la CLI y los tokens con los scopes de escritura también pueden hacerlo: quién tiene esas credenciales lo decides tú, así que dalas solo donde confíes en quien las tiene (consulta [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api)). diff --git a/src/content/docs-es/examples.md b/src/content/docs-es/examples.md index ff92d520..6ecb46e5 100644 --- a/src/content/docs-es/examples.md +++ b/src/content/docs-es/examples.md @@ -1,7 +1,7 @@ --- title: Ejemplos description: Fragmentos de código para conectarte a la API de NaN con Python, Node.js, curl y más. -order: 24 +order: 25 group: Guías --- diff --git a/src/content/docs-es/nan-cli.mdx b/src/content/docs-es/nan-cli.mdx index c8be9e05..d37851f4 100644 --- a/src/content/docs-es/nan-cli.mdx +++ b/src/content/docs-es/nan-cli.mdx @@ -198,6 +198,8 @@ nan automations triggers add "PR review" -f webhook.yaml # imprime la URL y el El valor de un secreto se lee de un archivo o de una tubería, nunca de la línea de comandos, y nada lo vuelve a imprimir. Todas las opciones de `nan secrets`, cómo rotar un secreto y los errores que puedes ver están en [Secretos](/es/docs/secrets#gestiona-los-secretos-desde-la-cli). Mientras tu cuenta no tenga la API de escritura, estos comandos te lo dicen y te mandan al portal. Aprobar propuestas siempre se hace en el portal, con sesión iniciada en tu navegador. +Desde la v0.1.34, donde tu cuenta tiene notificaciones, `nan notifications` gestiona los canales que te avisan en Slack, Microsoft Teams, Discord o tu propio endpoint cuando una automatización necesita tu aprobación o falla: `nan notifications add "team alerts" --kind slack --url-file ./slack-url.txt`, y luego `nan notifications test "team alerts"`. La URL del webhook se lee de un archivo o de una tubería, nunca de la línea de comandos. Todos los comandos están en [Notificaciones](/es/docs/notifications#gestiona-los-canales-desde-la-cli). + Estos comandos usan primero tu sesión de `nan auth login` (gana a `NAN_TOKEN`; `--token-file` gana a las dos), o un token de plataforma con los scopes que necesitan: `secrets:read` y `secrets:write` para secretos, `automations:write` (y `automations:read` para usar nombres) para automatizaciones; consulta [Tokens para automatizaciones](/es/docs/automations#tokens-para-automatizaciones). Inicia sesión en tu propia máquina, nunca dentro de un workspace: los agentes de ahí podrían leer la sesión y cambiar tus secretos. Todos los comandos, los archivos YAML y los avisos están en [Gestiónalas desde la CLI o la API](/es/docs/automations#gestiónalas-desde-la-cli-o-la-api). Un run que espera aprobación hace que `nan run`, `nan runs logs -f` y `nan automations run` salgan con código 10. Los detalles están en [Automatizaciones](/es/docs/automations#desde-la-terminal). diff --git a/src/content/docs-es/notifications.mdx b/src/content/docs-es/notifications.mdx new file mode 100644 index 00000000..fd50fe14 --- /dev/null +++ b/src/content/docs-es/notifications.mdx @@ -0,0 +1,462 @@ +--- +title: Notificaciones +description: Entérate en Slack, Microsoft Teams, Discord o tu propio endpoint HTTPS cuando una automatización necesita tu aprobación, falla, se desactiva o su webhook no deja de fallar. +order: 23 +group: Guías +parent: automations +--- + +import Callout from '../../components/docs/Callout.astro'; + +# Notificaciones. + +Un **canal de notificaciones** es un sitio donde NaN te cuenta qué hacen tus [automatizaciones](/es/docs/automations), para que no tengas que tener el portal abierto: un canal de Slack, un canal de Microsoft Teams, un canal de Discord o un endpoint HTTPS tuyo. Añades el canal una vez, eliges los eventos que recibe y NaN publica un mensaje corto con un enlace al portal cada vez que ocurre uno de ellos. + +Solo notifican los runs de automatizaciones. Un run que lanzas tú con `nan run` o desde la pestaña **Runs** de un workspace nunca envía una notificación. + +Para añadir uno ya, ve a [Añade un canal](#añade-un-canal). + +## Canales + +Cada canal tiene: + +| Parte | Qué es | +|---|---| +| **Tipo** | `slack`, `teams`, `discord` o `https`. Se fija al crearlo. | +| **Nombre** | Cómo lo reconoces, por ejemplo `team alerts`. De 1 a 64 caracteres, único en tu cuenta (sin distinguir mayúsculas). | +| **URL** | Dónde publica NaN. De solo escritura: nunca se vuelve a mostrar, solo una pista. Mira [Las URLs son credenciales](#las-urls-son-credenciales). | +| **Eventos** | Lo que recibe, para todas tus automatizaciones o para una. Mira [Eventos](#eventos). | + +Puedes tener hasta 10 canales. Cada uno envía el mensaje en el formato que espera su proveedor: + +| Tipo | Lo que recibes | +|---|---| +| **Slack** | Un mensaje con el título como enlace al portal, una línea con el evento, el estado del run y el código de error, y el resumen del run cuando lo tiene. | +| **Microsoft Teams** | Una adaptive card con el título, el evento, el estado y el código de error, el resumen y un botón **Open in NaN**. | +| **Discord** | Un embed con el título como enlace, el evento, el estado y el código de error, y el resumen. Nunca menciona a nadie. | +| **HTTPS** | Un `POST` JSON firmado a tu endpoint, con el [esquema documentado](#contenido-del-mensaje). | + +### Consigue la URL de un webhook de Slack + +1. Ve a [api.slack.com/apps](https://api.slack.com/apps), pulsa **Create New App**, **From scratch**, ponle nombre (por ejemplo `NaN`) y elige tu workspace de Slack. +2. En **Incoming Webhooks**, activa **Activate Incoming Webhooks**. +3. Pulsa **Add New Webhook to Workspace**, elige el canal y pulsa **Allow**. +4. Copia la **Webhook URL**. Tiene esta forma: `https://hooks.slack.com/services/T…/B…/…`. + +### Consigue la URL de un webhook de Microsoft Teams + +Teams publica webhooks con su app **Workflows**: + +1. En el canal de Teams, abre **…** (más opciones) y elige **Workflows**. +2. Elige la plantilla **Post to a channel when a webhook request is received** (en algunos tenants, **Send webhook alerts to a channel**), ponle nombre y confirma el equipo y el canal. +3. Copia la URL que muestra Teams al final. Está en un host que termina en `logic.azure.com`, `powerplatform.com` o `webhook.office.com`. + +NaN envía una adaptive card, que es lo que publica esa plantilla. Si tu organización aún tiene un conector **Incoming Webhook** antiguo, su URL (en `webhook.office.com`) también funciona. + +### Consigue la URL de un webhook de Discord + +1. En Discord, abre **Server Settings**, luego **Integrations** y luego **Webhooks**. Necesitas el permiso **Manage Webhooks**. +2. Pulsa **New Webhook**, ponle nombre, elige el canal y pulsa **Copy Webhook URL**. Tiene esta forma: `https://discord.com/api/webhooks/…/…`. + +### Tu propio endpoint HTTPS + +Cualquier URL `https` en el puerto 443 con un nombre DNS público. NaN firma cada petición con un **secreto de firma** que crea para el canal y te muestra **una sola vez**, al crear el canal: mira [Verifica la firma](#verifica-la-firma). Los canales de Slack, Teams y Discord no tienen secreto de firma. + +## Eventos + +| Evento | Cuándo se envía | Activo por defecto | +|---|---|---| +| `approval.pending` | Un run de una automatización espera tu aprobación. | Sí | +| `run.failed` | Un run de una automatización falló o agotó su tiempo (`timed_out`), o falló aplicar las acciones que aprobaste. | Sí | +| `automation.disabled` | Se desactivó una automatización: tú, porque se borró su workspace o porque se borró un secreto que usa. | Sí | +| `trigger.failing` | El webhook de una automatización no deja de recibir peticiones con una firma incorrecta: unas 10 seguidas en una hora. Como mucho una vez al día por trigger. | Sí | +| `run.finished` | Un run de una automatización terminó bien. | No: actívalo tú | + +Un run cancelado nunca notifica, ni tampoco un run que lanzas tú (fuera de una automatización). `run.finished` viene desactivado porque una automatización con mucho uso publicaría en cada run: actívalo donde lo quieras. + +**Para todas tus automatizaciones o para una.** Cada evento de un canal es para **todas tus automatizaciones** (también las que crees más adelante) o para **una automatización**. Al crear un canal puedes limitar sus eventos por defecto a una automatización, y luego mezclar ambos: por ejemplo, `run.failed` para todas y `run.finished` solo para `nightly report`. Un canal admite hasta 50 de estas suscripciones. Al borrar una automatización se borran las suscripciones que la nombraban. + +El mismo evento se envía una sola vez por canal, aunque el canal esté suscrito a él para todas las automatizaciones y para esa en concreto. + +## Gestiona los canales en el portal + +Tus canales están en los ajustes de [cloud.nan.builders](https://cloud.nan.builders/): abre **Settings** y luego **Notifications**, debajo de **Secrets**. La página de cada automatización también te dice qué canales la cubren. + +### Añade un canal + +1. Pulsa **Add channel** y elige el tipo: Slack, Microsoft Teams, Discord o HTTPS. +2. Escribe un **Nombre** y pega la **URL** ([dónde conseguirla](#canales)). +3. Revisa los **Eventos**. Los cuatro por defecto vienen activados; activa `run.finished` si lo quieres. Si quieres, limítalos a una automatización. +4. Guarda. En un canal **HTTPS** el portal te muestra el **secreto de firma** una sola vez: cópialo ya al almacén de secretos de tu endpoint, no se vuelve a mostrar. + +Después pulsa **Test**: NaN envía un mensaje de prueba al momento y te dice si llegó. + +### Prueba, edita, rota, borra + +- **Test** envía un mensaje de prueba al canal. Solo funciona en un canal activo, y puedes enviar uno por minuto, más o menos (5 seguidos). +- **Edit** cambia el nombre, los eventos y sus automatizaciones, o reemplaza la URL. El tipo no se puede cambiar: añade un canal nuevo. +- **Rotate signing secret** (solo HTTPS) crea un secreto de firma nuevo y lo muestra una sola vez. El antiguo sigue firmando también durante 24 horas, para que puedas actualizar tu endpoint sin perder ninguna petición. +- **Disable** y **Enable** paran y reanudan un canal sin borrarlo. +- **Delete** te pide confirmación, y el canal y sus suscripciones desaparecen para siempre. +- **Deliveries** muestra los envíos de los últimos 30 días: evento, estado, estado HTTP y código de error. Nunca el mensaje. + +## Resúmenes y límites + +NaN mantiene cada canal legible y nunca inunda a un proveedor: + +- **Resúmenes (digests).** Cuando hay más de 3 notificaciones pendientes para un canal a la vez, o más de las que aún puede recibir en el minuto actual, llegan como **un solo mensaje de resumen**: cuántas hay de cada evento, y hasta 10 de ellas con sus enlaces. +- **Por canal:** como mucho 30 mensajes por minuto y 1.000 al día. +- **Por cuenta:** como mucho 3.000 mensajes al día entre todos tus canales. + +Por encima del límite por minuto, los mensajes esperan y se envían el minuto siguiente (como resumen si son varios). Por encima de un límite diario, las demás notificaciones de ese día se descartan y el registro de envíos muestra `rate_limited`. Los mensajes de prueba cuentan como mensajes. + +Una notificación que no se entrega en 24 horas se descarta: una alerta tardía es peor que ninguna. + +## Cuando un canal falla + +Cada mensaje se reintenta cuando el fallo puede ser pasajero: un timeout, un error de conexión o de TLS, un fallo de DNS, `408`, `429` o un `5xx`. Se intenta hasta 5 veces, tras 1 minuto, 5 minutos, 30 minutos y 2 horas. Cualquier otro `4xx`, y cualquier redirección, falla al momento: la URL está mal o se revocó. + +**Desactivado tras 20 fallos.** Tras **20 intentos fallidos seguidos**, el canal se desactiva con el motivo **failing**, y sus mensajes pendientes se descartan. El portal y `nan notifications ls` lo muestran. Para arreglarlo: + +1. Mira el error en el registro de **Deliveries** (`http_4xx`, `timeout`, `dns`, etc.). +2. Si el webhook se borró o se revocó en el proveedor, crea uno nuevo y **reemplaza la URL**: eso vuelve a activar el canal. +3. Si el problema estaba en tu lado y ya está resuelto, pulsa **Enable** en el canal. Luego pulsa **Test**. + +Volver a activarlo o reemplazar la URL pone a cero el contador de fallos. + +**Desactivado como `invalid_url`.** Si el host de la URL empieza a resolver a una dirección que NaN rechaza (una privada o reservada, mira [Qué URLs se aceptan](#qué-urls-se-aceptan)), el canal se desactiva al momento con el motivo **invalid_url**. Reemplaza la URL para volver a activarlo. + +Un canal que desactivaste tú (motivo **member**) sigue apagado hasta que lo actives, aunque reemplaces su URL. + +## Verifica la firma + +Cada petición a un canal **HTTPS** lleva estas cabeceras, con el mismo esquema de firma que los [webhooks que tú envías a NaN](/es/docs/webhooks#envía-tus-propios-eventos): + +| Cabecera | Valor | +|---|---| +| `X-Nan-Event` | El evento: `approval.pending`, `run.failed`, `run.finished`, `automation.disabled`, `trigger.failing`, `digest` o `test`. | +| `X-Nan-Delivery` | Un id único por notificación, el mismo en cada reintento. Úsalo para ignorar repeticiones. | +| `X-Nan-Timestamp` | Cuándo se firmó, en segundos Unix. | +| `X-Nan-Signature` | `v1=` seguido del HMAC-SHA256 en hexadecimal de `.` con el secreto de firma (la clave es la cadena entera `nan_nsec_…`). Durante las 24 horas tras una rotación hay dos, separadas por una coma, la nueva primero. | + +Acepta una petición cuando **cualquier** valor `v1=` coincide y el timestamp no se desvía más de 5 minutos. Calcula el HMAC sobre el cuerpo exactamente como llega, byte a byte, antes de parsearlo. Responde rápido con cualquier `2xx`; el cuerpo de la respuesta se ignora. + +En bash, para comprobar una petición que guardaste (cuerpo en `body.json`): + +```bash +SECRET="nan_nsec_..." # el secreto de firma del canal +TS="1760436000" # la cabecera X-Nan-Timestamp +SIGS="v1=...,v1=..." # la cabecera X-Nan-Signature +EXPECTED=$(printf '%s.' "$TS" | cat - body.json | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //') + +NOW=$(date +%s) +if [ $((NOW - TS)) -gt 300 ] || [ $((TS - NOW)) -gt 300 ]; then echo "too old"; exit 1; fi +case ",$SIGS," in *",v1=$EXPECTED,"*) echo "valid" ;; *) echo "invalid"; exit 1 ;; esac +``` + +En Python: + +```python +import hashlib, hmac, time + +SECRET = b"nan_nsec_..." + +def verify(body: bytes, timestamp: str, signature_header: str) -> bool: + if abs(time.time() - int(timestamp)) > 300: + return False + expected = hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest() + return any( + part.startswith("v1=") and hmac.compare_digest(part[3:], expected) + for part in signature_header.split(",") + ) + +# En tu handler, con el cuerpo en bruto: +# verify(request_body, headers["X-Nan-Timestamp"], headers["X-Nan-Signature"]) +``` + +En Node.js: + +```js +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { createServer } from 'node:http'; + +const secret = process.env.NAN_SIGNING_SECRET; // nan_nsec_... + +function verify(body, timestamp, header) { + if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; + const expected = Buffer.from(createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex')); + return (header ?? '').split(',').some((part) => { + const got = Buffer.from(part.trim().replace(/^v1=/, '')); + return part.trim().startsWith('v1=') && got.length === expected.length && timingSafeEqual(got, expected); + }); +} + +createServer((req, res) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const body = Buffer.concat(chunks); + if (!verify(body, req.headers['x-nan-timestamp'], req.headers['x-nan-signature'])) { + res.writeHead(401).end(); + return; + } + const event = JSON.parse(body); + console.log(event.event, event.text, event.url); + res.writeHead(204).end(); + }); +}).listen(8080); +``` + +La entrega es al menos una vez: la misma notificación puede llegar dos veces (por ejemplo, tras un error de red cuando ya se había recibido). Ignora un `X-Nan-Delivery` que ya hayas procesado. + +## Contenido del mensaje + +Un canal **HTTPS** recibe peticiones `POST` con `Content-Type: application/json` y `User-Agent: NaN-Notifications/1`. El cuerpo lleva `version: 1`, y los campos que no aplican a un evento son `null`: + +```json +{ + "version": 1, + "id": "…", + "event": "run.failed", + "created_at": "2026-10-14T10:00:00Z", + "automation": { "id": "…", "name": "PR review" }, + "run": { + "id": "…", + "state": "failed", + "error_code": "agent_failed", + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "trigger": null, + "reason": null, + "url": "https://cloud.nan.builders/workspaces/…/runs/…", + "text": "Run failed: PR review (agent_failed)" +} +``` + +| Campo | Qué contiene | +|---|---| +| `id` | El id de la notificación, el mismo que `X-Nan-Delivery`. | +| `event` | Uno de los [eventos](#eventos). | +| `automation` | `{ "id", "name" }` de la automatización. | +| `run` | En los eventos de run: `id`, `state` (`awaiting_approval`, `succeeded`, `failed` o `timed_out`), `error_code`, `phase` y `summary`. Si no, `null`. | +| `run.summary` | El resumen del run tal como lo muestra el portal, con los secretos ya enmascarados y sin caracteres de control, como mucho 280 caracteres. Solo en `run.finished` y `run.failed`. | +| `trigger` | `{ "id", "kind" }` en `trigger.failing`. | +| `reason` | `automation.disabled`: `member`, `workspace_deleted` o `secret_deleted`. `trigger.failing`: `bad_signature`. | +| `url` | Dónde mirar en el portal: el run en los eventos de run, la automatización en los demás. | +| `text` | Un título de una línea, en inglés, por ejemplo `Approval needed: PR review`, `Run timed out: PR review` o `Automation disabled: PR review (its workspace was deleted)`. | + +Un **resumen** (`digest`) agrupa varias notificaciones: + +```json +{ + "version": 1, + "id": "…", + "event": "digest", + "created_at": "2026-10-14T10:00:00Z", + "count": 3, + "events": { "approval.pending": 1, "run.failed": 2 }, + "items": [ + { + "event": "run.failed", + "automation": { "id": "…", "name": "PR review" }, + "run": { "id": "…", "state": "timed_out", "error_code": "timed_out", "phase": "propose", "summary": "…" }, + "url": "https://cloud.nan.builders/workspaces/…/runs/…" + } + ], + "url": "https://cloud.nan.builders/automations", + "text": "3 notifications: 1 approval needed, 2 runs failed" +} +``` + +`count` es cuántas notificaciones agrupa, `events` cuántas hay de cada evento, e `items` hasta 10 de ellas. + +Un mensaje de **prueba**: + +```json +{ + "version": 1, + "id": "…", + "event": "test", + "created_at": "2026-10-14T10:00:00Z", + "url": "https://cloud.nan.builders/settings#notifications", + "text": "Test notification from NaN" +} +``` + +La versión 1 puede ganar campos nuevos; ignora los que no conozcas. + +## Seguridad + +### Las URLs son credenciales + +Cualquiera que tenga la URL de un webhook de Slack, Teams o Discord puede publicar en ese canal, así que NaN trata cada URL de canal como un [secreto](/es/docs/secrets): + +- Es de **solo escritura**: se cifra con una clave que solo pertenece a tu cuenta, y el portal, la CLI y la API nunca la devuelven. Ves una pista en su lugar, el host y los 4 últimos caracteres de la ruta: `hooks.slack.com/…/abcd`. +- Nunca aparece en un error, un log ni la lista de envíos. +- Desde la CLI se lee de un archivo o de una tubería, nunca de la línea de comandos. + +Si se filtra la URL de un webhook, borra el webhook en el proveedor, crea uno nuevo y reemplaza la URL en NaN. + +### Lo que un mensaje nunca lleva + +Los mensajes llevan nombres, estados, códigos de error, ids y enlaces. **Nunca** llevan: + +- el valor de ningún secreto, ni la URL de un canal; +- el cuerpo ni las cabeceras de la petición de webhook que lanzó un run; +- la salida del agente, más allá del resumen corto del run (como mucho 280 caracteres, con los secretos enmascarados). + +El texto que escribes tú (el nombre de una automatización, un resumen) se escapa para cada proveedor: no puede mencionar a nadie, avisar a un rol ni formar un enlace. + +### Qué URLs se aceptan + +Los servidores de NaN llaman a estas URLs, así que siguen reglas estrictas, que se comprueban al guardar la URL y otra vez en cada envío: + +- Solo `https`, en el puerto 443. Sin usuario ni contraseña en la URL, sin fragmento, sin dirección IP: un nombre DNS con al menos dos etiquetas. +- Ni los hosts de NaN (`nan.builders`, `helmcode.com` y sus subdominios) ni nombres locales (`localhost`, `.local`, `.internal`, `.lan`, `.home.arpa`, `.svc`). +- Todas las direcciones a las que resuelve el nombre deben ser públicas: se rechazan las privadas, de loopback, link-local, compartidas y reservadas, y también las de NaN. +- Las URLs de Slack deben estar en `hooks.slack.com` con una ruta que empiece por `/services/`. Las de Teams, en un subdominio de `webhook.office.com`, `logic.azure.com` o `powerplatform.com`. Las de Discord, en `discord.com` (o `discordapp.com`, `canary.discord.com`, `ptb.discord.com`) con una ruta que empiece por `/api/webhooks/`. +- No se siguen redirecciones: un `3xx` es un fallo. + +Una URL rechazada recibe un único error genérico que dice qué regla incumple, nunca lo que enviaste, y un nombre que no resuelve recibe la misma respuesta que uno que resuelve a una dirección rechazada. + +## Gestiona los canales desde la CLI + +Con la [CLI de NaN](/es/docs/nan-cli) **0.1.34** o posterior, donde tu cuenta tiene notificaciones y la API de escritura: + +```bash +nan notifications ls # tipo, nombre, pista de la URL, estado +nan notifications add "team alerts" --kind slack --url-file ./slack-url.txt +pbpaste | nan notifications add "ops endpoint" --kind https --url-stdin --event run.failed --event run.finished +nan notifications add "nightly" --kind discord --url-file ./discord-url.txt --automation "nightly report" +nan notifications show "team alerts" +nan notifications test "team alerts" +nan notifications subscriptions "team alerts" # las lista +nan notifications subscriptions "team alerts" --add "run.finished=nightly report" +nan notifications subscriptions "team alerts" --set run.failed --set approval.pending +nan notifications edit "team alerts" --url-file ./new-url.txt +nan notifications edit "team alerts" --enable +nan notifications rotate "ops endpoint" # muestra el nuevo secreto de firma una vez +nan notifications deliveries "team alerts" --limit 50 +nan notifications rm "team alerts" +``` + +- **La URL nunca va en la línea de comandos**, donde se quedaría en el historial de tu shell. `--url-file PATH` la lee de un archivo, `--url-stdin` de una tubería. No hay flag `--url`. +- `add` usa los eventos por defecto salvo que pases `--event` (repetible, o separados por comas) o `--no-events`; `--automation` (un nombre o un id) los limita a una automatización. +- En un canal HTTPS, `add` y `rotate` muestran el secreto de firma **una sola vez**, con un aviso para que lo guardes ya. Con `--json` va dentro del JSON, como `signing_secret`. +- `subscriptions` sin flags las lista. `EVENT` solo significa todas tus automatizaciones, `EVENT=AUTOMATION` una de ellas. `--set` reemplaza el conjunto entero, `--add` y `--remove` lo cambian, `--clear` las quita todas. +- `edit` cambia el nombre (`--name`), enciende o apaga el canal (`--enable`, `--disable`) o reemplaza la URL. Reemplazar la URL o `--enable` vuelve a activar un canal que apagó NaN y pone a cero su contador de fallos. +- `test` sale con código 0 cuando el mensaje se entregó y con 1 cuando no, con el motivo. +- `deliveries` muestra los últimos 20 envíos por defecto (`--limit` de 1 a 100). +- `rm` pregunta antes de borrar; `--yes` se salta la pregunta en scripts. +- Los comandos aceptan el nombre o el id de un canal, y `--json` imprime JSON. +- Donde tu cuenta no tiene notificaciones o la API de escritura, los comandos lo dicen, te mandan al portal y salen con código 64. Una credencial o un scope rechazados salen con 65, y un límite de peticiones o un error del servidor con 69. + +Con un token de plataforma, los comandos necesitan `notifications:read` para listar o ver canales (y para encontrar uno por nombre) y `notifications:write` para cambiarlos; `--automation` con un nombre también necesita `automations:read`. + +La lista completa de flags está en `nan notifications --help`. + +## Gestiona los canales desde la API + +Las mismas operaciones están en la API, bajo `https://api.nan.builders/v1/notification-channels`: + +| Método y ruta | Scope | Qué hace | +|---|---|---| +| `GET /v1/notification-channels` | `notifications:read` | Lista tus canales. | +| `GET /v1/notification-channels/{id}` | `notifications:read` | Un canal. | +| `GET /v1/notification-channels/{id}/deliveries?limit=` | `notifications:read` | Sus últimos envíos, los más nuevos primero (30 días, `limit` de 1 a 100, 50 por defecto). | +| `POST /v1/notification-channels` | `notifications:write` | Crea un canal: `{"name", "kind", "url", "events"?, "automation_id"?}`. `201`, con `signing_secret` una sola vez en `https`. | +| `PATCH /v1/notification-channels/{id}` | `notifications:write` | Cambia `name`, `enabled` o `url`. | +| `DELETE /v1/notification-channels/{id}` | `notifications:write` | Lo borra. `204`. | +| `PUT /v1/notification-channels/{id}/subscriptions` | `notifications:write` | Reemplaza sus suscripciones: `{"subscriptions": [{"event", "automation_id"}]}`, `automation_id` `null` para todas las automatizaciones. Hasta 50; `[]` las quita todas. | +| `POST /v1/notification-channels/{id}/test` | `notifications:write` | Envía un mensaje de prueba y responde si se entregó. | +| `POST /v1/notification-channels/{id}/rotate-signing-secret` | `notifications:write` | Nuevo secreto de firma (se muestra una vez); el antiguo sigue firmando 24 horas. | + +**Qué credenciales sirven:** tu sesión de la CLI de `nan auth login`, o un token de plataforma con el scope de la ruta. Los dos scopes son independientes: `notifications:write` no incluye `notifications:read`. Un token con un scope `notifications:` solo se puede crear en el portal, **Settings → Tokens**, con sesión iniciada desde tu navegador, y esos scopes solo aparecen allí donde tu cuenta tiene notificaciones y la API de escritura. Una API key (`sk-...`) y la clave de dentro de un workspace se rechazan. Donde tu cuenta no tiene notificaciones o la API de escritura, todas las rutas responden `404`. + +Crea un canal desde un archivo, para que la URL nunca aparezca en el historial de tu shell: + +```bash +jq -n --rawfile u slack-url.txt '{name: "team alerts", kind: "slack", url: ($u | rtrimstr("\n"))}' | + curl https://api.nan.builders/v1/notification-channels \ + -H "Authorization: Bearer $NAN_TOKEN" \ + -H "Content-Type: application/json" \ + --data-binary @- +``` + +La respuesta es el canal, nunca su URL: + +```json +{ + "id": "…", + "object": "notification_channel", + "name": "team alerts", + "kind": "slack", + "url_hint": "hooks.slack.com/…/abcd", + "enabled": true, + "disabled_reason": null, + "consecutive_failures": 0, + "last_delivery_at": null, + "last_delivery_status": null, + "subscriptions": [ + { "event": "approval.pending", "automation_id": null }, + { "event": "run.failed", "automation_id": null }, + { "event": "automation.disabled", "automation_id": null }, + { "event": "trigger.failing", "automation_id": null } + ], + "signing": null, + "updated_by": "platform_token", + "created_at": "…", + "updated_at": "…" +} +``` + +- `disabled_reason` es `null`, `member` (lo desactivaste tú), `failing` o `invalid_url`. `signing` es `{"previous_expires_at"}` en los canales `https`: cuándo deja de firmar el secreto antiguo tras una rotación. +- `updated_by` dice de dónde vino el último cambio: `browser_session`, `cli_session` o `platform_token`. +- **Los errores** llegan como `{"error": {"message", "type", "param", "code"}}`. `param` nombra el campo que está mal y nunca repite lo que enviaste. +- **Límites de peticiones.** Los cambios en canales (crear, editar, borrar, suscripciones, rotar) comparten un cupo por cuenta: una ráfaga de 30 y luego 120 por hora, sumando el portal, la CLI y la API. Las pruebas tienen el suyo: 5 seguidas y luego 1 por minuto. Por encima, `429 rate_limited`. + +Todas las rutas, cuerpos y errores están en [Referencia de la API → Notifications](/es/docs/api#tag/notifications). + +## Límites + +| Límite | Valor | +|---|---| +| Canales | 10 por miembro | +| Suscripciones | 50 por canal | +| Nombre | De 1 a 64 caracteres | +| URL | Hasta 2048 bytes | +| Mensajes | 30 por minuto y 1.000 al día por canal; 3.000 al día por miembro | +| Resumen | Hasta 10 elementos listados | +| Reintentos | 5 intentos en unas 2,5 horas | +| Desactivación automática | Tras 20 intentos fallidos seguidos | +| Notificación pendiente | Se descarta a las 24 horas | +| Registro de envíos | 30 días | +| Cambios en canales | Una ráfaga de 30 y luego 120 por hora | +| Pruebas | 5 seguidas y luego 1 por minuto | +| Cuerpo de una petición a la API | 8 KiB | + +## Si algo sale mal + +| Lo que ves | Por qué, y qué hacer | +|---|---| +| `400 invalid_request` con un `param` | Ese campo incumple una regla: `name`, `kind`, `events` o `subscriptions` (un evento desconocido, o más de 50), o `limit` (de 1 a 100). | +| `400 invalid_url` | La URL incumple una de las [reglas](#qué-urls-se-aceptan): no es `https` en el 443, es una dirección IP, el host no corresponde a su tipo, o el host no resuelve o resuelve a una dirección rechazada. Vuelve a copiar la URL del proveedor. | +| `403 credential_not_allowed` | Una API key (`sk-...`) o una sesión de una CLI antigua. Usa tu sesión de `nan auth login` o un token de plataforma con el scope. | +| `403 token_scope_denied` | Al token le falta `notifications:read` o `notifications:write`. Crea uno con el scope en **Settings → Tokens**. | +| `403 browser_session_required` | Tu cuenta aún no tiene la API de escritura, así que los cambios necesitan el portal, con sesión iniciada desde tu navegador. | +| `404` en todas las rutas de `/v1/notification-channels` | Tu cuenta aún no tiene notificaciones o la API de escritura. Usa el portal. | +| `404 not_found` | No hay ningún canal con ese id en tu cuenta. | +| `404 automation_not_found` | El `automation_id` no es de una de tus automatizaciones. | +| `409 name_taken` | Ya tienes un canal con ese nombre. | +| `409 too_many_channels` | Tienes 10. Borra uno para añadir otro. | +| `409 channel_disabled` | Intentaste probar un canal desactivado. Actívalo antes. | +| `409 signing_not_supported` | Solo los canales HTTPS tienen secreto de firma. | +| `429 rate_limited` | Demasiados cambios o pruebas. Espera y vuelve a intentarlo. | +| `503 notifications_unavailable` | Las notificaciones no están disponibles un momento. Vuelve a intentarlo en unos minutos. | +| La prueba responde `failed` con `http_4xx` | El proveedor rechazó el mensaje: se borró el webhook o la URL está incompleta. Crea uno nuevo y reemplaza la URL. | +| La prueba responde `blocked` | El host de la URL resuelve a una dirección que NaN rechaza. Usa un endpoint público. | +| La prueba responde `rate_limited` | El canal ha gastado sus mensajes por ahora. Prueba más tarde. | +| El canal muestra **failing** | 20 envíos seguidos fallaron. Mira [Cuando un canal falla](#cuando-un-canal-falla). | +| No llega nada de un run | El run no lo lanzó una automatización, se canceló, o su evento no está suscrito para esa automatización (`run.finished` viene desactivado). Revisa los eventos del canal y sus **Deliveries**. | +| Tu endpoint rechaza todas las firmas | Calcula el HMAC sobre el cuerpo en bruto antes de parsearlo, usa como clave el secreto entero `nan_nsec_…` y revisa el reloj de tu servidor. Tras una rotación, guarda el secreto nuevo. | diff --git a/src/content/docs-es/secrets.mdx b/src/content/docs-es/secrets.mdx index 4008939c..8859a53e 100644 --- a/src/content/docs-es/secrets.mdx +++ b/src/content/docs-es/secrets.mdx @@ -43,6 +43,8 @@ Cada secreto tiene: | `generic` | **Secret** | Un token o un valor que tus automatizaciones reciben como variable de entorno: un token de GitHub, un webhook de Slack, una API key de un servicio tuyo. | | `provider_key` | **Provider key** | Tu propia clave con un proveedor de IA, que se usa como inferencia de [runs](/es/docs/runs#usa-tu-propia-clave-de-proveedor-byok) y [automatizaciones](/es/docs/automations#usa-tu-propia-clave-de-proveedor-byok) en lugar de la inferencia de NaN (trae tu propia clave, BYOK). | +Para enterarte en Slack, Microsoft Teams o Discord cuando una automatización te necesita no hace falta un secreto: añade un [canal de notificaciones](/es/docs/notifications). Su URL se guarda de solo escritura, como un secreto. + ### Secret El valor se guarda tal cual lo pegas, hasta 16 KiB: nunca se recorta, así que una clave pegada nunca cambia sin que te enteres. Tiene que ser texto (UTF-8), sin caracteres de control salvo tabuladores y saltos de línea. Los saltos de línea dentro del valor están bien (una clave privada PEM, por ejemplo), pero no al principio ni al final. diff --git a/src/content/docs-es/webhooks.mdx b/src/content/docs-es/webhooks.mdx index cca5ff96..56881599 100644 --- a/src/content/docs-es/webhooks.mdx +++ b/src/content/docs-es/webhooks.mdx @@ -1,7 +1,7 @@ --- title: Webhooks y programaciones description: Lanza tus automatizaciones desde GitHub, Stripe o tus propios sistemas con webhooks firmados, o con una programación. -order: 23 +order: 24 group: Guías --- diff --git a/src/content/docs/apps.md b/src/content/docs/apps.md index 67d713eb..882d9349 100644 --- a/src/content/docs/apps.md +++ b/src/content/docs/apps.md @@ -1,7 +1,7 @@ --- title: Apps description: Deploy your apps from GitHub to NaN Cloud in minutes. -order: 25 +order: 26 group: Guides --- diff --git a/src/content/docs/automations.mdx b/src/content/docs/automations.mdx index c9c93a40..6574af5e 100644 --- a/src/content/docs/automations.mdx +++ b/src/content/docs/automations.mdx @@ -21,6 +21,8 @@ What can start an automation: The agent of an automation does not push or post on its own. It **proposes actions** (push a branch, open a pull request, comment, review, add labels) and NaN applies them with a token you keep apart from the agent. You decide which kinds of action wait for your approval and which apply automatically: all of them, none of them or any mix. +To hear in Slack, Microsoft Teams, Discord or your own endpoint when a run waits for your approval or fails, add a [notification channel](/docs/notifications). + ## Before you start - **A workspace that is running**, with the agent you want installed (Pi, Pi + Gentle Shell or Hermes), as for any [run](/docs/runs#pick-the-agent). @@ -475,8 +477,10 @@ A platform token carries **scopes**: what it may do. You pick them when you crea | `automations:write` | Create, change, switch off and delete automations and their triggers. **It can give any of your secrets to an agent.** | | `secrets:read` | Read your secrets' metadata: names, hints, descriptions, which automations use them. Never values. | | `secrets:write` | Create secrets, replace their values, change their descriptions and delete them. **It can replace any of your secrets.** | +| `notifications:read` | Read your [notification channels](/docs/notifications) and their deliveries. Never their URLs. | +| `notifications:write` | Create, change, test and delete notification channels and rotate their signing secrets. **It can send your notifications to any URL.** | -New tokens get `runs` and `workspaces:read` by default. A token with an `automations:` or `secrets:` scope can only be created in the portal, signed in through your browser, not from the CLI. The last three scopes appear there only where your account has the write API, with a warning. Scopes are independent: `secrets:write` does not include `secrets:read`, and `automations:write` does not include `automations:read`, so a token that manages secrets from the CLI usually carries both, and one that changes automations by name also needs `automations:read` to look the name up. A token without the scope an endpoint needs gets `403 token_scope_denied`. +New tokens get `runs` and `workspaces:read` by default. A token with an `automations:`, `secrets:` or `notifications:` scope can only be created in the portal, signed in through your browser, not from the CLI. `automations:write` and the `secrets:` scopes appear there only where your account has the write API, and the `notifications:` scopes only where it also has notifications, with a warning. Scopes are independent: `secrets:write` does not include `secrets:read`, and `automations:write` does not include `automations:read`, so a token that manages secrets from the CLI usually carries both, and one that changes automations by name also needs `automations:read` to look the name up. A token without the scope an endpoint needs gets `403 token_scope_denied`. An agent or a script holding a token with `secrets:write` or `automations:write` can change your secrets or automations. With `automations:write` it can create an automation that receives any of your secrets and runs without waiting for your approval, which is as good as reading them all. Give these scopes only to tokens that need them, keep them out of workspaces, set an expiry and revoke them when you are done. @@ -512,7 +516,7 @@ Change any setting in the **Settings** tab and press **Save changes**, or use `n An automation decides which of your secrets an agent receives and what it may do in your repository without asking. These always need you, signed in through your browser: - **Approving a proposal.** Approving is your review of one concrete change an agent made. If a credential that a script or an agent can hold could approve, an agent could approve its own proposal. -- **Creating a token with an `automations:` or `secrets:` scope.** So a stolen CLI session cannot turn itself into a long-lived token, and every such token was created by you, after the warning. +- **Creating a token with an `automations:`, `secrets:` or `notifications:` scope.** So a stolen CLI session cannot turn itself into a long-lived token, and every such token was created by you, after the warning. Creating, changing or deleting automations, triggers and secrets also needs the browser until your account has the write API. With it, your CLI session and tokens with the write scopes can do it too: who holds those credentials is your choice, so give them only where you trust the holder (see [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api)). diff --git a/src/content/docs/examples.md b/src/content/docs/examples.md index 7f9878b0..504a4d3a 100644 --- a/src/content/docs/examples.md +++ b/src/content/docs/examples.md @@ -1,7 +1,7 @@ --- title: Examples description: Code snippets to connect to the NaN API with Python, Node.js, curl, and more. -order: 24 +order: 25 group: Guides --- diff --git a/src/content/docs/nan-cli.mdx b/src/content/docs/nan-cli.mdx index 6815cea5..a7893183 100644 --- a/src/content/docs/nan-cli.mdx +++ b/src/content/docs/nan-cli.mdx @@ -198,6 +198,8 @@ nan automations triggers add "PR review" -f webhook.yaml # prints the URL and s A secret's value is read from a file or a pipe, never from the command line, and nothing ever prints it back. Every `nan secrets` flag, how to rotate a secret and the errors you can see are in [Secrets](/docs/secrets#manage-secrets-from-the-cli). Until your account has the write API, these commands say so and point you to the portal. Approving proposals always happens in the portal, signed in through your browser. +From v0.1.34, where your account has notifications, `nan notifications` manages the channels that tell you in Slack, Microsoft Teams, Discord or your own endpoint when an automation needs your approval or fails: `nan notifications add "team alerts" --kind slack --url-file ./slack-url.txt`, then `nan notifications test "team alerts"`. The webhook URL is read from a file or a pipe, never from the command line. Every command is in [Notifications](/docs/notifications#manage-channels-from-the-cli). + These commands use your `nan auth login` session first (it wins over `NAN_TOKEN`; `--token-file` wins over both), or a platform token with the scopes they need: `secrets:read` and `secrets:write` for secrets, `automations:write` (plus `automations:read` to use names) for automations; see [Tokens for automations](/docs/automations#tokens-for-automations). Sign in on your own machine, never inside a workspace: the agents there could read the session and change your secrets. Every command, the YAML files and the warnings are in [Manage them from the CLI or the API](/docs/automations#manage-them-from-the-cli-or-the-api). A run waiting for approval makes `nan run`, `nan runs logs -f` and `nan automations run` exit with code 10. The details are in [Automations](/docs/automations#from-the-terminal). diff --git a/src/content/docs/notifications.mdx b/src/content/docs/notifications.mdx new file mode 100644 index 00000000..db2d04a0 --- /dev/null +++ b/src/content/docs/notifications.mdx @@ -0,0 +1,462 @@ +--- +title: Notifications +description: Get told in Slack, Microsoft Teams, Discord or your own HTTPS endpoint when an automation needs your approval, fails, is switched off or its webhook keeps failing. +order: 23 +group: Guides +parent: automations +--- + +import Callout from '../../components/docs/Callout.astro'; + +# Notifications. + +A **notification channel** is a place where NaN tells you what your [automations](/docs/automations) are doing, so you do not have to keep the portal open: a Slack channel, a Microsoft Teams channel, a Discord channel or an HTTPS endpoint of your own. You add the channel once, pick the events it receives, and NaN posts a short message with a link back to the portal every time one of them happens. + +Only automation runs notify. A run you start yourself with `nan run` or from a workspace's **Runs** tab never sends a notification. + +To add one right away, go to [Add a channel](#add-a-channel). + +## Channels + +Every channel has: + +| Part | What it is | +|---|---| +| **Kind** | `slack`, `teams`, `discord` or `https`. Fixed when you create it. | +| **Name** | How you recognise it, for example `team alerts`. 1 to 64 characters, unique in your account (capitals do not count). | +| **URL** | Where NaN posts. Write-only: it is never shown again, only a hint. See [URLs are credentials](#urls-are-credentials). | +| **Events** | What it receives, for all your automations or for one. See [Events](#events). | + +You can have up to 10 channels. Each one sends a message in the format its provider expects: + +| Kind | What you get | +|---|---| +| **Slack** | A message with the title as a link to the portal, a line with the event, the run state and the error code, and the run's summary when it has one. | +| **Microsoft Teams** | An adaptive card with the title, the event, state and error code, the summary and an **Open in NaN** button. | +| **Discord** | An embed with the title as a link, the event, state and error code, and the summary. It never mentions anyone. | +| **HTTPS** | A signed JSON `POST` to your endpoint, with the [documented schema](#payload). | + +### Get a Slack webhook URL + +1. Go to [api.slack.com/apps](https://api.slack.com/apps), press **Create New App**, **From scratch**, name it (for example `NaN`) and pick your workspace. +2. In **Incoming Webhooks**, switch **Activate Incoming Webhooks** on. +3. Press **Add New Webhook to Workspace**, pick the channel and press **Allow**. +4. Copy the **Webhook URL**. It looks like `https://hooks.slack.com/services/T…/B…/…`. + +### Get a Microsoft Teams webhook URL + +Teams posts webhooks through its **Workflows** app: + +1. In the Teams channel, open **…** (more options) and choose **Workflows**. +2. Pick the template **Post to a channel when a webhook request is received** (in some tenants, **Send webhook alerts to a channel**), name it and confirm the team and the channel. +3. Copy the URL Teams shows at the end. It is on a host ending in `logic.azure.com`, `powerplatform.com` or `webhook.office.com`. + +NaN sends an adaptive card, which is what that template posts. If your organisation still has an old **Incoming Webhook** connector, its URL (on `webhook.office.com`) works too. + +### Get a Discord webhook URL + +1. In Discord, open **Server Settings**, then **Integrations**, then **Webhooks**. You need the **Manage Webhooks** permission. +2. Press **New Webhook**, name it, pick the channel and press **Copy Webhook URL**. It looks like `https://discord.com/api/webhooks/…/…`. + +### Your own HTTPS endpoint + +Any `https` URL on port 443 with a public DNS name. NaN signs every request with a **signing secret** it creates for the channel and shows you **once**, when you create the channel: see [Verify the signature](#verify-the-signature). Slack, Teams and Discord channels have no signing secret. + +## Events + +| Event | When it is sent | On by default | +|---|---|---| +| `approval.pending` | A run of an automation is waiting for your approval. | Yes | +| `run.failed` | A run of an automation failed or timed out (`timed_out`), or applying the actions you approved failed. | Yes | +| `automation.disabled` | An automation was switched off: by you, because its workspace was deleted, or because a secret it uses was deleted. | Yes | +| `trigger.failing` | An automation's webhook keeps receiving requests with a bad signature: about 10 in a row within an hour. At most once a day per trigger. | Yes | +| `run.finished` | A run of an automation finished successfully. | No: opt in | + +A cancelled run never notifies, and neither does a run you start yourself (outside an automation). `run.finished` is off by default because a busy automation would post on every run: turn it on where you want it. + +**For all your automations, or for one.** Every event of a channel is either for **all your automations** (including the ones you create later) or for **one automation**. When you create a channel you can scope its default events to one automation, and later mix both: for example, `run.failed` for every automation and `run.finished` only for `nightly report`. A channel takes up to 50 of these subscriptions. Deleting an automation removes the subscriptions that named it. + +The same event is sent once per channel, even when the channel is subscribed to it for all automations and for that one. + +## Manage channels in the portal + +Your channels are in your settings at [cloud.nan.builders](https://cloud.nan.builders/): open **Settings**, then **Notifications**, below **Secrets**. Each automation's page also tells you which channels cover it. + +### Add a channel + +1. Press **Add channel** and pick the kind: Slack, Microsoft Teams, Discord or HTTPS. +2. Type a **Name** and paste the **URL** ([where to get it](#channels)). +3. Check the **Events**. The four defaults are on; turn on `run.finished` if you want it. Optionally limit them to one automation. +4. Save. For an **HTTPS** channel the portal shows the **signing secret** once: copy it to your endpoint's secret store now, it is never shown again. + +Then press **Test**: NaN sends a test message right away and tells you whether it arrived. + +### Test, edit, rotate, delete + +- **Test** sends a test message to the channel. It works only on an enabled channel, and you can send about one a minute (5 at once). +- **Edit** changes the name, the events and their automations, or replaces the URL. The kind cannot be changed: add a new channel instead. +- **Rotate signing secret** (HTTPS only) creates a new signing secret and shows it once. The old one keeps signing too for 24 hours, so you can update your endpoint without missing a request. +- **Disable** and **Enable** stop and resume a channel without deleting it. +- **Delete** asks you to confirm, and the channel and its subscriptions are gone for good. +- **Deliveries** shows the last 30 days of sends: event, status, HTTP status and error code. Never the message itself. + +## Digests and limits + +NaN keeps every channel readable and never floods a provider: + +- **Digests.** When more than 3 notifications are due for a channel at once, or more than it may still receive in the current minute, they arrive as **one digest message**: how many of each event, and up to 10 of them with their links. +- **Per channel:** at most 30 messages a minute and 1,000 a day. +- **Per account:** at most 3,000 messages a day across all your channels. + +Over the minute limit, messages wait and are sent the next minute (as a digest when there are several). Over a daily limit, that day's further notifications are dropped and the deliveries log shows `rate_limited`. Test messages count as messages. + +A notification not delivered within 24 hours is dropped: a late alert is worse than none. + +## When a channel fails + +Each message is retried when the failure may be temporary: a timeout, a connection or TLS error, a DNS failure, `408`, `429` or a `5xx`. It is tried up to 5 times, after 1 minute, 5 minutes, 30 minutes and 2 hours. Any other `4xx`, and any redirect, fails at once: the URL is wrong or was revoked. + +**Switched off after 20 failures.** After **20 failed attempts in a row**, the channel is disabled with the reason **failing**, and its pending messages are dropped. The portal and `nan notifications ls` show it. To fix it: + +1. Check the **Deliveries** log for the error (`http_4xx`, `timeout`, `dns` and so on). +2. If the webhook was deleted or revoked at the provider, create a new one and **replace the URL**: that re-enables the channel. +3. If the problem was on your side and is fixed, **Enable** the channel. Then press **Test**. + +Re-enabling or replacing the URL resets the failure count. + +**Switched off as `invalid_url`.** If the URL's host starts resolving to an address NaN refuses (a private or reserved one, see [Which URLs are accepted](#which-urls-are-accepted)), the channel is disabled at once with the reason **invalid_url**. Replace the URL to re-enable it. + +A channel you disabled yourself (reason **member**) stays off until you enable it, even if you replace its URL. + +## Verify the signature + +Every request to an **HTTPS** channel carries these headers, with the same signature scheme as the [webhooks you send to NaN](/docs/webhooks#send-your-own-events): + +| Header | Value | +|---|---| +| `X-Nan-Event` | The event: `approval.pending`, `run.failed`, `run.finished`, `automation.disabled`, `trigger.failing`, `digest` or `test`. | +| `X-Nan-Delivery` | A unique id per notification, the same on every retry of it. Use it to ignore repeats. | +| `X-Nan-Timestamp` | When it was signed, in Unix seconds. | +| `X-Nan-Signature` | `v1=` followed by the hex HMAC-SHA256 of `.` with the signing secret (the whole `nan_nsec_…` string is the key). For 24 hours after a rotation there are two, separated by a comma, the new one first. | + +Accept a request when **any** `v1=` value matches and the timestamp is less than 5 minutes off. Compute the HMAC over the body exactly as received, byte for byte, before parsing it. Answer with any `2xx` quickly; the response body is ignored. + +In bash, to check a request you saved (body in `body.json`): + +```bash +SECRET="nan_nsec_..." # the channel's signing secret +TS="1760436000" # the X-Nan-Timestamp header +SIGS="v1=...,v1=..." # the X-Nan-Signature header +EXPECTED=$(printf '%s.' "$TS" | cat - body.json | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //') + +NOW=$(date +%s) +if [ $((NOW - TS)) -gt 300 ] || [ $((TS - NOW)) -gt 300 ]; then echo "too old"; exit 1; fi +case ",$SIGS," in *",v1=$EXPECTED,"*) echo "valid" ;; *) echo "invalid"; exit 1 ;; esac +``` + +In Python: + +```python +import hashlib, hmac, time + +SECRET = b"nan_nsec_..." + +def verify(body: bytes, timestamp: str, signature_header: str) -> bool: + if abs(time.time() - int(timestamp)) > 300: + return False + expected = hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest() + return any( + part.startswith("v1=") and hmac.compare_digest(part[3:], expected) + for part in signature_header.split(",") + ) + +# In your handler, with the raw body: +# verify(request_body, headers["X-Nan-Timestamp"], headers["X-Nan-Signature"]) +``` + +In Node.js: + +```js +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { createServer } from 'node:http'; + +const secret = process.env.NAN_SIGNING_SECRET; // nan_nsec_... + +function verify(body, timestamp, header) { + if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; + const expected = Buffer.from(createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex')); + return (header ?? '').split(',').some((part) => { + const got = Buffer.from(part.trim().replace(/^v1=/, '')); + return part.trim().startsWith('v1=') && got.length === expected.length && timingSafeEqual(got, expected); + }); +} + +createServer((req, res) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const body = Buffer.concat(chunks); + if (!verify(body, req.headers['x-nan-timestamp'], req.headers['x-nan-signature'])) { + res.writeHead(401).end(); + return; + } + const event = JSON.parse(body); + console.log(event.event, event.text, event.url); + res.writeHead(204).end(); + }); +}).listen(8080); +``` + +Delivery is at least once: the same notification can arrive twice (for example after a network error once it was already received). Ignore an `X-Nan-Delivery` you have already processed. + +## Payload + +An **HTTPS** channel receives `POST` requests with `Content-Type: application/json` and `User-Agent: NaN-Notifications/1`. The body has `version: 1`, and fields that do not apply to an event are `null`: + +```json +{ + "version": 1, + "id": "…", + "event": "run.failed", + "created_at": "2026-10-14T10:00:00Z", + "automation": { "id": "…", "name": "PR review" }, + "run": { + "id": "…", + "state": "failed", + "error_code": "agent_failed", + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "trigger": null, + "reason": null, + "url": "https://cloud.nan.builders/workspaces/…/runs/…", + "text": "Run failed: PR review (agent_failed)" +} +``` + +| Field | What it holds | +|---|---| +| `id` | The notification's id, the same as `X-Nan-Delivery`. | +| `event` | One of the [events](#events). | +| `automation` | `{ "id", "name" }` of the automation. | +| `run` | On run events: `id`, `state` (`awaiting_approval`, `succeeded`, `failed` or `timed_out`), `error_code`, `phase` and `summary`. `null` otherwise. | +| `run.summary` | The run's summary as the portal shows it, with secrets already masked and control characters removed, at most 280 characters. Only on `run.finished` and `run.failed`. | +| `trigger` | `{ "id", "kind" }` on `trigger.failing`. | +| `reason` | `automation.disabled`: `member`, `workspace_deleted` or `secret_deleted`. `trigger.failing`: `bad_signature`. | +| `url` | Where to look in the portal: the run for run events, the automation for the others. | +| `text` | A one-line title, for example `Approval needed: PR review`, `Run timed out: PR review` or `Automation disabled: PR review (its workspace was deleted)`. | + +A **digest** groups several notifications: + +```json +{ + "version": 1, + "id": "…", + "event": "digest", + "created_at": "2026-10-14T10:00:00Z", + "count": 3, + "events": { "approval.pending": 1, "run.failed": 2 }, + "items": [ + { + "event": "run.failed", + "automation": { "id": "…", "name": "PR review" }, + "run": { "id": "…", "state": "timed_out", "error_code": "timed_out", "phase": "propose", "summary": "…" }, + "url": "https://cloud.nan.builders/workspaces/…/runs/…" + } + ], + "url": "https://cloud.nan.builders/automations", + "text": "3 notifications: 1 approval needed, 2 runs failed" +} +``` + +`count` is how many notifications it groups, `events` how many of each, and `items` up to 10 of them. + +A **test** message: + +```json +{ + "version": 1, + "id": "…", + "event": "test", + "created_at": "2026-10-14T10:00:00Z", + "url": "https://cloud.nan.builders/settings#notifications", + "text": "Test notification from NaN" +} +``` + +New fields may be added to version 1; ignore the ones you do not know. + +## Security + +### URLs are credentials + +Anyone who has a Slack, Teams or Discord webhook URL can post to that channel, so NaN treats every channel URL like a [secret](/docs/secrets): + +- It is **write-only**: encrypted with a key that belongs to your account alone, and never returned by the portal, the CLI or the API. You see a hint instead, the host and the last 4 characters of the path: `hooks.slack.com/…/abcd`. +- It never appears in an error, a log or the deliveries list. +- From the CLI it is read from a file or a pipe, never from the command line. + +If a webhook URL leaks, delete the webhook at the provider, create a new one and replace the URL in NaN. + +### What a message never carries + +Messages carry names, states, error codes, ids and links. They **never** carry: + +- the value of any secret, or a channel URL; +- the body or headers of the webhook request that started a run; +- the agent's output, beyond the run's short summary (at most 280 characters, with secrets masked). + +Member text (an automation's name, a summary) is escaped for each provider: it cannot mention anyone, ping a role or form a link. + +### Which URLs are accepted + +NaN's servers call these URLs, so they follow strict rules, checked when you save the URL and again on every send: + +- `https` only, on port 443. No user or password in the URL, no fragment, no IP address: a DNS name with at least two labels. +- Not NaN's own hosts (`nan.builders`, `helmcode.com` and their subdomains), and not local names (`localhost`, `.local`, `.internal`, `.lan`, `.home.arpa`, `.svc`). +- Every address the name resolves to must be public: private, loopback, link-local, shared and reserved addresses are refused, and so are NaN's own. +- Slack URLs must be on `hooks.slack.com` with a path starting `/services/`. Teams URLs on a subdomain of `webhook.office.com`, `logic.azure.com` or `powerplatform.com`. Discord URLs on `discord.com` (or `discordapp.com`, `canary.discord.com`, `ptb.discord.com`) with a path starting `/api/webhooks/`. +- Redirects are not followed: a `3xx` is a failure. + +A refused URL gets one generic error that names the rule it breaks, never what you sent, and a name that does not resolve gets the same answer as one that resolves to a refused address. + +## Manage channels from the CLI + +With the [NaN CLI](/docs/nan-cli) **0.1.34** or newer, where your account has notifications and the write API: + +```bash +nan notifications ls # kind, name, URL hint, status +nan notifications add "team alerts" --kind slack --url-file ./slack-url.txt +pbpaste | nan notifications add "ops endpoint" --kind https --url-stdin --event run.failed --event run.finished +nan notifications add "nightly" --kind discord --url-file ./discord-url.txt --automation "nightly report" +nan notifications show "team alerts" +nan notifications test "team alerts" +nan notifications subscriptions "team alerts" # list them +nan notifications subscriptions "team alerts" --add "run.finished=nightly report" +nan notifications subscriptions "team alerts" --set run.failed --set approval.pending +nan notifications edit "team alerts" --url-file ./new-url.txt +nan notifications edit "team alerts" --enable +nan notifications rotate "ops endpoint" # prints the new signing secret once +nan notifications deliveries "team alerts" --limit 50 +nan notifications rm "team alerts" +``` + +- **The URL never goes on the command line**, where it would stay in your shell history. `--url-file PATH` reads it from a file, `--url-stdin` from a pipe. There is no `--url` flag. +- `add` takes the default events unless you pass `--event` (repeatable, or comma-separated) or `--no-events`; `--automation` (a name or an id) scopes them to one automation. +- For an HTTPS channel, `add` and `rotate` print the signing secret **once**, with a reminder to store it now. With `--json` it is inside the JSON, as `signing_secret`. +- `subscriptions` without flags lists them. `EVENT` alone means all your automations, `EVENT=AUTOMATION` one of them. `--set` replaces the whole set, `--add` and `--remove` change it, `--clear` removes them all. +- `edit` changes the name (`--name`), switches the channel on or off (`--enable`, `--disable`) or replaces the URL. Replacing the URL or `--enable` re-enables a channel NaN switched off and resets its failure count. +- `test` exits with code 0 when the message was delivered and 1 when it was not, with the reason. +- `deliveries` shows the last 20 sends by default (`--limit` 1 to 100). +- `rm` asks before deleting; `--yes` skips the question in scripts. +- Commands take a channel's name or its id, and `--json` prints JSON. +- Where your account does not have notifications or the write API, the commands say so, point you to the portal and exit with code 64. A refused credential or scope exits with 65, and a rate limit or a server error with 69. + +With a platform token, the commands need `notifications:read` to list or show channels (and to find one by name) and `notifications:write` to change them; `--automation` with a name also needs `automations:read`. + +The full list of flags is in `nan notifications --help`. + +## Manage channels from the API + +The same operations are in the API, under `https://api.nan.builders/v1/notification-channels`: + +| Method and path | Scope | What it does | +|---|---|---| +| `GET /v1/notification-channels` | `notifications:read` | Lists your channels. | +| `GET /v1/notification-channels/{id}` | `notifications:read` | One channel. | +| `GET /v1/notification-channels/{id}/deliveries?limit=` | `notifications:read` | Its last sends, newest first (30 days, `limit` 1 to 100, default 50). | +| `POST /v1/notification-channels` | `notifications:write` | Creates a channel: `{"name", "kind", "url", "events"?, "automation_id"?}`. `201`, with `signing_secret` once for `https`. | +| `PATCH /v1/notification-channels/{id}` | `notifications:write` | Changes `name`, `enabled` or `url`. | +| `DELETE /v1/notification-channels/{id}` | `notifications:write` | Deletes it. `204`. | +| `PUT /v1/notification-channels/{id}/subscriptions` | `notifications:write` | Replaces its subscriptions: `{"subscriptions": [{"event", "automation_id"}]}`, `automation_id` `null` for all automations. Up to 50; `[]` removes them all. | +| `POST /v1/notification-channels/{id}/test` | `notifications:write` | Sends a test message and answers whether it was delivered. | +| `POST /v1/notification-channels/{id}/rotate-signing-secret` | `notifications:write` | New signing secret (shown once); the old one keeps signing for 24 hours. | + +**Which credentials work:** your CLI session from `nan auth login`, or a platform token with the scope of the route. The two scopes are independent: `notifications:write` does not include `notifications:read`. A token with a `notifications:` scope can only be created in the portal, **Settings → Tokens**, signed in through your browser, and the scopes are offered there only where your account has notifications and the write API. An API key (`sk-...`) and the key inside a workspace are refused. Where your account does not have notifications or the write API, every route answers `404`. + +Create a channel from a file, so the URL never appears in your shell history: + +```bash +jq -n --rawfile u slack-url.txt '{name: "team alerts", kind: "slack", url: ($u | rtrimstr("\n"))}' | + curl https://api.nan.builders/v1/notification-channels \ + -H "Authorization: Bearer $NAN_TOKEN" \ + -H "Content-Type: application/json" \ + --data-binary @- +``` + +The answer is the channel, never its URL: + +```json +{ + "id": "…", + "object": "notification_channel", + "name": "team alerts", + "kind": "slack", + "url_hint": "hooks.slack.com/…/abcd", + "enabled": true, + "disabled_reason": null, + "consecutive_failures": 0, + "last_delivery_at": null, + "last_delivery_status": null, + "subscriptions": [ + { "event": "approval.pending", "automation_id": null }, + { "event": "run.failed", "automation_id": null }, + { "event": "automation.disabled", "automation_id": null }, + { "event": "trigger.failing", "automation_id": null } + ], + "signing": null, + "updated_by": "platform_token", + "created_at": "…", + "updated_at": "…" +} +``` + +- `disabled_reason` is `null`, `member` (you disabled it), `failing` or `invalid_url`. `signing` is `{"previous_expires_at"}` on `https` channels: when the old secret stops signing after a rotation. +- `updated_by` says where the last change came from: `browser_session`, `cli_session` or `platform_token`. +- **Errors** come as `{"error": {"message", "type", "param", "code"}}`. `param` names the field that is wrong and never repeats what you sent. +- **Rate limits.** Changes to channels (create, edit, delete, subscriptions, rotate) share one allowance per account: a burst of 30, then 120 per hour, counting the portal, the CLI and the API. Tests have their own: 5 at once, then 1 a minute. Beyond them, `429 rate_limited`. + +Every route, body and error is in [API reference → Notifications](/docs/api#tag/notifications). + +## Limits + +| Limit | Value | +|---|---| +| Channels | 10 per member | +| Subscriptions | 50 per channel | +| Name | 1 to 64 characters | +| URL | Up to 2048 bytes | +| Messages | 30 a minute and 1,000 a day per channel; 3,000 a day per member | +| Digest | Up to 10 items listed | +| Retries | 5 attempts over about 2.5 hours | +| Auto-disable | After 20 failed attempts in a row | +| Pending notification | Dropped after 24 hours | +| Deliveries log | 30 days | +| Changes to channels | A burst of 30, then 120 per hour | +| Tests | 5 at once, then 1 a minute | +| API request body | 8 KiB | + +## If something goes wrong + +| What you see | Why, and what to do | +|---|---| +| `400 invalid_request` with a `param` | That field breaks a rule: `name`, `kind`, `events` or `subscriptions` (an unknown event, or more than 50), or `limit` (1 to 100). | +| `400 invalid_url` | The URL breaks one of the [rules](#which-urls-are-accepted): not `https` on 443, an IP address, the wrong host for its kind, or a host that does not resolve or resolves to a refused address. Copy the URL again from the provider. | +| `403 credential_not_allowed` | An API key (`sk-...`) or a session from an older CLI. Use your `nan auth login` session or a platform token with the scope. | +| `403 token_scope_denied` | The token lacks `notifications:read` or `notifications:write`. Create one with the scope in **Settings → Tokens**. | +| `403 browser_session_required` | Your account does not have the write API yet, so changes need the portal, signed in through your browser. | +| `404` on every `/v1/notification-channels` route | Your account does not have notifications or the write API yet. Use the portal. | +| `404 not_found` | No channel with that id in your account. | +| `404 automation_not_found` | The `automation_id` is not one of your automations. | +| `409 name_taken` | You already have a channel with that name. | +| `409 too_many_channels` | You have 10. Delete one to add another. | +| `409 channel_disabled` | You tried to test a disabled channel. Enable it first. | +| `409 signing_not_supported` | Only HTTPS channels have a signing secret. | +| `429 rate_limited` | Too many changes or tests. Wait and try again. | +| `503 notifications_unavailable` | Notifications are briefly unavailable. Try again in a few minutes. | +| The test answers `failed` with `http_4xx` | The provider refused the message: the webhook was deleted, or the URL is incomplete. Create a new one and replace the URL. | +| The test answers `blocked` | The URL's host resolves to an address NaN refuses. Use a public endpoint. | +| The test answers `rate_limited` | The channel used up its messages for now. Try later. | +| The channel shows **failing** | 20 sends in a row failed. See [When a channel fails](#when-a-channel-fails). | +| Nothing arrives for a run | The run was not started by an automation, was cancelled, or its event is not subscribed for that automation (`run.finished` is off by default). Check the channel's events and its **Deliveries**. | +| Your endpoint rejects every signature | Compute the HMAC over the raw body before parsing it, use the whole `nan_nsec_…` secret as the key, and check your server's clock. After a rotation, store the new secret. | diff --git a/src/content/docs/secrets.mdx b/src/content/docs/secrets.mdx index fe189b78..b7403c85 100644 --- a/src/content/docs/secrets.mdx +++ b/src/content/docs/secrets.mdx @@ -43,6 +43,8 @@ Every secret has: | `generic` | **Secret** | A token or value your automations receive as an environment variable: a GitHub token, a Slack webhook, an API key for your own service. | | `provider_key` | **Provider key** | Your own key with an AI provider, used as the inference of [runs](/docs/runs#use-your-own-provider-key-byok) and [automations](/docs/automations#use-your-own-provider-key-byok) instead of NaN inference (bring your own key, BYOK). | +To be told in Slack, Microsoft Teams or Discord when an automation needs you, you do not need a secret: add a [notification channel](/docs/notifications). Its URL is kept write-only like a secret. + ### Secret The value is saved exactly as you paste it, up to 16 KiB: it is never trimmed, so a pasted key is never silently changed. It must be text (UTF-8), with no control characters other than tabs and line breaks. Line breaks inside the value are fine (a PEM private key, for example), but not at its start or end. diff --git a/src/content/docs/webhooks.mdx b/src/content/docs/webhooks.mdx index a6f65166..e0cd2d44 100644 --- a/src/content/docs/webhooks.mdx +++ b/src/content/docs/webhooks.mdx @@ -1,7 +1,7 @@ --- title: Webhooks and schedules description: Start your automations from GitHub, Stripe or your own systems with signed webhooks, or on a schedule. -order: 23 +order: 24 group: Guides --- diff --git a/src/tests/fixtures/notifications/discord_run_failed.json b/src/tests/fixtures/notifications/discord_run_failed.json new file mode 100644 index 00000000..71e677e2 --- /dev/null +++ b/src/tests/fixtures/notifications/discord_run_failed.json @@ -0,0 +1,32 @@ +{ + "allowed_mentions": { + "parse": [] + }, + "content": null, + "embeds": [ + { + "color": 15158332, + "description": "Reviewed PR \\#42\\: two issues found in pkg/foo.", + "fields": [ + { + "inline": true, + "name": "Event", + "value": "run.failed" + }, + { + "inline": true, + "name": "State", + "value": "failed" + }, + { + "inline": true, + "name": "Error", + "value": "agent_failed" + } + ], + "timestamp": "2026-10-14T10:00:00Z", + "title": "Run failed: PR review (agent_failed)", + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222" + } + ] +} diff --git a/src/tests/fixtures/notifications/https_approval_pending.json b/src/tests/fixtures/notifications/https_approval_pending.json new file mode 100644 index 00000000..aabff7ea --- /dev/null +++ b/src/tests/fixtures/notifications/https_approval_pending.json @@ -0,0 +1,21 @@ +{ + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "created_at": "2026-10-14T10:00:00Z", + "event": "approval.pending", + "id": "44444444-4444-4444-8444-444444444444", + "reason": null, + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "awaiting_approval", + "error_code": null, + "phase": "propose", + "summary": null + }, + "text": "Approval needed: PR review", + "trigger": null, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_automation_disabled.json b/src/tests/fixtures/notifications/https_automation_disabled.json new file mode 100644 index 00000000..1440fd56 --- /dev/null +++ b/src/tests/fixtures/notifications/https_automation_disabled.json @@ -0,0 +1,15 @@ +{ + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "created_at": "2026-10-14T10:00:00Z", + "event": "automation.disabled", + "id": "44444444-4444-4444-8444-444444444444", + "reason": "workspace_deleted", + "run": null, + "text": "Automation disabled: PR review (its workspace was deleted)", + "trigger": null, + "url": "https://cloud.nan.builders/automations/11111111-1111-4111-8111-111111111111", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_digest.json b/src/tests/fixtures/notifications/https_digest.json new file mode 100644 index 00000000..b6ea2bf8 --- /dev/null +++ b/src/tests/fixtures/notifications/https_digest.json @@ -0,0 +1,60 @@ +{ + "count": 3, + "created_at": "2026-10-14T10:00:00Z", + "event": "digest", + "events": { + "approval.pending": 1, + "run.failed": 2 + }, + "id": "44444444-4444-4444-8444-444444444444", + "items": [ + { + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "event": "run.failed", + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "failed", + "error_code": "agent_failed", + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222" + }, + { + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "event": "run.failed", + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "timed_out", + "error_code": "timed_out", + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222" + }, + { + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "event": "approval.pending", + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "awaiting_approval", + "error_code": null, + "phase": "propose", + "summary": null + }, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222" + } + ], + "text": "3 notifications: 1 approval needed, 2 runs failed", + "url": "https://cloud.nan.builders/automations", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_run_failed.json b/src/tests/fixtures/notifications/https_run_failed.json new file mode 100644 index 00000000..b1f89e11 --- /dev/null +++ b/src/tests/fixtures/notifications/https_run_failed.json @@ -0,0 +1,21 @@ +{ + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "created_at": "2026-10-14T10:00:00Z", + "event": "run.failed", + "id": "44444444-4444-4444-8444-444444444444", + "reason": null, + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "failed", + "error_code": "agent_failed", + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "text": "Run failed: PR review (agent_failed)", + "trigger": null, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_run_finished.json b/src/tests/fixtures/notifications/https_run_finished.json new file mode 100644 index 00000000..9f23c6ee --- /dev/null +++ b/src/tests/fixtures/notifications/https_run_finished.json @@ -0,0 +1,21 @@ +{ + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "created_at": "2026-10-14T10:00:00Z", + "event": "run.finished", + "id": "44444444-4444-4444-8444-444444444444", + "reason": null, + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "succeeded", + "error_code": null, + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "text": "Run succeeded: PR review", + "trigger": null, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_run_timed_out.json b/src/tests/fixtures/notifications/https_run_timed_out.json new file mode 100644 index 00000000..f27dcb62 --- /dev/null +++ b/src/tests/fixtures/notifications/https_run_timed_out.json @@ -0,0 +1,21 @@ +{ + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "created_at": "2026-10-14T10:00:00Z", + "event": "run.failed", + "id": "44444444-4444-4444-8444-444444444444", + "reason": null, + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "timed_out", + "error_code": "timed_out", + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "text": "Run timed out: PR review", + "trigger": null, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_test.json b/src/tests/fixtures/notifications/https_test.json new file mode 100644 index 00000000..4a993402 --- /dev/null +++ b/src/tests/fixtures/notifications/https_test.json @@ -0,0 +1,8 @@ +{ + "created_at": "2026-10-14T10:00:00Z", + "event": "test", + "id": "44444444-4444-4444-8444-444444444444", + "text": "Test notification from NaN", + "url": "https://cloud.nan.builders/settings#notifications", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_trigger_failing.json b/src/tests/fixtures/notifications/https_trigger_failing.json new file mode 100644 index 00000000..ac68f037 --- /dev/null +++ b/src/tests/fixtures/notifications/https_trigger_failing.json @@ -0,0 +1,18 @@ +{ + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "created_at": "2026-10-14T10:00:00Z", + "event": "trigger.failing", + "id": "44444444-4444-4444-8444-444444444444", + "reason": "bad_signature", + "run": null, + "text": "Webhook trigger failing: PR review (signature checks keep failing)", + "trigger": { + "id": "55555555-5555-4555-8555-555555555555", + "kind": "webhook" + }, + "url": "https://cloud.nan.builders/automations/11111111-1111-4111-8111-111111111111", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/slack_run_failed.json b/src/tests/fixtures/notifications/slack_run_failed.json new file mode 100644 index 00000000..67adb7f5 --- /dev/null +++ b/src/tests/fixtures/notifications/slack_run_failed.json @@ -0,0 +1,37 @@ +{ + "blocks": [ + { + "text": { + "text": "*\u003chttps://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222|Run failed: PR review (agent_failed)\u003e*", + "type": "mrkdwn" + }, + "type": "section" + }, + { + "elements": [ + { + "text": "`run.failed`", + "type": "mrkdwn" + }, + { + "text": "state: failed", + "type": "mrkdwn" + }, + { + "text": "error: agent_failed", + "type": "mrkdwn" + } + ], + "type": "context" + }, + { + "text": { + "emoji": false, + "text": "Reviewed PR #42: two issues found in pkg/foo.", + "type": "plain_text" + }, + "type": "section" + } + ], + "text": "Run failed: PR review (agent_failed)" +} diff --git a/src/tests/fixtures/notifications/teams_run_failed.json b/src/tests/fixtures/notifications/teams_run_failed.json new file mode 100644 index 00000000..7c7ec606 --- /dev/null +++ b/src/tests/fixtures/notifications/teams_run_failed.json @@ -0,0 +1,60 @@ +{ + "attachments": [ + { + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "actions": [ + { + "title": "Open in NaN", + "type": "Action.OpenUrl", + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222" + } + ], + "body": [ + { + "inlines": [ + { + "size": "Medium", + "text": "Run failed: PR review (agent_failed)", + "type": "TextRun", + "weight": "Bolder" + } + ], + "type": "RichTextBlock" + }, + { + "facts": [ + { + "title": "Event", + "value": "run.failed" + }, + { + "title": "State", + "value": "failed" + }, + { + "title": "Error", + "value": "agent_failed" + } + ], + "type": "FactSet" + }, + { + "inlines": [ + { + "text": "Reviewed PR #42: two issues found in pkg/foo.", + "type": "TextRun" + } + ], + "type": "RichTextBlock" + } + ], + "type": "AdaptiveCard", + "version": "1.4" + }, + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": null + } + ], + "type": "message" +} diff --git a/src/tests/layouts/automationsDocs.test.ts b/src/tests/layouts/automationsDocs.test.ts index 5f199725..e7b2da1e 100644 --- a/src/tests/layouts/automationsDocs.test.ts +++ b/src/tests/layouts/automationsDocs.test.ts @@ -96,10 +96,10 @@ describe.each(Object.entries(pages))('automations guides (%s)', (_locale, page) expect(a.group).toBe(page.group); expect(h.group).toBe(page.group); expect(a.order).toBe(runs.order + 1); - // The PR gatekeeper guide and the Secrets page are nested under - // Automations (frontmatter `parent`), so they take the two slots right - // after it and Webhooks follows. - expect(h.order).toBe(a.order + 3); + // The PR gatekeeper guide, the Secrets page and the Notifications page + // are nested under Automations (frontmatter `parent`), so they take the + // three slots right after it and Webhooks follows. + expect(h.order).toBe(a.order + 4); for (const later of ['examples.md', 'apps.md']) { const o = (parseFrontmatter(read(page.dir + later)).frontmatter as { order: number }).order; expect(o, later).toBeGreaterThan(h.order); diff --git a/src/tests/layouts/docsNavNesting.render.test.ts b/src/tests/layouts/docsNavNesting.render.test.ts index 6bbd5434..015b146f 100644 --- a/src/tests/layouts/docsNavNesting.render.test.ts +++ b/src/tests/layouts/docsNavNesting.render.test.ts @@ -111,12 +111,33 @@ for (const lang of ['en', 'es'] as const) { expect(html).toMatch( new RegExp( ``, + `]*>\\s*]*>[^<]*\\s*`, ), ); expect(html.match(new RegExp(`href="${pfx}/docs/secrets"`, 'g'))).toHaveLength(1); }); + // Owner's ask (2026-10-14): Notifications is the third sub-item of + // Automations, right after Secrets, closing the nested list. + it('renders Notifications last in the same nested list, right after Secrets', async () => { + const html = await sidebar(lang, `${pfx}/docs`); + expect(html).toMatch( + new RegExp( + ``, + ), + ); + expect(html.match(new RegExp(`href="${pfx}/docs/notifications"`, 'g'))).toHaveLength(1); + }); + + it('marks Notifications as the current page and Automations as the trail when it is open', async () => { + const html = await sidebar(lang, `${pfx}/docs/notifications`); + expect(html).toMatch(new RegExp(` { const html = await sidebar(lang, `${pfx}/docs/secrets`); expect(html).toMatch(new RegExp(`(); /* @@ -46,11 +46,11 @@ const between = (html: string, start: RegExp, end: string) => { }; const LOCALES = [ - { lang: 'en', pfx: '', automations: 'Automations', webhooks: 'Webhooks and schedules', secrets: 'Secrets' }, - { lang: 'es', pfx: '/es', automations: 'Automatizaciones', webhooks: 'Webhooks y programaciones', secrets: 'Secretos' }, + { lang: 'en', pfx: '', automations: 'Automations', webhooks: 'Webhooks and schedules', secrets: 'Secrets', notifications: 'Notifications' }, + { lang: 'es', pfx: '/es', automations: 'Automatizaciones', webhooks: 'Webhooks y programaciones', secrets: 'Secretos', notifications: 'Notificaciones' }, ] as const; -for (const { lang, pfx, automations, webhooks, secrets } of LOCALES) { +for (const { lang, pfx, automations, webhooks, secrets, notifications } of LOCALES) { describe(`nested docs page (${lang})`, () => { it('breadcrumb: Docs / parent / current page', async () => { const body = await page(`${pfx}/docs/pr-gatekeeper`); @@ -71,10 +71,17 @@ for (const { lang, pfx, automations, webhooks, secrets } of LOCALES) { expect(pn).toMatch(new RegExp(`.*?${secrets}`)); }); - it('prev/next of the last child: its previous sibling, then the parent\'s next sibling', async () => { + it('prev/next of a middle child: its previous sibling, then its next sibling', async () => { const body = await page(`${pfx}/docs/secrets`); const pn = between(body, /