Utoljára aktív 5 days ago

Demo real de la API de ingesta de notificaciones de Valet (episodio 847 de atareao con Linux): arranque local, claves de ingesta, ingesta, buzon, mark-read, revocacion, casos limite y WebPush.

00-arranque.sh Eredeti
1#!/usr/bin/env bash
2# 00-arranque.sh — levanta Valet en local con una SQLite de usar y tirar.
3#
4# Uso: ./00-arranque.sh
5#
6# Decisiones de esta demo (todas verificadas ejecutándola):
7# * Binario DEBUG (`target/debug/valet`). El `release` está caducado y revienta
8# al migrar con `no such module: vec0`.
9# * Se ejecuta DESDE la raíz del repositorio: las migraciones se resuelven por
10# ruta relativa (`migrations/`). Desde otro `cwd` falla.
11# * AUTH_ENABLED=false: toda la API `/api/*` opera como el usuario `dev`, sin
12# PocketID y sin cookie de sesión. Es lo que permite la demo sin montar OIDC.
13# La ruta de ingesta (`POST /api/notifications`) sigue exigiendo token.
14# * NO se exige OPENROUTER_API_KEY en el arranque: sin ella cae a Ollama y deja
15# el RAG desactivado. No se necesita ninguna credencial de LLM.
16# * La DATABASE_URL apunta a $WORKDIR, nunca a una base de datos real.
17set -euo pipefail
18
19REPO="${VALET_REPO:-/data/rust/valet}"
20WORK="${WORKDIR:-/tmp/opencode/valet-847}"
21HOST="${HOST:-127.0.0.1}"
22PORT="${PORT:-3458}"
23
24mkdir -p "$WORK"
25# SQLite limpia en cada arranque (borra también WAL/SHM).
26rm -f "$WORK"/valet-demo.db "$WORK"/valet-demo.db-shm "$WORK"/valet-demo.db-wal
27
28cd "$REPO"
29DATABASE_URL="sqlite:$WORK/valet-demo.db" \
30AUTH_ENABLED=false \
31HOST="$HOST" PORT="$PORT" \
32 ./target/debug/valet > "$WORK/valet.log" 2>&1 &
33echo $! > "$WORK/valet.pid"
34
35# Espera activa a que /api/health responda.
36for _ in $(seq 1 40); do
37 if curl -fsS "http://$HOST:$PORT/api/health" >/dev/null 2>&1; then break; fi
38 sleep 0.5
39done
40
41echo "== comando (sin secretos) =="
42echo "cd $REPO"
43echo "DATABASE_URL=\"sqlite:$WORK/valet-demo.db\" AUTH_ENABLED=false HOST=$HOST PORT=$PORT ./target/debug/valet"
44echo
45echo "== health =="
46curl -s -w "\n[%{http_code}]\n" "http://$HOST:$PORT/api/health"
47echo
48echo "PID=$(cat "$WORK/valet.pid") · log: $WORK/valet.log"
49echo "Para parar el servidor: ./99-parar.sh"
50
01-crear-clave.sh Eredeti
1#!/usr/bin/env bash
2# 01-crear-clave.sh — crea una clave de ingesta.
3#
4# El token se enseña UNA sola vez. Aquí se ENMASCARA: solo se muestra el prefijo
5# (los primeros 12 caracteres, `val_XXXXXXXX`) y el resto se sustituye por `<…>`.
6# El token completo se guarda solo en el workdir efímero para los pasos siguientes.
7#
8# Uso: ./01-crear-clave.sh [nombre] (por defecto: backup-host)
9set -euo pipefail
10
11BASE="${VALET_BASE:-http://127.0.0.1:3458}"
12WORK="${WORKDIR:-/tmp/opencode/valet-847}"
13NAME="${1:-backup-host}"
14
15echo "== POST /api/ingest-keys name=$NAME =="
16# AUTH_ENABLED=false => no hace falta cookie de sesión (la API opera como `dev`).
17RAW=$(curl -s -w $'\n%{http_code}' -X POST "$BASE/api/ingest-keys" \
18 -H "Content-Type: application/json" \
19 -d "{\"name\":\"$NAME\"}")
20BODY=$(printf '%s' "$RAW" | head -n1)
21CODE=$(printf '%s' "$RAW" | tail -n1)
22
23# Salida enmascarada: el token se recorta a su prefijo + <…>.
24echo "$BODY" | python3 -c '
25import sys, json
26d = json.load(sys.stdin)
27out = {"id": d["id"], "name": d["name"], "prefix": d["prefix"],
28 "token": d["token"][:12] + "<…>"}
29print(json.dumps(out, ensure_ascii=False))
30'
31echo "[$CODE]"
32
33# Estado para los pasos siguientes (efímero, nunca en la evidencia).
34echo "$BODY" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])' > "$WORK/keyid.txt"
35echo "$BODY" | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])' > "$WORK/token.txt"
36echo "# token completo guardado en $WORK/token.txt (efímero, NO se imprime)"
37echo "# máscara aplicada: token -> primeros 12 caracteres + <…>"
38
02-enviar-notificacion.sh Eredeti
1#!/usr/bin/env bash
2# 02-enviar-notificacion.sh — ingesta autenticada con la clave del paso 01.
3#
4# Demuestra:
5# * POST /api/notifications con token -> 201 + {"id"}
6# * Sin cabecera Authorization -> 401 {"error":"unauthorized"}
7# * Token inventado -> 401 (mismo cuerpo, indistinguible)
8#
9# Uso: ./02-enviar-notificacion.sh
10set -euo pipefail
11
12BASE="${VALET_BASE:-http://127.0.0.1:3458}"
13WORK="${WORKDIR:-/tmp/opencode/valet-847}"
14TOKEN="$(cat "$WORK/token.txt")"
15
16echo "== POST /api/notifications (Authorization: Bearer val_XXXXXXXX<…>) =="
17RAW=$(curl -s -w $'\n%{http_code}' -X POST "$BASE/api/notifications" \
18 -H "Authorization: Bearer $TOKEN" \
19 -H "Content-Type: application/json" \
20 -d '{"message":"Backup terminado con 3 avisos","origin":"backup-host","author":"nightly-job","level":"warning","metadata":{"warnings":3}}')
21BODY=$(printf '%s' "$RAW" | head -n1)
22CODE=$(printf '%s' "$RAW" | tail -n1)
23echo "$BODY"
24echo "[$CODE]"
25# Guarda el id de la notificación para los pasos siguientes.
26printf '%s' "$BODY" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])' > "$WORK/nid.txt"
27
28echo
29echo "== sin cabecera Authorization -> 401 =="
30curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications" \
31 -H "Content-Type: application/json" -d '{"message":"x"}'
32
33echo
34echo "== Bearer con token inventado -> 401 (mismo cuerpo) =="
35curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications" \
36 -H "Authorization: Bearer val_estoNoExiste000000000000000000000000000000" \
37 -H "Content-Type: application/json" -d '{"message":"x"}'
38
39echo
40echo "== Authorization mal formada (sin 'Bearer ') -> 401 =="
41curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications" \
42 -H "Authorization: val_algo" \
43 -H "Content-Type: application/json" -d '{"message":"x"}'
44
03-leer-buzon.sh Eredeti
1#!/usr/bin/env bash
2# 03-leer-buzon.sh — lee el buzón (no leídas) y marca una como leída.
3#
4# Recuerda la frontera: el buzón se lee CON SESIÓN, no con token de ingesta.
5# Con AUTH_ENABLED=false no hace falta cookie: la API opera como `dev`.
6#
7# Uso: ./03-leer-buzon.sh
8set -euo pipefail
9
10BASE="${VALET_BASE:-http://127.0.0.1:3458}"
11WORK="${WORKDIR:-/tmp/opencode/valet-847}"
12
13echo "== GET /api/notifications/unread?limit=20 =="
14curl -s -w "\n[%{http_code}]\n" "$BASE/api/notifications/unread?limit=20"
15
04-marcar-leidas.sh Eredeti
1#!/usr/bin/env bash
2# 04-marcar-leidas.sh — marca como leída la notificación creada en el paso 02.
3#
4# Demuestra la idempotencia: la segunda llamada devuelve {"marked":0}.
5#
6# Uso: ./04-marcar-leidas.sh
7set -euo pipefail
8
9BASE="${VALET_BASE:-http://127.0.0.1:3458}"
10WORK="${WORKDIR:-/tmp/opencode/valet-847}"
11NID="$(cat "$WORK/nid.txt")"
12
13echo "== POST /api/notifications/mark-read ids=[$NID] =="
14curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications/mark-read" \
15 -H "Content-Type: application/json" \
16 -d "{\"ids\":[\"$NID\"]}"
17
18echo
19echo "== repetido (idempotente) -> 0 =="
20curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications/mark-read" \
21 -H "Content-Type: application/json" \
22 -d "{\"ids\":[\"$NID\"]}"
23
05-revocar.sh Eredeti
1#!/usr/bin/env bash
2# 05-revocar.sh — revoca la clave y comprueba que el token deja de funcionar.
3#
4# Demuestra:
5# * DELETE /api/ingest-keys/{id} -> 204 siempre (exista o no)
6# * repetir el DELETE -> 204 (idempotente, no cambia revoked_at)
7# * reenviar con el token revocado -> 401 (igual que un token inventado)
8# * GET /api/ingest-keys muestra la clave con revoked_at relleno (no se borra)
9#
10# Uso: ./05-revocar.sh
11set -euo pipefail
12
13BASE="${VALET_BASE:-http://127.0.0.1:3458}"
14WORK="${WORKDIR:-/tmp/opencode/valet-847}"
15KEYID="$(cat "$WORK/keyid.txt")"
16TOKEN="$(cat "$WORK/token.txt")"
17
18echo "== DELETE /api/ingest-keys/$KEYID =="
19curl -s -w "\n[%{http_code}]\n" -X DELETE "$BASE/api/ingest-keys/$KEYID"
20
21echo
22echo "== DELETE otra vez (idempotente) -> 204 =="
23curl -s -w "\n[%{http_code}]\n" -X DELETE "$BASE/api/ingest-keys/$KEYID"
24
25echo
26echo "== reenviar con el token revocado -> 401 =="
27curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications" \
28 -H "Authorization: Bearer $TOKEN" \
29 -H "Content-Type: application/json" -d '{"message":"x"}'
30
31echo
32echo "== GET /api/ingest-keys (revoked_at relleno; la fila NO se borra) =="
33curl -s -w "\n[%{http_code}]\n" "$BASE/api/ingest-keys"
34
06-casos-limite.sh Eredeti
1#!/usr/bin/env bash
2# 06-casos-limite.sh — los casos límite que cita el guion.
3#
4# Demuestra:
5# * 409 nombre duplicado en POST /api/ingest-keys
6# * 400 level con espacios (" info ") — NO se recorta
7# * 400 message vacío tras trim
8# * 415 sin Content-Type: application/json
9# * 400 mark-read con ids:null
10# * 400 mark-read con ids:[] (lista vacía)
11# * 400 unread con limit fuera de rango o no numérico
12#
13# Uso: ./06-casos-limite.sh
14set -euo pipefail
15
16BASE="${VALET_BASE:-http://127.0.0.1:3458}"
17WORK="${WORKDIR:-/tmp/opencode/valet-847}"
18
19echo "== 409: nombre duplicado 'backup-host' =="
20curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/ingest-keys" \
21 -H "Content-Type: application/json" -d '{"name":"backup-host"}'
22
23echo
24echo "== crear una clave nueva 'monitor' para los límites =="
25RAW=$(curl -s -w $'\n%{http_code}' -X POST "$BASE/api/ingest-keys" \
26 -H "Content-Type: application/json" -d '{"name":"monitor"}')
27printf '%s' "$RAW" | head -n1 | python3 -c '
28import sys, json
29d = json.load(sys.stdin)
30print(json.dumps({"id": d["id"], "name": d["name"], "prefix": d["prefix"],
31 "token": d["token"][:12] + "<…>"}, ensure_ascii=False))
32'
33echo "[$(printf '%s' "$RAW" | tail -n1)]"
34printf '%s' "$RAW" | head -n1 | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])' > "$WORK/token2.txt"
35T2="$(cat "$WORK/token2.txt")"
36
37echo
38echo "== 400: level con espacios (\" info \") =="
39curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications" \
40 -H "Authorization: Bearer $T2" -H "Content-Type: application/json" \
41 -d '{"message":"prueba","level":" info "}'
42
43echo
44echo "== 400: message vacío tras trim =="
45curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications" \
46 -H "Authorization: Bearer $T2" -H "Content-Type: application/json" \
47 -d '{"message":" "}'
48
49echo
50echo "== 415: sin Content-Type =="
51curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications" \
52 -H "Authorization: Bearer $T2" -d '{"message":"x"}'
53
54echo
55echo "== 400: mark-read con ids:null =="
56curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications/mark-read" \
57 -H "Content-Type: application/json" -d '{"ids":null}'
58
59echo
60echo "== 400: mark-read con ids:[] =="
61curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications/mark-read" \
62 -H "Content-Type: application/json" -d '{"ids":[]}'
63
64echo
65echo "== 400: unread limit=999 (fuera de [1,200]) =="
66curl -s -w "\n[%{http_code}]\n" "$BASE/api/notifications/unread?limit=999"
67
68echo
69echo "== 400: metadata no objeto (array) =="
70curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications" \
71 -H "Authorization: Bearer $T2" -H "Content-Type: application/json" \
72 -d '{"message":"x","metadata":[1,2]}'
73
74echo
75echo "== 400: message > 4096 bytes =="
76python3 -c 'print("{\"message\":\"" + "a"*4100 + "\"}")' \
77 | curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications" \
78 -H "Authorization: Bearer $T2" -H "Content-Type: application/json" --data @-
79
80echo
81echo "== 413: cuerpo > 64 KiB =="
82python3 -c 'print("{\"message\":\"" + "a"*70000 + "\"}")' \
83 | curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications" \
84 -H "Authorization: Bearer $T2" -H "Content-Type: application/json" --data @-
85
07-webpush.sh Eredeti
1#!/usr/bin/env bash
2# 07-webpush.sh — clave pública VAPID y alta de una suscripción de ejemplo.
3#
4# Demuestra:
5# * GET /api/push/vapid-key: la primera llamada GENERA el par VAPID (perezoso);
6# la segunda devuelve el MISMO par.
7# * POST /api/push/subscriptions con una suscripción válida -> 201 {"id"}
8# (p256dh = punto P-256 uncompressed de 65 bytes; auth = 16 bytes).
9# * upsert por endpoint: repetir la misma suscripción conserva el `id`.
10# * endpoint http:// -> 400; p256dh mal formado -> 400.
11#
12# La p256dh de ejemplo se genera aquí con `cryptography` (punto P-256 real).
13# Si no estuviera disponible, se documenta el intento y el error real.
14#
15# Uso: ./07-webpush.sh
16set -euo pipefail
17
18BASE="${VALET_BASE:-http://127.0.0.1:3458}"
19WORK="${WORKDIR:-/tmp/opencode/valet-847}"
20EP="https://push.example.test/demo-endpoint-847"
21
22# --- Genera una p256dh (65 bytes) y un auth (16 bytes) reales ---
23eval "$(python3 - <<'PY'
24import base64, os
25from cryptography.hazmat.primitives.asymmetric import ec
26from cryptography.hazmat.primitives import serialization
27priv = ec.generate_private_key(ec.SECP256R1())
28pub = priv.public_key().public_bytes(serialization.Encoding.X962,
29 serialization.PublicFormat.UncompressedPoint) # 0x04 || X || Y = 65 bytes
30assert len(pub) == 65, len(pub)
31b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=').decode()
32print("P256DH=%s" % b64(pub))
33print("AUTH=%s" % b64(os.urandom(16)))
34PY
35)"
36
37echo "== GET /api/push/vapid-key (1ª llamada: genera el par) =="
38curl -s -w "\n[%{http_code}]\n" "$BASE/api/push/vapid-key"
39
40echo
41echo "== GET /api/push/vapid-key (2ª llamada: mismo par) =="
42curl -s -w "\n[%{http_code}]\n" "$BASE/api/push/vapid-key"
43
44echo
45echo "== POST /api/push/subscriptions (suscripción válida) =="
46curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/push/subscriptions" \
47 -H "Content-Type: application/json" -A "DemoUA/1.0" \
48 -d "{\"endpoint\":\"$EP\",\"keys\":{\"p256dh\":\"$P256DH\",\"auth\":\"$AUTH\"}}"
49
50echo
51echo "== repetir la misma endpoint: upsert conserva el id =="
52curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/push/subscriptions" \
53 -H "Content-Type: application/json" -A "DemoUA/2.0" \
54 -d "{\"endpoint\":\"$EP\",\"keys\":{\"p256dh\":\"$P256DH\",\"auth\":\"$AUTH\"}}"
55
56echo
57echo "== 400: endpoint http:// =="
58curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/push/subscriptions" \
59 -H "Content-Type: application/json" \
60 -d "{\"endpoint\":\"http://push.example.test/x\",\"keys\":{\"p256dh\":\"$P256DH\",\"auth\":\"$AUTH\"}}"
61
62echo
63echo "== 400: p256dh mal formado =="
64curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/push/subscriptions" \
65 -H "Content-Type: application/json" \
66 -d "{\"endpoint\":\"$EP\",\"keys\":{\"p256dh\":\"AAAA\",\"auth\":\"$AUTH\"}}"
67
08-paginacion-keyset.sh Eredeti
1#!/usr/bin/env bash
2# 08-paginacion-keyset.sh — la paginación keyset del buzón (pieza de la trampa doc).
3#
4# Envía 3 avisos con la clave `monitor`, pide `unread?limit=1` y muestra que
5# `next_cursor` NO es null: la vista SÍ pagina. Luego encadena el cursor.
6#
7# NOTA: el cursor contiene un espacio (`"<created_at>|<rowid>"`), así que hay que
8# URL-encodearlo: se usa `curl -G --data-urlencode`.
9#
10# Uso: ./08-paginacion-keyset.sh
11set -euo pipefail
12
13BASE="${VALET_BASE:-http://127.0.0.1:3458}"
14WORK="${WORKDIR:-/tmp/opencode/valet-847}"
15T2="$(cat "$WORK/token2.txt")"
16
17for i in 1 2 3; do
18 curl -s -X POST "$BASE/api/notifications" \
19 -H "Authorization: Bearer $T2" -H "Content-Type: application/json" \
20 -d "{\"message\":\"aviso $i\",\"origin\":\"monitor\",\"author\":\"cron\",\"level\":\"info\"}" >/dev/null
21done
22
23echo "== GET /api/notifications/unread?limit=1 =="
24P1=$(curl -s "$BASE/api/notifications/unread?limit=1")
25echo "$P1"
26CUR=$(printf '%s' "$P1" | python3 -c 'import sys,json;print(json.load(sys.stdin)["next_cursor"] or "")')
27echo
28echo "-- next_cursor = $CUR (¡NO es null!)"
29echo
30echo "== siguiente página con el cursor (URL-encoded) =="
31curl -s -G -w "\n[%{http_code}]\n" "$BASE/api/notifications/unread" \
32 --data-urlencode "limit=1" --data-urlencode "cursor=$CUR"
33
09-trampa-doc-codigo.sh Eredeti
1#!/usr/bin/env bash
2# 09-trampa-doc-codigo.sh — demuestra la divergencia doc ↔ código.
3#
4# Dos puntos:
5# (A) mark-read SIN `ids`: la doc dice 400; el código MARCA TODAS.
6# (B) paginación del buzón: la doc dice `next_cursor` siempre null; el código
7# devuelve un cursor keyset real.
8#
9# Ejecuta (A) contra la instancia local y, además, coteja cada punto con su
10# fichero del repositorio y su línea.
11#
12# Uso: ./09-trampa-doc-codigo.sh
13set -euo pipefail
14
15BASE="${VALET_BASE:-http://127.0.0.1:3458}"
16REPO="${VALET_REPO:-/data/rust/valet}"
17WORK="${WORKDIR:-/tmp/opencode/valet-847}"
18T2="$(cat "$WORK/token2.txt")"
19
20echo "=== (A) mark-read SIN ids: lo que dice la doc y lo que hace el código ==="
21echo
22echo "-- DOC docs/api-notifications.md:191-193"
23sed -n '191,193p' "$REPO/docs/api-notifications.md" | sed 's/^/ /'
24echo
25echo "-- CÓDIGO src/routes/notifications.rs:386-393"
26sed -n '386,393p' "$REPO/src/routes/notifications.rs" | sed 's/^/ /'
27echo
28echo "-- EJECUCIÓN REAL (ids ausente = cuerpo {}):"
29# Primero crea un par de no leídas para que el marcado masivo tenga efecto.
30for i in 1 2; do
31 curl -s -X POST "$BASE/api/notifications" \
32 -H "Authorization: Bearer $T2" -H "Content-Type: application/json" \
33 -d "{\"message\":\"trampa $i\",\"origin\":\"monitor\"}" >/dev/null
34done
35curl -s -w "\n [%{http_code}]\n" -X POST "$BASE/api/notifications/mark-read" \
36 -H "Content-Type: application/json" -d '{}'
37echo " ^ marca TODAS, no un 400."
38
39echo
40echo
41echo "=== (B) paginación del buzón: doc vs spec vs código ==="
42echo
43echo "-- DOC docs/api-notifications.md:155-156"
44sed -n '155,156p' "$REPO/docs/api-notifications.md" | sed 's/^/ /'
45echo
46echo "-- SPEC openspec/specs/notifications/spec.md:174-177"
47sed -n '174,177p' "$REPO/openspec/specs/notifications/spec.md" | sed 's/^/ /'
48echo
49echo "-- CÓDIGO src/routes/notifications.rs:293-299"
50sed -n '293,299p' "$REPO/src/routes/notifications.rs" | sed 's/^/ /'
51echo
52echo "-- EJECUCIÓN REAL: unread?limit=1 devuelve next_cursor no nulo (ver 08)."
53
10-logs.sh Eredeti
1#!/usr/bin/env bash
2# 10-logs.sh — extrae del log del servidor los motivos de rechazo de la ingesta.
3#
4# La respuesta al cliente es SIEMPRE 401 {"error":"unauthorized"} e idéntica.
5# El motivo real (`missing_authorization`, `malformed_authorization`,
6# `unknown_or_revoked_key`) va SOLO al log del servidor. Esto lo demuestra.
7#
8# Uso: ./10-logs.sh
9set -euo pipefail
10
11WORK="${WORKDIR:-/tmp/opencode/valet-847}"
12
13echo "== motivos de rechazo registrados en $WORK/valet.log =="
14grep -aE 'denegado' "$WORK/valet.log" \
15 | sed -E 's/.*reason[^"]*"([^"]+)".*/ reason=\1/' \
16 | sort | uniq -c
17echo
18echo "== líneas completas (sin colores ANSI) =="
19grep -aE 'denegado' "$WORK/valet.log" | sed -E 's/\x1b\[[0-9;]*m//g'
20
99-parar.sh Eredeti
1#!/usr/bin/env bash
2# 99-parar.sh — detiene el servidor Valet de la demo.
3#
4# Uso: ./99-parar.sh
5set -euo pipefail
6
7WORK="${WORKDIR:-/tmp/opencode/valet-847}"
8
9if [[ -f "$WORK/valet.pid" ]]; then
10 PID="$(cat "$WORK/valet.pid")"
11 if kill "$PID" 2>/dev/null; then
12 echo "Servidor (PID $PID) detenido."
13 else
14 echo "El PID $PID ya no estaba en ejecución."
15 fi
16 rm -f "$WORK/valet.pid"
17else
18 echo "No hay $WORK/valet.pid; nada que parar."
19fi
20
21echo "La base de datos de prueba se deja en $WORK (es efímera; bórrala cuando quieras)."
22
README.md Eredeti

Ejemplos y demos — Episodio 847: «Integrando Valet»

Material de cabina del episodio 847 — «Integrando Valet», el cierre de la trilogía (843 memoria → 845 herramientas → 847 integración). El corazón es una demostración real y ejecutada de la API de ingesta de notificaciones de Valet contra una instancia local con una SQLite de usar y tirar.

Regla de oro: solo se pega lo que se ha ejecutado de verdad. Lo que no se pudo probar, se dice al final. Toda la salida cruda está en evidencia/. Ningún token real: los tokens se muestran como val_XXXXXXXX<…> (prefijo + máscara).


Arranque en dos pasos

./setup.sh          # prepara el directorio de trabajo efímero
./00-arranque.sh    # levanta el backend con una SQLite de usar y tirar

Luego las demos, en orden: 01 … 10. Para cerrar: ./99-parar.sh.

Condiciones de la demo (y por qué)

Decisión Motivo
DATABASE_URL=sqlite:/tmp/opencode/valet-847/valet-demo.db Base de datos de usar y tirar; nunca se toca una BD real.
AUTH_ENABLED=false Levanta la pila sin PocketID: toda /api/* opera como usuario dev, sin cookie de sesión.
Binario target/debug/valet El release está caducado y falla al migrar por vec0.
Ejecutar desde la raíz del repo Las migraciones se resuelven por ruta relativa.
Sin claves de LLM No se exigen: cae a Ollama y deja el RAG desactivado.

Piezas

00-arranque.sh — arranque local limpio

  • Qué demuestra: que Valet arranca sin credenciales de LLM y con la auth desactivada, sobre una SQLite efímera, y que /api/health responde.
  • Cómo se ejecuta: ./00-arranque.sh
  • Qué se espera ver: {"db":"connected","status":"ok","version":"0.15.0"} y en el log Valet server listening on 127.0.0.1:3458.
  • Evidencia: evidencia/00-arranque.txt

01-crear-clave.sh — crear una clave de ingesta (token revelado una vez)

  • Qué demuestra: POST /api/ingest-keys → 201 con {id, name, prefix, token}. El token se enmascara a prefijo + <…>.
  • Cómo se ejecuta: ./01-crear-clave.sh [nombre] (por defecto backup-host)
  • Qué se espera ver: 201 y un token de forma val_ + 43 chars base64url (47 totales); prefix = primeros 12 caracteres.
  • Evidencia: evidencia/01-crear-clave.txt

02-enviar-notificacion.sh — ingesta con Authorization: Bearer

  • Qué demuestra: 201 con {"id"}; sin cabecera y con token inventado, 401 con el mismo cuerpo ({"error":"unauthorized"}).
  • Cómo se ejecuta: ./02-enviar-notificacion.sh
  • Evidencia: evidencia/02-enviar-notificacion.txt

03-leer-buzon.sh — leer el buzón

  • Qué demuestra: GET /api/notifications/unread?limit=20 devuelve la notificación con metadata ya parseado.
  • Cómo se ejecuta: ./03-leer-buzon.sh
  • Evidencia: evidencia/03-leer-buzon.txt

04-marcar-leidas.sh — marcar como leídas (idempotente)

05-revocar.sh — revocar y comprobar el 401

  • Qué demuestra: DELETE → 204 (idempotente), token revocado → 401, y la clave sigue en el listado con revoked_at (no se borra).
  • Cómo se ejecuta: ./05-revocar.sh
  • Evidencia: evidencia/05-revocar.txt

06-casos-limite.sh — los límites que cita el guion

  • Qué demuestra: 409 (nombre duplicado), 400 (level con espacios, message vacío, metadata no objeto, message > 4096, ids null/[]), 415 (sin Content-Type) y 413 (cuerpo > 64 KiB).
  • Cómo se ejecuta: ./06-casos-limite.sh
  • Evidencia: evidencia/06-casos-limite.txt

07-webpush.sh — VAPID y suscripción

  • Qué demuestra: GET /api/push/vapid-key genera el par en la primera llamada y lo reutiliza; POST /api/push/subscriptions con un p256dh real de 65 bytes y un auth de 16 bytes → 201; upsert conserva el id.
  • Cómo se ejecuta: ./07-webpush.sh (necesita python3 + cryptography)
  • Evidencia: evidencia/07-webpush.txt

08-paginacion-keyset.sh — el next_cursor real

  • Qué demuestra: con limit=1 y 3 avisos, next_cursor no es null ("<created_at>|<rowid>"), y el cursor se encadena URL-encodeado.
  • Cómo se ejecuta: ./08-paginacion-keyset.sh
  • Evidencia: evidencia/08-paginacion-keyset.txt

09-trampa-doc-codigo.sh — la trampa de la documentación

  • Qué demuestra: (A) mark-read sin ids marca todas (la doc dice 400); (B) el buzón sí pagina (la doc dice next_cursor siempre null). Contrasta cada punto con su fichero y línea del repo.
  • Cómo se ejecuta: ./09-trampa-doc-codigo.sh
  • Evidencia: evidencia/09-trampa-doc-codigo.txt

10-logs.sh — el motivo real, solo en el log

  • Qué demuestra: que missing_authorization, malformed_authorization y unknown_or_revoked_key aparecen solo en el log, nunca en la respuesta.
  • Cómo se ejecuta: ./10-logs.sh
  • Evidencia: evidencia/10-logs-servidor.txt

Chuletas


Evidencia real (resultados clave)

Todo lo siguiente se ejecutó en este equipo el 22/10/2026 contra la instancia local descrita arriba. Detalle completo en evidencia/.

# Paso Resultado real
0 GET /api/health 200 {"db":"connected","status":"ok","version":"0.15.0"}
1 POST /api/ingest-keys backup-host 201, token val_83xGOtBB<…>, prefix=val_83xGOtBB
2 POST /api/notifications con el token 201 {"id":"65879b20-…"}
2b ingesta sin cabecera 401 {"error":"unauthorized"}
2c ingesta con token inventado 401 {"error":"unauthorized"} (idéntico)
2d cabecera mal formada (sin Bearer ) 401 {"error":"unauthorized"} (idéntico)
3 GET …/unread?limit=20 200, la notificación con metadata parseado
4 POST …/mark-read con el id 200 {"marked":1}; repetido → {"marked":0}
5 DELETE /api/ingest-keys/{id} 204; repetido → 204; token → 401
6a POST /api/ingest-keys nombre duplicado 409 {"error":"an ingest key with that name already exists"}
6b ingesta level:" info " 400 {"error":"level must be one of info, warning or critical"}
6c mark-read ids:null 400 {"error":"ids must not be null"}
6d mark-read ids:[] 400 {"error":"ids must not be empty"}
6e ingesta sin Content-Type 415
6f ingesta cuerpo > 64 KiB 413
6g metadata:[1,2] 400 {"error":"metadata must be a JSON object"}
6h message > 4096 bytes 400 {"error":"message must be at most 4096 bytes"}
7a GET /api/push/vapid-key (1ª y 2ª) 200, mismo public_key (par perezoso)
7b POST /api/push/subscriptions válida 201 {"id":"c2ceb43b-…"}; mismo endpoint → mismo id
7c endpoint http:// 400 {"error":"endpoint must use the https scheme"}
7d p256dh mal formado 400 {"error":"p256dh must decode to 65 bytes"}
8 unread?limit=1 con 3 avisos 200, next_cursor:"2026-10-22 19:03:44|4" (no null)
9A mark-read sin ids 200 {"marked":N} (en la última ejecución, 5) — marca todas (la doc dice 400)
9B doc vs código paginación doc: next_cursor siempre null · código: keyset real
10 log del servidor reason=missing_authorization / malformed_authorization / unknown_or_revoked_key

Dato de forma del token: val_ + 43 caracteres base64url = 47 caracteres (32 bytes sin relleno); prefix = 12 caracteres.

Confirmado en el listado: last_used_at se rellena tras una ingesta correcta; revoked_at se rellena al revocar y la fila no se borra.


Qué no se pudo probar (y por qué)

  1. La frontera de sesión OIDC no se ejercitó. La demo va con AUTH_ENABLED=false, así que todas las rutas /api/* operan como usuario dev y no se envió ninguna cookie valet_session. En consecuencia, este material no demuestra que unread, mark-read, ingest-keys y push exijan sesión: con la auth apagada, no la exigen. La afirmación del guion («quien lee el buzón eres tú, con sesión») se sostiene en el código (src/middleware/auth.rs:72-80, exención por par método+path), no en esta ejecución. No se levantó PocketID (es un proveedor externo, no parte del stack).
  2. No hubo entrega WebPush real. La suscripción de ejemplo apunta a https://push.example.test/… (dominio reservado para documentación). No se envió a un servicio de push real ni se abrió un navegador que recibiera el aviso. El despacho es best-effort y en segundo plano: aquí no se pudo observar su efecto.
  3. La generación concurrente del par VAPID no se reprodujo en vivo. El código la resuelve en una transacción con INSERT … ON CONFLICT … DO UPDATE (probado en el propio repo con una barrera). Aquí solo se verificó que la 2ª llamada devuelve el mismo par que la 1ª.
  4. El fallo del binario release (vec0) no se reprodujo. Se documenta desde la investigación y el guion; en esta demo se usó directamente el debug.
  5. No se probaron: rotación de claves (Valet no rota, solo revoca), expiración (no existe TTL), ni la baja de suscripción vía DELETE como comprobación de persistencia más allá del 204 (sí se ejecutó el 204).

Requisitos

curl, python3 y —solo para 07-webpush.sh— el módulo cryptography. Nada más: todo vive en el directorio de trabajo efímero y el servidor se para con ./99-parar.sh.

setup.sh Eredeti
1#!/usr/bin/env bash
2# setup.sh — prepara el directorio de trabajo efímero y deja las variables claras.
3#
4# ./setup.sh # usa /tmp/opencode/valet-847
5# WORKDIR=/otro/sitio ./setup.sh
6#
7# IMPORTANTE: la base de datos de la demo es de USAR Y TIRAR. Vive en
8# $WORKDIR/valet-demo.db y NUNCA apunta a una base de datos real del usuario.
9set -euo pipefail
10
11WORK="${WORKDIR:-/tmp/opencode/valet-847}"
12
13mkdir -p "$WORK"
14rm -f "$WORK"/valet-demo.db "$WORK"/valet-demo.db-shm "$WORK"/valet-demo.db-wal
15rm -f "$WORK"/token.txt "$WORK"/token2.txt "$WORK"/keyid.txt "$WORK"/key2id.txt "$WORK"/nid.txt
16
17echo "Directorio de trabajo: $WORK"
18echo "Base de datos (efímera): $WORK/valet-demo.db"
19echo
20echo "Siguiente paso: ./00-arranque.sh"
21