| 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. |
| 17 | set -euo pipefail |
| 18 | |
| 19 | REPO="${VALET_REPO:-/data/rust/valet}" |
| 20 | WORK="${WORKDIR:-/tmp/opencode/valet-847}" |
| 21 | HOST="${HOST:-127.0.0.1}" |
| 22 | PORT="${PORT:-3458}" |
| 23 | |
| 24 | mkdir -p "$WORK" |
| 25 | # SQLite limpia en cada arranque (borra también WAL/SHM). |
| 26 | rm -f "$WORK"/valet-demo.db "$WORK"/valet-demo.db-shm "$WORK"/valet-demo.db-wal |
| 27 | |
| 28 | cd "$REPO" |
| 29 | DATABASE_URL="sqlite:$WORK/valet-demo.db" \ |
| 30 | AUTH_ENABLED=false \ |
| 31 | HOST="$HOST" PORT="$PORT" \ |
| 32 | ./target/debug/valet > "$WORK/valet.log" 2>&1 & |
| 33 | echo $! > "$WORK/valet.pid" |
| 34 | |
| 35 | # Espera activa a que /api/health responda. |
| 36 | for _ 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 |
| 39 | done |
| 40 | |
| 41 | echo "== comando (sin secretos) ==" |
| 42 | echo "cd $REPO" |
| 43 | echo "DATABASE_URL=\"sqlite:$WORK/valet-demo.db\" AUTH_ENABLED=false HOST=$HOST PORT=$PORT ./target/debug/valet" |
| 44 | echo |
| 45 | echo "== health ==" |
| 46 | curl -s -w "\n[%{http_code}]\n" "http://$HOST:$PORT/api/health" |
| 47 | echo |
| 48 | echo "PID=$(cat "$WORK/valet.pid") · log: $WORK/valet.log" |
| 49 | echo "Para parar el servidor: ./99-parar.sh" |
| 50 |
| 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) |
| 9 | set -euo pipefail |
| 10 | |
| 11 | BASE="${VALET_BASE:-http://127.0.0.1:3458}" |
| 12 | WORK="${WORKDIR:-/tmp/opencode/valet-847}" |
| 13 | NAME="${1:-backup-host}" |
| 14 | |
| 15 | echo "== POST /api/ingest-keys name=$NAME ==" |
| 16 | # AUTH_ENABLED=false => no hace falta cookie de sesión (la API opera como `dev`). |
| 17 | RAW=$(curl -s -w $'\n%{http_code}' -X POST "$BASE/api/ingest-keys" \ |
| 18 | -H "Content-Type: application/json" \ |
| 19 | -d "{\"name\":\"$NAME\"}") |
| 20 | BODY=$(printf '%s' "$RAW" | head -n1) |
| 21 | CODE=$(printf '%s' "$RAW" | tail -n1) |
| 22 | |
| 23 | # Salida enmascarada: el token se recorta a su prefijo + <…>. |
| 24 | echo "$BODY" | python3 -c ' |
| 25 | import sys, json |
| 26 | d = json.load(sys.stdin) |
| 27 | out = {"id": d["id"], "name": d["name"], "prefix": d["prefix"], |
| 28 | "token": d["token"][:12] + "<…>"} |
| 29 | print(json.dumps(out, ensure_ascii=False)) |
| 30 | ' |
| 31 | echo "[$CODE]" |
| 32 | |
| 33 | # Estado para los pasos siguientes (efímero, nunca en la evidencia). |
| 34 | echo "$BODY" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])' > "$WORK/keyid.txt" |
| 35 | echo "$BODY" | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])' > "$WORK/token.txt" |
| 36 | echo "# token completo guardado en $WORK/token.txt (efímero, NO se imprime)" |
| 37 | echo "# máscara aplicada: token -> primeros 12 caracteres + <…>" |
| 38 |
| 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 |
| 10 | set -euo pipefail |
| 11 | |
| 12 | BASE="${VALET_BASE:-http://127.0.0.1:3458}" |
| 13 | WORK="${WORKDIR:-/tmp/opencode/valet-847}" |
| 14 | TOKEN="$(cat "$WORK/token.txt")" |
| 15 | |
| 16 | echo "== POST /api/notifications (Authorization: Bearer val_XXXXXXXX<…>) ==" |
| 17 | RAW=$(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}}') |
| 21 | BODY=$(printf '%s' "$RAW" | head -n1) |
| 22 | CODE=$(printf '%s' "$RAW" | tail -n1) |
| 23 | echo "$BODY" |
| 24 | echo "[$CODE]" |
| 25 | # Guarda el id de la notificación para los pasos siguientes. |
| 26 | printf '%s' "$BODY" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])' > "$WORK/nid.txt" |
| 27 | |
| 28 | echo |
| 29 | echo "== sin cabecera Authorization -> 401 ==" |
| 30 | curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications" \ |
| 31 | -H "Content-Type: application/json" -d '{"message":"x"}' |
| 32 | |
| 33 | echo |
| 34 | echo "== Bearer con token inventado -> 401 (mismo cuerpo) ==" |
| 35 | curl -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 | |
| 39 | echo |
| 40 | echo "== Authorization mal formada (sin 'Bearer ') -> 401 ==" |
| 41 | curl -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 |
| 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 |
| 8 | set -euo pipefail |
| 9 | |
| 10 | BASE="${VALET_BASE:-http://127.0.0.1:3458}" |
| 11 | WORK="${WORKDIR:-/tmp/opencode/valet-847}" |
| 12 | |
| 13 | echo "== GET /api/notifications/unread?limit=20 ==" |
| 14 | curl -s -w "\n[%{http_code}]\n" "$BASE/api/notifications/unread?limit=20" |
| 15 |
| 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 |
| 7 | set -euo pipefail |
| 8 | |
| 9 | BASE="${VALET_BASE:-http://127.0.0.1:3458}" |
| 10 | WORK="${WORKDIR:-/tmp/opencode/valet-847}" |
| 11 | NID="$(cat "$WORK/nid.txt")" |
| 12 | |
| 13 | echo "== POST /api/notifications/mark-read ids=[$NID] ==" |
| 14 | curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications/mark-read" \ |
| 15 | -H "Content-Type: application/json" \ |
| 16 | -d "{\"ids\":[\"$NID\"]}" |
| 17 | |
| 18 | echo |
| 19 | echo "== repetido (idempotente) -> 0 ==" |
| 20 | curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications/mark-read" \ |
| 21 | -H "Content-Type: application/json" \ |
| 22 | -d "{\"ids\":[\"$NID\"]}" |
| 23 |
| 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 |
| 11 | set -euo pipefail |
| 12 | |
| 13 | BASE="${VALET_BASE:-http://127.0.0.1:3458}" |
| 14 | WORK="${WORKDIR:-/tmp/opencode/valet-847}" |
| 15 | KEYID="$(cat "$WORK/keyid.txt")" |
| 16 | TOKEN="$(cat "$WORK/token.txt")" |
| 17 | |
| 18 | echo "== DELETE /api/ingest-keys/$KEYID ==" |
| 19 | curl -s -w "\n[%{http_code}]\n" -X DELETE "$BASE/api/ingest-keys/$KEYID" |
| 20 | |
| 21 | echo |
| 22 | echo "== DELETE otra vez (idempotente) -> 204 ==" |
| 23 | curl -s -w "\n[%{http_code}]\n" -X DELETE "$BASE/api/ingest-keys/$KEYID" |
| 24 | |
| 25 | echo |
| 26 | echo "== reenviar con el token revocado -> 401 ==" |
| 27 | curl -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 | |
| 31 | echo |
| 32 | echo "== GET /api/ingest-keys (revoked_at relleno; la fila NO se borra) ==" |
| 33 | curl -s -w "\n[%{http_code}]\n" "$BASE/api/ingest-keys" |
| 34 |
| 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 |
| 14 | set -euo pipefail |
| 15 | |
| 16 | BASE="${VALET_BASE:-http://127.0.0.1:3458}" |
| 17 | WORK="${WORKDIR:-/tmp/opencode/valet-847}" |
| 18 | |
| 19 | echo "== 409: nombre duplicado 'backup-host' ==" |
| 20 | curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/ingest-keys" \ |
| 21 | -H "Content-Type: application/json" -d '{"name":"backup-host"}' |
| 22 | |
| 23 | echo |
| 24 | echo "== crear una clave nueva 'monitor' para los límites ==" |
| 25 | RAW=$(curl -s -w $'\n%{http_code}' -X POST "$BASE/api/ingest-keys" \ |
| 26 | -H "Content-Type: application/json" -d '{"name":"monitor"}') |
| 27 | printf '%s' "$RAW" | head -n1 | python3 -c ' |
| 28 | import sys, json |
| 29 | d = json.load(sys.stdin) |
| 30 | print(json.dumps({"id": d["id"], "name": d["name"], "prefix": d["prefix"], |
| 31 | "token": d["token"][:12] + "<…>"}, ensure_ascii=False)) |
| 32 | ' |
| 33 | echo "[$(printf '%s' "$RAW" | tail -n1)]" |
| 34 | printf '%s' "$RAW" | head -n1 | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])' > "$WORK/token2.txt" |
| 35 | T2="$(cat "$WORK/token2.txt")" |
| 36 | |
| 37 | echo |
| 38 | echo "== 400: level con espacios (\" info \") ==" |
| 39 | curl -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 | |
| 43 | echo |
| 44 | echo "== 400: message vacío tras trim ==" |
| 45 | curl -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 | |
| 49 | echo |
| 50 | echo "== 415: sin Content-Type ==" |
| 51 | curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications" \ |
| 52 | -H "Authorization: Bearer $T2" -d '{"message":"x"}' |
| 53 | |
| 54 | echo |
| 55 | echo "== 400: mark-read con ids:null ==" |
| 56 | curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications/mark-read" \ |
| 57 | -H "Content-Type: application/json" -d '{"ids":null}' |
| 58 | |
| 59 | echo |
| 60 | echo "== 400: mark-read con ids:[] ==" |
| 61 | curl -s -w "\n[%{http_code}]\n" -X POST "$BASE/api/notifications/mark-read" \ |
| 62 | -H "Content-Type: application/json" -d '{"ids":[]}' |
| 63 | |
| 64 | echo |
| 65 | echo "== 400: unread limit=999 (fuera de [1,200]) ==" |
| 66 | curl -s -w "\n[%{http_code}]\n" "$BASE/api/notifications/unread?limit=999" |
| 67 | |
| 68 | echo |
| 69 | echo "== 400: metadata no objeto (array) ==" |
| 70 | curl -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 | |
| 74 | echo |
| 75 | echo "== 400: message > 4096 bytes ==" |
| 76 | python3 -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 | |
| 80 | echo |
| 81 | echo "== 413: cuerpo > 64 KiB ==" |
| 82 | python3 -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 |
| 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 |
| 16 | set -euo pipefail |
| 17 | |
| 18 | BASE="${VALET_BASE:-http://127.0.0.1:3458}" |
| 19 | WORK="${WORKDIR:-/tmp/opencode/valet-847}" |
| 20 | EP="https://push.example.test/demo-endpoint-847" |
| 21 | |
| 22 | # --- Genera una p256dh (65 bytes) y un auth (16 bytes) reales --- |
| 23 | eval "$(python3 - <<'PY' |
| 24 | import base64, os |
| 25 | from cryptography.hazmat.primitives.asymmetric import ec |
| 26 | from cryptography.hazmat.primitives import serialization |
| 27 | priv = ec.generate_private_key(ec.SECP256R1()) |
| 28 | pub = priv.public_key().public_bytes(serialization.Encoding.X962, |
| 29 | serialization.PublicFormat.UncompressedPoint) # 0x04 || X || Y = 65 bytes |
| 30 | assert len(pub) == 65, len(pub) |
| 31 | b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=').decode() |
| 32 | print("P256DH=%s" % b64(pub)) |
| 33 | print("AUTH=%s" % b64(os.urandom(16))) |
| 34 | PY |
| 35 | )" |
| 36 | |
| 37 | echo "== GET /api/push/vapid-key (1ª llamada: genera el par) ==" |
| 38 | curl -s -w "\n[%{http_code}]\n" "$BASE/api/push/vapid-key" |
| 39 | |
| 40 | echo |
| 41 | echo "== GET /api/push/vapid-key (2ª llamada: mismo par) ==" |
| 42 | curl -s -w "\n[%{http_code}]\n" "$BASE/api/push/vapid-key" |
| 43 | |
| 44 | echo |
| 45 | echo "== POST /api/push/subscriptions (suscripción válida) ==" |
| 46 | curl -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 | |
| 50 | echo |
| 51 | echo "== repetir la misma endpoint: upsert conserva el id ==" |
| 52 | curl -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 | |
| 56 | echo |
| 57 | echo "== 400: endpoint http:// ==" |
| 58 | curl -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 | |
| 62 | echo |
| 63 | echo "== 400: p256dh mal formado ==" |
| 64 | curl -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 |
| 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 |
| 11 | set -euo pipefail |
| 12 | |
| 13 | BASE="${VALET_BASE:-http://127.0.0.1:3458}" |
| 14 | WORK="${WORKDIR:-/tmp/opencode/valet-847}" |
| 15 | T2="$(cat "$WORK/token2.txt")" |
| 16 | |
| 17 | for 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 |
| 21 | done |
| 22 | |
| 23 | echo "== GET /api/notifications/unread?limit=1 ==" |
| 24 | P1=$(curl -s "$BASE/api/notifications/unread?limit=1") |
| 25 | echo "$P1" |
| 26 | CUR=$(printf '%s' "$P1" | python3 -c 'import sys,json;print(json.load(sys.stdin)["next_cursor"] or "")') |
| 27 | echo |
| 28 | echo "-- next_cursor = $CUR (¡NO es null!)" |
| 29 | echo |
| 30 | echo "== siguiente página con el cursor (URL-encoded) ==" |
| 31 | curl -s -G -w "\n[%{http_code}]\n" "$BASE/api/notifications/unread" \ |
| 32 | --data-urlencode "limit=1" --data-urlencode "cursor=$CUR" |
| 33 |
| 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 |
| 13 | set -euo pipefail |
| 14 | |
| 15 | BASE="${VALET_BASE:-http://127.0.0.1:3458}" |
| 16 | REPO="${VALET_REPO:-/data/rust/valet}" |
| 17 | WORK="${WORKDIR:-/tmp/opencode/valet-847}" |
| 18 | T2="$(cat "$WORK/token2.txt")" |
| 19 | |
| 20 | echo "=== (A) mark-read SIN ids: lo que dice la doc y lo que hace el código ===" |
| 21 | echo |
| 22 | echo "-- DOC docs/api-notifications.md:191-193" |
| 23 | sed -n '191,193p' "$REPO/docs/api-notifications.md" | sed 's/^/ /' |
| 24 | echo |
| 25 | echo "-- CÓDIGO src/routes/notifications.rs:386-393" |
| 26 | sed -n '386,393p' "$REPO/src/routes/notifications.rs" | sed 's/^/ /' |
| 27 | echo |
| 28 | echo "-- EJECUCIÓN REAL (ids ausente = cuerpo {}):" |
| 29 | # Primero crea un par de no leídas para que el marcado masivo tenga efecto. |
| 30 | for 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 |
| 34 | done |
| 35 | curl -s -w "\n [%{http_code}]\n" -X POST "$BASE/api/notifications/mark-read" \ |
| 36 | -H "Content-Type: application/json" -d '{}' |
| 37 | echo " ^ marca TODAS, no un 400." |
| 38 | |
| 39 | echo |
| 40 | echo |
| 41 | echo "=== (B) paginación del buzón: doc vs spec vs código ===" |
| 42 | echo |
| 43 | echo "-- DOC docs/api-notifications.md:155-156" |
| 44 | sed -n '155,156p' "$REPO/docs/api-notifications.md" | sed 's/^/ /' |
| 45 | echo |
| 46 | echo "-- SPEC openspec/specs/notifications/spec.md:174-177" |
| 47 | sed -n '174,177p' "$REPO/openspec/specs/notifications/spec.md" | sed 's/^/ /' |
| 48 | echo |
| 49 | echo "-- CÓDIGO src/routes/notifications.rs:293-299" |
| 50 | sed -n '293,299p' "$REPO/src/routes/notifications.rs" | sed 's/^/ /' |
| 51 | echo |
| 52 | echo "-- EJECUCIÓN REAL: unread?limit=1 devuelve next_cursor no nulo (ver 08)." |
| 53 |
| 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 |
| 9 | set -euo pipefail |
| 10 | |
| 11 | WORK="${WORKDIR:-/tmp/opencode/valet-847}" |
| 12 | |
| 13 | echo "== motivos de rechazo registrados en $WORK/valet.log ==" |
| 14 | grep -aE 'denegado' "$WORK/valet.log" \ |
| 15 | | sed -E 's/.*reason[^"]*"([^"]+)".*/ reason=\1/' \ |
| 16 | | sort | uniq -c |
| 17 | echo |
| 18 | echo "== líneas completas (sin colores ANSI) ==" |
| 19 | grep -aE 'denegado' "$WORK/valet.log" | sed -E 's/\x1b\[[0-9;]*m//g' |
| 20 |
| 1 | #!/usr/bin/env bash |
| 2 | # 99-parar.sh — detiene el servidor Valet de la demo. |
| 3 | # |
| 4 | # Uso: ./99-parar.sh |
| 5 | set -euo pipefail |
| 6 | |
| 7 | WORK="${WORKDIR:-/tmp/opencode/valet-847}" |
| 8 | |
| 9 | if [[ -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" |
| 17 | else |
| 18 | echo "No hay $WORK/valet.pid; nada que parar." |
| 19 | fi |
| 20 | |
| 21 | echo "La base de datos de prueba se deja en $WORK (es efímera; bórrala cuando quieras)." |
| 22 |
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.
- Guion:
../guion-0847.md - Investigación:
../investigacion-0847.md - Repositorio (fuente de verdad):
/data/rust/valet(público, MIT:github.com/atareao/valet-ai)
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 comoval_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/healthresponde. - Cómo se ejecuta:
./00-arranque.sh - Qué se espera ver:
{"db":"connected","status":"ok","version":"0.15.0"}y en el logValet 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→201con{id, name, prefix, token}. Eltokense enmascara a prefijo +<…>. - Cómo se ejecuta:
./01-crear-clave.sh [nombre](por defectobackup-host) - Qué se espera ver:
201y un token de formaval_+ 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:
201con{"id"}; sin cabecera y con token inventado,401con 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=20devuelve la notificación conmetadataya parseado. - Cómo se ejecuta:
./03-leer-buzon.sh - Evidencia:
evidencia/03-leer-buzon.txt
04-marcar-leidas.sh — marcar como leídas (idempotente)
- Qué demuestra:
{"marked":1}y, al repetir,{"marked":0}. - Cómo se ejecuta:
./04-marcar-leidas.sh - Evidencia:
evidencia/04-marcar-leidas.txt
05-revocar.sh — revocar y comprobar el 401
- Qué demuestra:
DELETE→204(idempotente), token revocado →401, y la clave sigue en el listado conrevoked_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(levelcon espacios,messagevacío,metadatano objeto,message> 4096,idsnull/[]),415(sinContent-Type) y413(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-keygenera el par en la primera llamada y lo reutiliza;POST /api/push/subscriptionscon unp256dhreal de 65 bytes y unauthde 16 bytes →201; upsert conserva elid. - Cómo se ejecuta:
./07-webpush.sh(necesitapython3+cryptography) - Evidencia:
evidencia/07-webpush.txt
08-paginacion-keyset.sh — el next_cursor real
- Qué demuestra: con
limit=1y 3 avisos,next_cursorno 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-readsinidsmarca todas (la doc dice400); (B) el buzón sí pagina (la doc dicenext_cursorsiemprenull). 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_authorizationyunknown_or_revoked_keyaparecen solo en el log, nunca en la respuesta. - Cómo se ejecuta:
./10-logs.sh - Evidencia:
evidencia/10-logs-servidor.txt
Chuletas
curl/README.md— las peticiones en el orden de la demo, con<token>donde iría el token.resumen-comandos.md— resumen de una línea por comando.
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é)
- La frontera de sesión OIDC no se ejercitó. La demo va con
AUTH_ENABLED=false, así que todas las rutas/api/*operan como usuariodevy no se envió ninguna cookievalet_session. En consecuencia, este material no demuestra queunread,mark-read,ingest-keysypushexijan 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). - 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. - 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ª. - El fallo del binario
release(vec0) no se reprodujo. Se documenta desde la investigación y el guion; en esta demo se usó directamente eldebug. - 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
DELETEcomo comprobación de persistencia más allá del204(sí se ejecutó el204).
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.
| 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. |
| 9 | set -euo pipefail |
| 10 | |
| 11 | WORK="${WORKDIR:-/tmp/opencode/valet-847}" |
| 12 | |
| 13 | mkdir -p "$WORK" |
| 14 | rm -f "$WORK"/valet-demo.db "$WORK"/valet-demo.db-shm "$WORK"/valet-demo.db-wal |
| 15 | rm -f "$WORK"/token.txt "$WORK"/token2.txt "$WORK"/keyid.txt "$WORK"/key2id.txt "$WORK"/nid.txt |
| 16 | |
| 17 | echo "Directorio de trabajo: $WORK" |
| 18 | echo "Base de datos (efímera): $WORK/valet-demo.db" |
| 19 | echo |
| 20 | echo "Siguiente paso: ./00-arranque.sh" |
| 21 |