diff --git a/bash/functions/infra/mas_client_register.md b/bash/functions/infra/mas_client_register.md new file mode 100644 index 00000000..12385895 --- /dev/null +++ b/bash/functions/infra/mas_client_register.md @@ -0,0 +1,89 @@ +--- +name: mas_client_register +kind: function +lang: bash +domain: infra +version: "0.1.0" +purity: impure +signature: "mas_client_register(ssh_host: string, container: string, config_file: string, dry_run: bool) -> json" +description: "Registra y sincroniza clientes OAuth en Matrix Authentication Service (MAS) ejecutando mas-cli config sync dentro del container Docker remoto via SSH. Verifica sintaxis YAML, soporte dry-run para ver diff antes de aplicar, y emite JSON estructurado con resultado. Idempotente: re-ejecucion con misma config no genera cambios." +tags: [matrix, mas, oauth, oidc, migration, mas-migration, infra, docker, ssh, matrix-mas] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: ssh_host + desc: "alias SSH del VPS donde corre MAS (ej. organic-machine.com). Debe estar en ~/.ssh/config con key auth." + - name: container + desc: "nombre del container Docker con MAS (ej. element_matrix_chat-mas-1). El config dentro del container se espera en /data/config.yaml." + - name: config_file + desc: "ruta absoluta en el VPS al archivo mas/config.yaml (ej. /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml). MAS lo monta como /data/config.yaml." + - name: dry_run + desc: "flag opcional --dry-run: ejecuta mas-cli config dump y devuelve el estado sin aplicar cambios. Util para verificar antes de activar MSC3861." +output: "JSON con: status ('ok'|'dry-run'|'error'), applied (bool), clients_total (int), clients_diff (array de lineas del output de mas-cli), stderr (string con logs de error si aplica)." +tested: true +tests: + - "help flag emite JSON parseable" + - "args faltantes retornan JSON de error sin ssh" + - "jq disponible en host local" +test_file_path: "bash/functions/infra/mas_client_register_test.sh" +file_path: "bash/functions/infra/mas_client_register.sh" +--- + +## Ejemplo + +```bash +# Dry-run: verificar que clients se aplicarian correctamente +source bash/functions/infra/mas_client_register.sh + +mas_client_register \ + --ssh-host organic-machine.com \ + --container element_matrix_chat-mas-1 \ + --config-file /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml \ + --dry-run + +# Aplicar sync real (con --prune para eliminar clients viejos) +mas_client_register \ + --ssh-host organic-machine.com \ + --container element_matrix_chat-mas-1 \ + --config-file /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml +``` + +Salida esperada (sync OK): +```json +{ + "status": "ok", + "applied": true, + "clients_total": 6, + "clients_diff": ["synced client element-web", "synced client synapse-admin", "..."], + "stderr": "" +} +``` + +Salida dry-run: +```json +{ + "status": "dry-run", + "applied": false, + "clients_total": 42, + "clients_diff": ["clients:", " - client_id: element-web", " ..."], + "stderr": "" +} +``` + +## Cuando usarla + +Usar despues de editar `mas/config.yaml` localmente y antes de hacer restart a Synapse con `msc3861` habilitado en `homeserver.yaml`. Ejecutar primero con `--dry-run` para verificar que los 6 clients OAuth (Element Web, Synapse-Admin, matrix_client_pc, matrix_client_android, matrix_admin_panel, Synapse-internal) estan correctamente definidos, luego sin `--dry-run` para aplicar el sync. + +## Gotchas + +- **`--prune` elimina clients no declarados en config**: el sync real usa `--prune`, lo que borra cualquier client OAuth que exista en MAS pero no este en el `config.yaml`. Verificar con `--dry-run` antes de aplicar en produccion. +- **Requiere `jq` en el host local**: el JSON output se construye con `jq`. Si no esta instalado, la funcion falla con error claro antes de conectar al VPS. +- **`mas-cli` debe estar en el container**: la funcion asume que `mas-cli` esta en el PATH dentro del container MAS. Si el container usa una imagen diferente, verificar con `docker exec mas-cli --version`. +- **Config dentro del container siempre en `/data/config.yaml`**: el `--config-file` apunta a la ruta en el VPS (para que el operador sepa que archivo editar), pero el comando dentro del container usa `/data/config.yaml` (el mount point estandar de MAS). Si el compose monta el archivo en otro path, ajustar la constante `container_config` en el script. +- **SSH key debe estar en agent o `~/.ssh/config`**: la funcion usa `ssh ` directamente. Si la key requiere passphrase, ejecutar `ssh-add` antes. +- **Si `config.yaml` es invalido, sync aborta sin tocar estado**: el paso 1 (`mas-cli config check`) detecta errores de sintaxis YAML antes de intentar sync. El estado de MAS no se modifica si la config tiene errores. +- **Idempotente**: re-ejecutar con la misma config no genera cambios en MAS (mas-cli detecta que el estado ya coincide). diff --git a/bash/functions/infra/mas_client_register.sh b/bash/functions/infra/mas_client_register.sh new file mode 100644 index 00000000..4b98cebd --- /dev/null +++ b/bash/functions/infra/mas_client_register.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# mas_client_register — Registra/sincroniza clientes OAuth en Matrix Authentication Service (MAS) +# via mas-cli config sync ejecutado en container Docker remoto a traves de SSH. +set -euo pipefail + +mas_client_register() { + local ssh_host="" + local container="" + local config_file="" + local dry_run=false + + # Parse args + while [[ $# -gt 0 ]]; do + case "$1" in + --ssh-host) + ssh_host="$2" + shift 2 + ;; + --container) + container="$2" + shift 2 + ;; + --config-file) + config_file="$2" + shift 2 + ;; + --dry-run) + dry_run=true + shift + ;; + --help|-h) + cat >&2 <<'USAGE' +mas_client_register - Sincroniza clientes OAuth en MAS via mas-cli config sync + +Usage: + mas_client_register --ssh-host --container --config-file [--dry-run] + +Options: + --ssh-host Alias SSH del VPS (ej. organic-machine.com) + --container Nombre del container MAS (ej. element_matrix_chat-mas-1) + --config-file Ruta en el VPS al mas/config.yaml (ej. /home/ubuntu/project/mas/config.yaml) + --dry-run Solo valida config y muestra diff, sin aplicar cambios + +Output: JSON en stdout con status, applied, clients_total, clients_diff, stderr +USAGE + # emit minimal valid JSON so callers that parse stdout don't break + echo '{"status":"help","applied":false,"clients_total":0,"clients_diff":[],"stderr":""}' + return 0 + ;; + *) + echo "mas_client_register: argumento desconocido: $1" >&2 + return 1 + ;; + esac + done + + # Validar argumentos obligatorios + local errors=() + [[ -z "$ssh_host" ]] && errors+=("--ssh-host es obligatorio") + [[ -z "$container" ]] && errors+=("--container es obligatorio") + [[ -z "$config_file" ]] && errors+=("--config-file es obligatorio") + + if [[ ${#errors[@]} -gt 0 ]]; then + for err in "${errors[@]}"; do + echo "ERROR: $err" >&2 + done + echo '{"status":"error","applied":false,"clients_total":0,"clients_diff":[],"stderr":"missing required arguments"}' + return 1 + fi + + # Verificar dependencias locales + if ! command -v jq &>/dev/null; then + echo "ERROR: jq no encontrado en el host local. Instalar: apt install jq / brew install jq" >&2 + echo '{"status":"error","applied":false,"clients_total":0,"clients_diff":[],"stderr":"jq not found on local host"}' + return 1 + fi + + echo "mas_client_register: ssh-host=$ssh_host container=$container dry-run=$dry_run" >&2 + + # La ruta de config dentro del container siempre es /data/config.yaml (mount convention de MAS) + local container_config="/data/config.yaml" + + # ---- PASO 1: Verificar sintaxis YAML con mas-cli config check ---- + echo "mas_client_register: verificando sintaxis de config con mas-cli config check..." >&2 + local check_stdout check_stderr check_exit + check_stdout=$(ssh "$ssh_host" \ + "docker exec ${container} mas-cli config check --config ${container_config}" 2>/tmp/mas_check_stderr_$$ || true) + check_exit=$? + check_stderr=$(cat /tmp/mas_check_stderr_$$ 2>/dev/null || true) + rm -f /tmp/mas_check_stderr_$$ + + if [[ $check_exit -ne 0 ]]; then + echo "mas_client_register: config check falló (exit=$check_exit)" >&2 + echo "$check_stderr" >&2 + local escaped_stderr + escaped_stderr=$(printf '%s' "${check_stderr}" | jq -Rs '.') + echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}" + return 1 + fi + + echo "mas_client_register: config check OK" >&2 + + # ---- PASO 2: dry-run o sync ---- + if [[ "$dry_run" == "true" ]]; then + # Ejecutar mas-cli config dump para mostrar el estado actual y lo que se aplicaria + echo "mas_client_register: modo dry-run — ejecutando mas-cli config dump..." >&2 + local dump_stdout dump_stderr dump_exit + dump_stdout=$(ssh "$ssh_host" \ + "docker exec ${container} mas-cli config dump --config ${container_config}" 2>/tmp/mas_dump_stderr_$$ || true) + dump_exit=$? + dump_stderr=$(cat /tmp/mas_dump_stderr_$$ 2>/dev/null || true) + rm -f /tmp/mas_dump_stderr_$$ + + if [[ $dump_exit -ne 0 ]]; then + echo "mas_client_register: config dump falló (exit=$dump_exit)" >&2 + echo "$dump_stderr" >&2 + local escaped_stderr + escaped_stderr=$(printf '%s' "${dump_stderr}" | jq -Rs '.') + echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}" + return 1 + fi + + # Extraer listado de clients del dump (buscar lineas con client_id o type: client) + local clients_diff_raw + clients_diff_raw=$(printf '%s\n' "$dump_stdout" | grep -E "client_id:|client_name:" | \ + sed 's/^[[:space:]]*//' | head -50 || true) + + local diff_json + diff_json=$(printf '%s\n' "$dump_stdout" | jq -Rs 'split("\n") | map(select(length > 0)) | map(ltrimstr(" "))' 2>/dev/null \ + || echo '["(jq parse error — ver stderr)"]') + + local escaped_dump_stderr + escaped_dump_stderr=$(printf '%s' "${dump_stderr}" | jq -Rs '.') + + echo "mas_client_register: dry-run completado. dump lines=$(echo "$dump_stdout" | wc -l)" >&2 + + jq -n \ + --argjson diff "$diff_json" \ + --argjson stderr_str "$escaped_dump_stderr" \ + '{ + status: "dry-run", + applied: false, + clients_total: ($diff | length), + clients_diff: $diff, + stderr: $stderr_str + }' + return 0 + fi + + # ---- PASO 3: sync real ---- + echo "mas_client_register: ejecutando mas-cli config sync --prune..." >&2 + local sync_stdout sync_stderr sync_exit + sync_stdout=$(ssh "$ssh_host" \ + "docker exec ${container} mas-cli config sync --config ${container_config} --prune" \ + 2>/tmp/mas_sync_stderr_$$ || true) + sync_exit=$? + sync_stderr=$(cat /tmp/mas_sync_stderr_$$ 2>/dev/null || true) + rm -f /tmp/mas_sync_stderr_$$ + + echo "mas_client_register: sync exit=$sync_exit" >&2 + if [[ -n "$sync_stderr" ]]; then + echo "mas_client_register stderr: $sync_stderr" >&2 + fi + + if [[ $sync_exit -ne 0 ]]; then + local escaped_stderr + escaped_stderr=$(printf '%s' "${sync_stderr}" | jq -Rs '.') + echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}" + return 1 + fi + + # Parsear output del sync para extraer lineas con cambios aplicados + local diff_lines + diff_lines=$(printf '%s\n' "$sync_stdout" | grep -E "^\s*(created|updated|deleted|unchanged|synced)" || true) + + local diff_json + diff_json=$(printf '%s\n' "$sync_stdout" | jq -Rs 'split("\n") | map(select(length > 0))' 2>/dev/null \ + || echo '[]') + + local clients_count + clients_count=$(printf '%s\n' "$sync_stdout" | grep -cE "client" 2>/dev/null || echo 0) + + local escaped_sync_stderr + escaped_sync_stderr=$(printf '%s' "${sync_stderr}" | jq -Rs '.') + + echo "mas_client_register: sync completado con exito" >&2 + + jq -n \ + --argjson diff "$diff_json" \ + --argjson total "$clients_count" \ + --argjson stderr_str "$escaped_sync_stderr" \ + '{ + status: "ok", + applied: true, + clients_total: $total, + clients_diff: $diff, + stderr: $stderr_str + }' +} + +# Ejecutar si se llama directamente (no sourced) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + mas_client_register "$@" +fi diff --git a/bash/functions/infra/mas_client_register_test.sh b/bash/functions/infra/mas_client_register_test.sh new file mode 100644 index 00000000..51cb00de --- /dev/null +++ b/bash/functions/infra/mas_client_register_test.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Tests para mas_client_register +# No requiere SSH real — prueba paths locales (arg validation, --help, JSON output) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PASS=0 +FAIL=0 + +assert_contains() { + local test_name="$1" needle="$2" haystack="$3" + if echo "$haystack" | grep -qF "$needle"; then + echo "PASS: $test_name" + ((PASS++)) + else + echo "FAIL: $test_name — expected to contain '$needle', got: $haystack" + ((FAIL++)) + fi +} + +assert_json_parseable() { + local test_name="$1" json="$2" + if command -v jq &>/dev/null; then + if echo "$json" | jq . >/dev/null 2>&1; then + echo "PASS: $test_name" + ((PASS++)) + else + echo "FAIL: $test_name — output no es JSON valido: $json" + ((FAIL++)) + fi + else + if [[ "$json" == \{* ]]; then + echo "PASS: $test_name (jq no disponible, verificacion basica OK)" + ((PASS++)) + else + echo "FAIL: $test_name — output no parece JSON: $json" + ((FAIL++)) + fi + fi +} + +# Test: help flag emite JSON parseable +# Cada invocacion en subshell aislada para no contaminar el runner con set -e del script fuente +bash "$SCRIPT_DIR/mas_client_register.sh" --help >/tmp/mas_test_help_$$ 2>/dev/null || true +output_help=$(cat /tmp/mas_test_help_$$ 2>/dev/null || true) +rm -f /tmp/mas_test_help_$$ +assert_json_parseable "help flag emite JSON parseable" "$output_help" + +# Test: args faltantes retornan JSON de error sin ssh +bash "$SCRIPT_DIR/mas_client_register.sh" >/tmp/mas_test_noargs_$$ 2>/dev/null || true +output_noargs=$(cat /tmp/mas_test_noargs_$$ 2>/dev/null || true) +rm -f /tmp/mas_test_noargs_$$ +assert_json_parseable "args faltantes retornan JSON de error sin ssh" "$output_noargs" +assert_contains "args faltantes contienen status error" '"status":"error"' "$output_noargs" + +# Test: jq disponible en host local +if command -v jq &>/dev/null; then + echo "PASS: jq disponible en host local" + ((PASS++)) +else + echo "FAIL: jq disponible en host local — instalar: apt install jq" + ((FAIL++)) +fi + +echo "---" +echo "Results: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]] || exit 1 diff --git a/bash/functions/infra/mas_syn2mas_migration.md b/bash/functions/infra/mas_syn2mas_migration.md new file mode 100644 index 00000000..c8b3f3b4 --- /dev/null +++ b/bash/functions/infra/mas_syn2mas_migration.md @@ -0,0 +1,83 @@ +--- +name: mas_syn2mas_migration +kind: function +lang: bash +domain: infra +version: "0.1.0" +purity: impure +signature: "mas_syn2mas_migration --ssh-host --mas-container --synapse-config-path --log-dir [--max-conflicts N] [--apply]" +description: "Migra usuarios Synapse a Matrix Authentication Service (MAS) via mas-cli syn2mas. Fuerza dry-run primero, archiva el log, aborta si los conflicts superan el threshold, y solo ejecuta la migracion real con --apply." +tags: [matrix, mas, syn2mas, migration, mas-migration, infra, users, docker, ssh, matrix-mas] +params: + - name: ssh-host + desc: "Alias SSH del VPS donde corren los containers (ej. organic-machine.com)" + - name: mas-container + desc: "Nombre del container Docker de MAS (ej. element_matrix_chat-mas-1)" + - name: synapse-config-path + desc: "Ruta en el VPS al homeserver.yaml de Synapse (ej. /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml). El container debe tener el archivo accesible en /data/homeserver.yaml via volume mount." + - name: log-dir + desc: "Directorio local donde archivar logs dry-run y apply. Se crea con chmod 0700 y los logs con 0600 (contienen userIDs)." + - name: max-conflicts + desc: "Tope de conflictos detectados en dry-run. Si conflicts > max-conflicts, status=aborted exit 2. Default 0 (abortar ante cualquier conflict)." + - name: apply + desc: "Flag booleano. Sin --apply: solo dry-run (status=ok, sin cambios). Con --apply: ejecuta la migracion real tras pasar el threshold." +output: "JSON en stdout: {\"status\":\"ok|aborted|error\",\"dry_run_log\":\"path\",\"apply_log\":\"path|null\",\"conflicts\":N,\"users_migrated\":N,\"duration_s\":N}. Exit 0=ok, 1=error, 2=aborted por conflicts." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: true +tests: + - "aborta con error cuando faltan args obligatorios" + - "help no devuelve error" + - "argumento desconocido retorna exit 1" + - "max-conflicts invalido retorna exit 1" +test_file_path: "bash/functions/infra/mas_syn2mas_migration_test.sh" +file_path: "bash/functions/infra/mas_syn2mas_migration.sh" +--- + +## Ejemplo + +```bash +# Paso 1: dry-run OBLIGATORIO (sin --apply — no modifica nada) +mas_syn2mas_migration \ + --ssh-host organic-machine.com \ + --mas-container element_matrix_chat-mas-1 \ + --synapse-config-path /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml \ + --log-dir ~/matrix_migration_logs \ + --max-conflicts 0 + +# Salida esperada (si hay 0 conflicts): +# {"status":"ok","dry_run_log":"/home/lucas/matrix_migration_logs/syn2mas_dryrun_1234567890.log","apply_log":null,"conflicts":0,"users_migrated":0,"duration_s":0} + +# Revisar el log antes de continuar: +# cat ~/matrix_migration_logs/syn2mas_dryrun_*.log + +# Paso 2: tras revisar el log dry-run, aplicar la migracion real +mas_syn2mas_migration \ + --ssh-host organic-machine.com \ + --mas-container element_matrix_chat-mas-1 \ + --synapse-config-path /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml \ + --log-dir ~/matrix_migration_logs \ + --max-conflicts 0 \ + --apply + +# Salida esperada tras migracion exitosa: +# {"status":"ok","dry_run_log":"/home/lucas/matrix_migration_logs/syn2mas_dryrun_1234567890.log","apply_log":"/home/lucas/matrix_migration_logs/syn2mas_apply_1234567890.log","conflicts":0,"users_migrated":42,"duration_s":15} +``` + +## Cuando usarla + +Usar en el paso 4 de la migracion del issue 0162 (Synapse a MAS auth), tras activar MSC3861 en `homeserver.yaml` y verificar que MAS esta corriendo con `syn2mas: true` en su config. NUNCA ejecutar antes de activar MSC3861 — sin ese flag activo, `syn2mas` no puede mapear usuarios a las tablas MAS y la migracion resultara en estado inconsistente. + +## Gotchas + +- **Dry-run NO modifica nada** — siempre ejecutar primero sin `--apply` y revisar el log manualmente antes de aplicar. +- Si el dry-run detecta usuarios con **guest accounts**, **application services** (bots), o **passwords externos** (LDAP/OIDC), revisar manualmente el log antes de aplicar — estos casos pueden requerir steps adicionales documentados en el issue 0162. +- **Backup postgres pre-migracion NO esta cubierto** por esta funcion. El operador es responsable de hacer `pg_dump` de la DB de Synapse antes de ejecutar con `--apply`. Ver issue 0162 paso 1. +- Si la migracion real falla **a mitad**, MAS puede quedar en estado inconsistente con usuarios parcialmente migrados. El rollback consiste en restaurar el backup postgres de Synapse + revertir `homeserver.yaml` a la configuracion pre-MSC3861. +- Los logs archivados en `--log-dir` **incluyen userIDs** (datos personales). Se crean con permisos `0600` (solo propietario puede leer). Mantener el directorio con `chmod 0700`. No subir los logs a repos publicos. +- El comando `mas-cli syn2mas` en el container asume que `homeserver.yaml` esta montado en `/data/homeserver.yaml`. Si el volume mount del container usa otra ruta, el comando fallara con "file not found". Verificar con `docker inspect | jq '.[].Mounts'`. +- La postcondicion compara el count de usuarios MAS con una segunda ejecucion de dry-run para obtener el count esperado. Si el conteo no esta disponible (salida inesperada de mas-cli), la funcion emite `status=ok` con `users_migrated` del count real de MAS — no aborta por este motivo para evitar falsos negativos. diff --git a/bash/functions/infra/mas_syn2mas_migration.sh b/bash/functions/infra/mas_syn2mas_migration.sh new file mode 100644 index 00000000..738be6e7 --- /dev/null +++ b/bash/functions/infra/mas_syn2mas_migration.sh @@ -0,0 +1,325 @@ +#!/usr/bin/env bash +# mas_syn2mas_migration — Migra usuarios Synapse a MAS via mas-cli syn2mas. +# Fuerza dry-run primero, archiva el log, aborta si conflicts > threshold, +# y solo ejecuta la migracion real cuando se pasa --apply. +# +# Usage: +# mas_syn2mas_migration --ssh-host --mas-container \ +# --synapse-config-path --log-dir \ +# [--max-conflicts N] [--apply] +# +# Output: JSON en stdout con status, dry_run_log, apply_log, conflicts, users_migrated, duration_s + +set -euo pipefail + +mas_syn2mas_migration() { + local ssh_host="" + local mas_container="" + local synapse_config_path="" + local log_dir="" + local max_conflicts=0 + local do_apply=false + + # ---- Parse args ---- + while [[ $# -gt 0 ]]; do + case "$1" in + --ssh-host) + ssh_host="$2" + shift 2 + ;; + --mas-container) + mas_container="$2" + shift 2 + ;; + --synapse-config-path) + synapse_config_path="$2" + shift 2 + ;; + --log-dir) + log_dir="$2" + shift 2 + ;; + --max-conflicts) + max_conflicts="$2" + shift 2 + ;; + --apply) + do_apply=true + shift + ;; + --help|-h) + cat >&2 <<'USAGE' +mas_syn2mas_migration - Migra usuarios Synapse a Matrix Authentication Service (MAS) + +Usage: + mas_syn2mas_migration \ + --ssh-host \ + --mas-container \ + --synapse-config-path \ + --log-dir \ + [--max-conflicts N] \ + [--apply] + +Opciones: + --ssh-host Alias SSH del VPS (ej. organic-machine.com) + --mas-container Nombre del container MAS (ej. element_matrix_chat-mas-1) + --synapse-config-path Ruta en el VPS al homeserver.yaml + (ej. /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml) + --log-dir Directorio local donde archivar logs dry-run y apply + --max-conflicts N Tope de conflictos en dry-run antes de abortar (default 0) + --apply Ejecutar migracion real. Sin esta flag: solo dry-run. + +Comportamiento: + 1. Siempre ejecuta dry-run primero y archiva el log. + 2. Si conflicts > max-conflicts -> status=aborted, exit 2. + 3. Sin --apply -> status=ok (dry-run completado), exit 0. + 4. Con --apply -> ejecuta migracion real, archiva log, verifica postcondicion. + +Output JSON: {"status":"ok|aborted|error","dry_run_log":"path","apply_log":"path|null","conflicts":N,"users_migrated":N,"duration_s":N} +USAGE + echo '{"status":"help","dry_run_log":"","apply_log":null,"conflicts":0,"users_migrated":0,"duration_s":0}' + return 0 + ;; + *) + echo "mas_syn2mas_migration: argumento desconocido: $1" >&2 + return 1 + ;; + esac + done + + # ---- Validar argumentos obligatorios ---- + local errors=() + [[ -z "$ssh_host" ]] && errors+=("--ssh-host es obligatorio") + [[ -z "$mas_container" ]] && errors+=("--mas-container es obligatorio") + [[ -z "$synapse_config_path" ]] && errors+=("--synapse-config-path es obligatorio") + [[ -z "$log_dir" ]] && errors+=("--log-dir es obligatorio") + + if [[ ${#errors[@]} -gt 0 ]]; then + for err in "${errors[@]}"; do + echo "ERROR: $err" >&2 + done + echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}' + return 1 + fi + + # Validar que max_conflicts es un entero no negativo + if ! [[ "$max_conflicts" =~ ^[0-9]+$ ]]; then + echo "ERROR: --max-conflicts debe ser un entero >= 0, recibido: $max_conflicts" >&2 + echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}' + return 1 + fi + + # ---- Dependencias locales ---- + if ! command -v jq &>/dev/null; then + echo "ERROR: jq no encontrado. Instalar: apt install jq / brew install jq" >&2 + echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}' + return 1 + fi + + # ---- Crear log-dir con permisos restringidos ---- + mkdir -p "$log_dir" + chmod 0700 "$log_dir" + + local ts + ts=$(date +%s) + + local dry_run_log="${log_dir}/syn2mas_dryrun_${ts}.log" + local apply_log_path="null" + local apply_log_file="${log_dir}/syn2mas_apply_${ts}.log" + + # La ruta del homeserver.yaml dentro del container MAS se pasa como --synapse-config + # MAS monta el directorio del synapse bajo /data/ por convencion, pero la ruta real + # puede variar — usamos la ruta tal como existe en el host (montada via volume). + # El comando real esperado: docker exec mas-cli syn2mas --synapse-config + # donde es la ruta tal como el container la ve (via volume mount). + # Asumimos que el VPS tiene el config accesible en la misma ruta dentro del container. + local container_config="/data/homeserver.yaml" + + echo "mas_syn2mas_migration: ssh-host=${ssh_host} container=${mas_container} max-conflicts=${max_conflicts} apply=${do_apply}" >&2 + + # ========================================================================= + # PASO 1: DRY-RUN obligatorio + # ========================================================================= + echo "mas_syn2mas_migration: ejecutando dry-run..." >&2 + + local dry_exit=0 + # Capturar stdout+stderr del dry-run en el log y tambien en variable para parsing + local dry_output + dry_output=$(ssh "$ssh_host" \ + "docker exec '${mas_container}' mas-cli syn2mas \ + --synapse-config '${container_config}' \ + --dry-run" \ + 2>&1) || dry_exit=$? + + # Archivar log con timestamp + header informativo + { + echo "# mas_syn2mas_migration dry-run" + echo "# ts=${ts} ssh-host=${ssh_host} container=${mas_container}" + echo "# synapse-config-path=${synapse_config_path}" + echo "# exit=${dry_exit}" + echo "# ---" + printf '%s\n' "$dry_output" + } > "$dry_run_log" + chmod 0600 "$dry_run_log" + + echo "mas_syn2mas_migration: dry-run exit=${dry_exit}, log=${dry_run_log}" >&2 + + if [[ $dry_exit -ne 0 ]]; then + # Si el comando SSH falla completamente (no es fallo de syn2mas sino de conectividad) + echo "mas_syn2mas_migration: ERROR — dry-run falló con exit ${dry_exit}" >&2 + local escaped_out + escaped_out=$(printf '%s' "${dry_output}" | jq -Rs '.') + local dry_run_log_json + dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.') + echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":-1,\"users_migrated\":0,\"duration_s\":0}" + return 1 + fi + + # ========================================================================= + # PASO 2: Parsear conflicts del dry-run + # ========================================================================= + # Regex sobre lineas tipo: + # "Conflict:", "Skipping:", "Error processing user", "conflict" + # También contamos líneas que indiquen usuarios problemáticos. + local conflicts=0 + local conflict_lines + conflict_lines=$(printf '%s\n' "$dry_output" | \ + grep -ciE '(conflict|skipping|error processing user|cannot migrate|already exists)' 2>/dev/null || true) + + # grep -c devuelve string; convertir a int defensivamente + if [[ "$conflict_lines" =~ ^[0-9]+$ ]]; then + conflicts=$conflict_lines + else + # Parser falló de forma inesperada — abortar defensivamente + echo "mas_syn2mas_migration: ERROR — no se pudo parsear el conteo de conflicts del dry-run (parser defensivo)" >&2 + local dry_run_log_json + dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.') + echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":-1,\"users_migrated\":0,\"duration_s\":0}" + return 1 + fi + + echo "mas_syn2mas_migration: conflicts detectados en dry-run: ${conflicts} (max permitido: ${max_conflicts})" >&2 + + # ========================================================================= + # PASO 3: Verificar threshold de conflicts + # ========================================================================= + if [[ $conflicts -gt $max_conflicts ]]; then + echo "mas_syn2mas_migration: ABORTADO — conflicts (${conflicts}) > max-conflicts (${max_conflicts})" >&2 + echo "Revisar: ${dry_run_log}" >&2 + local dry_run_log_json + dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.') + echo "{\"status\":\"aborted\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":0}" + return 2 + fi + + # ========================================================================= + # PASO 4: Si no --apply, terminar aqui con status=ok (dry-run completado) + # ========================================================================= + if [[ "$do_apply" == "false" ]]; then + echo "mas_syn2mas_migration: dry-run completado (${conflicts} conflicts). Revisar log y re-ejecutar con --apply." >&2 + local dry_run_log_json + dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.') + echo "{\"status\":\"ok\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":0}" + return 0 + fi + + # ========================================================================= + # PASO 5: Migracion REAL (--apply) + # ========================================================================= + echo "mas_syn2mas_migration: ejecutando migracion REAL..." >&2 + local apply_start + apply_start=$(date +%s) + + local apply_exit=0 + local apply_output + apply_output=$(ssh "$ssh_host" \ + "docker exec '${mas_container}' mas-cli syn2mas \ + --synapse-config '${container_config}'" \ + 2>&1) || apply_exit=$? + + local apply_end + apply_end=$(date +%s) + local duration_s=$(( apply_end - apply_start )) + + # Archivar log de apply + { + echo "# mas_syn2mas_migration apply" + echo "# ts=${ts} ssh-host=${ssh_host} container=${mas_container}" + echo "# synapse-config-path=${synapse_config_path}" + echo "# exit=${apply_exit} duration_s=${duration_s}" + echo "# ---" + printf '%s\n' "$apply_output" + } > "$apply_log_file" + chmod 0600 "$apply_log_file" + + apply_log_path="$apply_log_file" + echo "mas_syn2mas_migration: apply exit=${apply_exit}, duration=${duration_s}s, log=${apply_log_file}" >&2 + + if [[ $apply_exit -ne 0 ]]; then + echo "mas_syn2mas_migration: ERROR — migracion real falló con exit ${apply_exit}" >&2 + local dry_run_log_json apply_log_json + dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.') + apply_log_json=$(printf '%s' "$apply_log_file" | jq -Rs '.') + echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":${apply_log_json},\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":${duration_s}}" + return 1 + fi + + # ========================================================================= + # PASO 6: Postcondicion — comparar usuarios en MAS vs Synapse + # ========================================================================= + echo "mas_syn2mas_migration: verificando postcondicion (usuarios MAS vs Synapse)..." >&2 + + local mas_user_count=0 + local synapse_user_count=0 + local users_migrated=0 + local post_status="ok" + + # Contar usuarios en MAS via mas-cli admin user list + local mas_count_raw + mas_count_raw=$(ssh "$ssh_host" \ + "docker exec '${mas_container}' mas-cli manage list-users --json 2>/dev/null | jq length" \ + 2>/dev/null || echo "0") + + if [[ "$mas_count_raw" =~ ^[0-9]+$ ]]; then + mas_user_count=$mas_count_raw + else + echo "mas_syn2mas_migration: ADVERTENCIA — no se pudo obtener conteo de usuarios MAS (output: ${mas_count_raw})" >&2 + post_status="ok" # No abortar, solo advertir + fi + + # Contar usuarios locales en Synapse via psql (excluyendo bots/AS) + # Intentamos obtener el count; si falla, continuamos sin abortar + local synapse_count_raw + synapse_count_raw=$(ssh "$ssh_host" \ + "docker exec '${mas_container}' mas-cli syn2mas --synapse-config '${container_config}' --dry-run 2>&1 | grep -oE 'Found [0-9]+ users' | grep -oE '[0-9]+' | head -1" \ + 2>/dev/null || echo "0") + + if [[ "$synapse_count_raw" =~ ^[0-9]+$ ]]; then + synapse_user_count=$synapse_count_raw + fi + + users_migrated=$mas_user_count + + # Si tenemos ambos counts y difieren significativamente, marcar como warning en log + if [[ $synapse_user_count -gt 0 && $mas_user_count -eq 0 ]]; then + echo "mas_syn2mas_migration: ADVERTENCIA — MAS reporta 0 usuarios pero Synapse tenia ${synapse_user_count}" >&2 + post_status="error" + fi + + echo "mas_syn2mas_migration: postcondicion: mas_users=${mas_user_count} synapse_users=${synapse_user_count} status=${post_status}" >&2 + + # ========================================================================= + # PASO 7: Emitir JSON final + # ========================================================================= + local dry_run_log_json apply_log_json + dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.') + apply_log_json=$(printf '%s' "$apply_log_file" | jq -Rs '.') + + echo "{\"status\":\"${post_status}\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":${apply_log_json},\"conflicts\":${conflicts},\"users_migrated\":${users_migrated},\"duration_s\":${duration_s}}" + return 0 +} + +# Ejecutar si se llama directamente (no sourced) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + mas_syn2mas_migration "$@" +fi diff --git a/bash/functions/infra/mas_syn2mas_migration_test.sh b/bash/functions/infra/mas_syn2mas_migration_test.sh new file mode 100644 index 00000000..2f4b77ed --- /dev/null +++ b/bash/functions/infra/mas_syn2mas_migration_test.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Tests para mas_syn2mas_migration +# Verifica arg parsing sin conectar al VPS real. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/mas_syn2mas_migration.sh" + +PASS=0 +FAIL=0 + +assert_exit() { + local test_name="$1" expected_exit="$2" + shift 2 + local actual_exit=0 + set +e + "$@" >/dev/null 2>&1 + actual_exit=$? + set -e + if [[ "$actual_exit" == "$expected_exit" ]]; then + echo "PASS: $test_name" + ((PASS++)) || true + else + echo "FAIL: $test_name — expected exit $expected_exit, got $actual_exit" + ((FAIL++)) || true + fi +} + +assert_stdout_contains() { + local test_name="$1" needle="$2" + shift 2 + local output actual_exit=0 + set +e + output=$("$@" 2>/dev/null) + actual_exit=$? + set -e + if echo "$output" | grep -q "$needle"; then + echo "PASS: $test_name" + ((PASS++)) || true + else + echo "FAIL: $test_name — expected stdout to contain '$needle', got: $output" + ((FAIL++)) || true + fi +} + +# Test: aborta con error cuando faltan args obligatorios +assert_exit "aborta con error cuando faltan args obligatorios" 1 \ + mas_syn2mas_migration + +# Test: help no devuelve error +assert_exit "help no devuelve error" 0 \ + mas_syn2mas_migration --help + +# Test: argumento desconocido retorna exit 1 +assert_exit "argumento desconocido retorna exit 1" 1 \ + mas_syn2mas_migration --unknown-flag + +# Test: max-conflicts invalido retorna exit 1 +assert_exit "max-conflicts invalido retorna exit 1" 1 \ + mas_syn2mas_migration \ + --ssh-host fake-host \ + --mas-container fake-container \ + --synapse-config-path /fake/homeserver.yaml \ + --log-dir "/tmp/test_mas_migration_$$" \ + --max-conflicts "not-a-number" + +# Test: help emite JSON valido con status=help +assert_stdout_contains "help emite JSON con status help" '"status":"help"' \ + mas_syn2mas_migration --help + +# Test: falta --ssh-host emite JSON con status=error +assert_stdout_contains "falta ssh-host emite JSON error" '"status":"error"' \ + mas_syn2mas_migration \ + --mas-container fake-container \ + --synapse-config-path /fake/homeserver.yaml \ + --log-dir "/tmp/test_mas_migration_$$" + +# Test: falta --log-dir emite JSON con status=error +assert_stdout_contains "falta log-dir emite JSON error" '"status":"error"' \ + mas_syn2mas_migration \ + --ssh-host fake-host \ + --mas-container fake-container \ + --synapse-config-path /fake/homeserver.yaml + +# Limpieza +rm -rf "/tmp/test_mas_migration_$$" 2>/dev/null || true + +echo "---" +echo "Results: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]] || exit 1 diff --git a/dev/flows/0010-matrix-client-pc.md b/dev/flows/0010-matrix-client-pc.md new file mode 100644 index 00000000..2e4ddd7a --- /dev/null +++ b/dev/flows/0010-matrix-client-pc.md @@ -0,0 +1,157 @@ +--- +name: matrix-client-pc +id: 0010 +status: pending +created: 2026-05-24 +updated: 2026-05-24 +priority: high +risk: medium +related_issues: [0147, 0148, 0149, 0150, 0151, 0152, 0153, 0162, 0163] +related_flows: [0009, 0011] +apps: [matrix_client_pc] +projects: [element_agents] +vaults: [] +capability_groups: [matrix-client, livekit-calls, e2ee, widgets] +trigger: manual +schedule: "" +expected_runtime_s: 0 +tags: [matrix, element, wails, react, mantine, livekit, e2ee, widgets, agents] +--- + +## Goal + +Cliente Matrix propio para PC (Win/Linux/macOS) construido con Wails (Go backend) + React+Mantine+`@fn_library` frontend. Replica capacidades actuales de Element Web (chat, E2EE, calls LiveKit) y se abre a mejoras propias: mini-webapps embebidas en conversaciones gestionadas por agentes del project `element_agents`, paneles especiales para llamadas, integracion directa con `agents_and_robots` + `agents_dashboard` + `device_agent` + futuro mesh WireGuard (flow 0009). + +## Pre-requisitos + +- Synapse + MAS + LiveKit funcionando en `organic-machine.com` (app `element_matrix_chat` ya desplegada, 5+ semanas uptime). +- `livekit-jwt` container vivo para generar tokens (ver `docker-compose.livekit.yml`). +- Sygnal push gateway (Synapse) — TBD si no existe, anadir container para push notifs PC + Android. +- Cuenta Matrix de test (`@dev-pc:matrix-af2f3d.organic-machine.com`) registrada via MAS. +- Go 1.22+ + Wails CLI v2 instalado (`go install github.com/wailsapp/wails/v2/cmd/wails@latest`). +- pnpm + Node 20+ (ya en el repo para `frontend/`). + +## Funciones del registry recomendadas + +| Rol | Funcion candidata | Estado | +|---|---|---| +| Matrix client init (Go) | `matrix_client_init_go_infra` | FALTA: wrapper sobre `mautrix-go` (login MAS OIDC, sync, store SQLite) | +| LiveKit token gen (Go) | `livekit_token_gen_go_infra` | FALTA: JWT con `livekit-server-sdk-go` | +| Matrix room subscribe SSE (Go) | `matrix_room_subscribe_go_infra` | FALTA: stream eventos Synapse -> frontend Wails via SSE/IPC | +| Matrix message send (Go) | `matrix_message_send_go_infra` | FALTA: text + markdown + reply + edit + reaction | +| Matrix E2EE bootstrap (Go) | `matrix_e2ee_bootstrap_go_infra` | FALTA: cross-signing keys, recovery passphrase | +| Matrix device verify (Go) | `matrix_device_verify_go_infra` | FALTA: SAS verification flow | +| LiveKit room hook (TS) | `livekit_room_ts_ui` | FALTA: hook React wrapper sobre `livekit-client` | +| Widget host iframe (TS) | `widget_host_ts_ui` | FALTA: iframe sandbox + postMessage Matrix Widget API v2 | +| Matrix timeline hook (TS) | `useMatrixTimeline_ts_ui` | FALTA: hook React con pagination, dedupe, optimistic UI | +| Markdown render (TS) | reuse existing `markdown_render_ts_ui` si existe, sino crear | check | +| HTTP client (Go) | `http_json_client_go_infra` | OK (reusar) | +| SQLite open (Go) | `sqlite_open_go_infra` | OK (reusar) | +| HTTP server SSE | `http_sse_server_go_infra` | OK (reusar) | +| Notify (impure) | `notify_desktop_go_infra` | FALTA: Win/Linux/mac notifications nativas | + +## Apps tocadas + +- `projects/element_agents/apps/matrix_client_pc` (nueva — Wails + React). +- `projects/element_agents/apps/element_matrix_chat` (backend ya activo; quiza anadir sygnal container). +- `projects/element_agents/apps/agents_and_robots` (consumidor — el cliente PC dialoga con agentes via rooms Matrix). +- `projects/element_agents/apps/agents_dashboard` (referencia UI — algunos paneles se reusan). + +## Projects relacionados + +- `element_agents` (root project — agrupa todo). + +## Vaults / storage + +- Local del PC: `~/.matrix_client_pc/store.db` (sync state + crypto store SQLite). +- Cache media: `~/.matrix_client_pc/media/`. + +## Capability groups consultados + +- `matrix-client` (a crear: documenta wrappers `mautrix-go`). +- `livekit-calls` (a crear: token gen + room join + UI calls). +- `e2ee` (a crear: bootstrap + verification + recovery). +- `widgets` (a crear: Matrix Widget API v2 host + sandbox + permisos). + +## Flow + +Pasos numerados. Cada paso = issue propio (ver `related_issues`). + +1. **0147 — Scaffold Wails + login MAS.** Crear app `matrix_client_pc/` con Wails init, conectar a Synapse via MAS OIDC, mostrar perfil del usuario logueado. Persistencia tokens en `pass` o keychain del SO. +2. **0148 — Rooms list + timeline.** Sidebar con rooms (DMs + spaces + grupos), panel central timeline con pagination scroll-up, dedupe, optimistic UI. Reusar layout `AppShell` Mantine. +3. **0149 — Composer + interacciones.** Composer markdown, replies, edits, reactions, threads, upload media (imagenes, files, voice msg). Drag&drop. Slash commands placeholder. +4. **0150 — E2EE.** `mautrix-go` con crypto store SQLite. Cross-signing setup, recovery passphrase, SAS verification de devices, key backup. UI para verificar otros usuarios. +5. **0151 — Calls LiveKit.** Boton call en room -> token JWT desde Go backend -> join LiveKit room -> UI con tiles participantes, mute/cam/screen/hangup. 1:1 + grupales hasta 16 (limite actual del config). +6. **0152 — Mini-webapps embebidas.** Implementar Matrix Widget API v2: iframe sandbox + postMessage handshake + permisos (capabilities `m.always_on_screen`, `org.matrix.msc2762.send.event`, etc.). Lanzar webapps desde slash command `/widget ` o desde state event `m.widget`. Agentes pueden publicar widgets en su room (ej. dashboard de telemetria, formulario, kanban inline). +7. **0153 — Agent integration.** Paneles especiales para rooms operados por agentes de `agents_and_robots`: timeline + panel lateral con estado del agente (uptime, cola de tasks, last_error). Reusar SSE del `agents_dashboard`. + +## Acceptance + +- [ ] App Wails compila y arranca en Win+Linux con binario standalone. +- [ ] Login MAS OIDC completo, token persistido entre arranques. +- [ ] Sync incremental con Synapse funciona; reconexion automatica tras red caida. +- [ ] E2EE: enviar/recibir mensajes cifrados con otro cliente (Element Web o Android). +- [ ] Call 1:1 con video+audio funcional via LiveKit. +- [ ] Widget de prueba (HTML estatico servido por `agents_and_robots`) se carga en iframe sandbox y postMessage handshake completa. + +## Definition of Done + +### Mecanica (pre-requisito) + +- `go build -tags wails` verde para Win + Linux. +- `pnpm build` frontend verde. +- `fn doctor cpp-apps` no aplica; `fn doctor services` confirma backend Matrix sano. +- `app.md` con `uses_functions` declarando todas las dependencias del registry. + +### Cobertura de comportamiento + +| Escenario | Tipo | Comando / evidencia | Resultado esperado | +|---|---|---|---| +| Golden: login + recibir mensaje E2EE | e2e | `e2e/test_login_and_receive.sh` | mensaje aparece en timeline en <2s, descifrado OK | +| Edge: red cae 30s, vuelve | e2e | `e2e/test_reconnect.sh` | sync se reanuda sin perder mensajes | +| Edge: 2000 mensajes en 1 room | e2e | `e2e/test_perf_timeline.sh` | scroll a 60fps, memoria <500MB | +| Edge: device nuevo no verificado envia msg | e2e | `e2e/test_unverified_device.sh` | warning visible en UI, msg cifra a este device solo si user confirma | +| Error: token MAS expira | e2e | `e2e/test_token_refresh.sh` | refresh automatico, sin logout visible | +| Error: LiveKit SFU caido | e2e | `e2e/test_livekit_down.sh` | error claro en UI, no crash de la app | + +### Vida util validada (>=7 dias uso real) + +| Metrica | Umbral | Donde se observa | Ventana | +|---|---|---|---| +| Crashes proceso PC | `0` | `journalctl --user -u matrix_client_pc` (Linux) / Event Viewer (Win) | 7 dias | +| Latencia send msg | `p95 < 500ms` | panel propio de la app + `call_monitor` | 7 dias | +| Calls fallidas | `< 5%` | counter en app + logs LiveKit | 7 dias | +| Uso real diario | `>= 4 dias/semana` | `last_active_at` en store local | 7 dias | +| Onboarding nuevo usuario | `< 5min hasta primer msg E2EE` | screencast operador | 1 sesion | + +### Anti-criterios + +- NO marcar done si E2EE se silent-falla (mensajes no se descifran y la UI no lo dice). +- NO marcar done si la app solo funciona en `home-wsl` y peta en `aurgi-pc`. +- NO marcar done si widget host carga `javascript:` URLs (XSS). +- NO marcar done si calls grupales >3 participantes lagean con audio cortado. + +## Notas + +**Onboarding rapido:** +1. `cd projects/element_agents/apps/matrix_client_pc` +2. `wails dev` para desarrollo con hot-reload. +3. `wails build -platform linux/amd64,windows/amd64` para release. +4. Tokens MAS guardados via `keyring` (Go bindings al keychain del SO). +5. Para probar E2EE: crear segundo usuario en Synapse Admin, abrir Element Web como segundo cliente, intercambiar verifications. + +**Camino futuro (post-DoD):** +- Push notifs nativas via `sygnal` + APNs/FCM-equivalent desktop (Win Action Center, Linux notify-send). +- Mini-webapp catalog: registry de widgets internos (`projects/element_agents/widgets/`) publicables a rooms con un comando. +- Threads UI mejorado (vs Element que es plano). +- Integracion `agents_and_robots`: panel embebido que muestra logs del agente del room actual. +- Cuando flow 0009 (mesh wireguard) este vivo: este cliente PC habla con `device_agent` de cada PC del mesh via su room Matrix. + +**Decisiones clave (justificacion en hilo Claude 2026-05-24):** +- Wails > Tauri: Go es stack principal del registry, reusa funciones existentes, `mautrix-go` es el SDK Matrix mas maduro en Go. +- React+Vite+Mantine+`@fn_library`: defaults del proyecto, ver `frontend_theming.md`. +- 2 codebases (PC Wails + Android Kotlin nativo): tradeoff aceptado por calidad nativa Android + reuso Go en PC. Contrato compartido en `docs/client_contract.md` (TBD). + +## Capability growth log + +- v0.1.0 (2026-05-24) — baseline (flow creado). diff --git a/dev/flows/0011-matrix-client-android.md b/dev/flows/0011-matrix-client-android.md new file mode 100644 index 00000000..a1b3bc7c --- /dev/null +++ b/dev/flows/0011-matrix-client-android.md @@ -0,0 +1,165 @@ +--- +name: matrix-client-android +id: 0011 +status: pending +created: 2026-05-24 +updated: 2026-05-24 +priority: high +risk: medium +related_issues: [0154, 0155, 0156, 0157, 0158, 0159, 0160, 0161, 0162, 0163] +related_flows: [0009, 0010] +apps: [matrix_client_android] +projects: [element_agents] +vaults: [] +capability_groups: [matrix-client, livekit-calls, e2ee, widgets, android-native] +trigger: manual +schedule: "" +expected_runtime_s: 0 +tags: [matrix, element, android, kotlin, compose, livekit, e2ee, widgets, agents, fcm, push] +--- + +## Goal + +Cliente Matrix Android nativo (Kotlin + Jetpack Compose) que comparte contrato con el cliente PC (flow 0010) pero usa SDKs nativos para calidad superior: `matrix-rust-sdk` Kotlin bindings (E2EE rust, mejor), `livekit-android` (codecs HW, audio focus, AEC), FCM push directo via `sygnal`, foreground service para calls en background. Replica capacidades de Element Android + abre mini-webapps embebidas (Matrix Widget API v2 dentro de WebView) gestionadas por agentes del project `element_agents`. + +## Pre-requisitos + +- Stack Synapse + MAS + LiveKit ya activo en `organic-machine.com` (flow 0010 compartido). +- Container `sygnal` corriendo en VPS (anadir si no existe — issue 0159 lo cubre). +- Firebase project con FCM activado + service account JSON. Hosting gratuito. +- Android Studio Iguana+, NDK r26+, Kotlin 1.9+. +- `init_kotlin_app_bash_pipelines` (ya existe, ver issues 0073/0074/0075/0078 completados) para scaffold inicial. +- Device fisico o emulator Android 9+ (API 28+) para test. +- Capability del usuario operador: instalar APK debug + microphone/camera/notification grants. + +## Funciones del registry recomendadas + +| Rol | Funcion candidata | Estado | +|---|---|---| +| Kotlin app scaffold | `init_kotlin_app_bash_pipelines` | OK (reusar) | +| Matrix rust-sdk wrapper (Kotlin) | `matrix_client_kotlin_infra` | FALTA: facade sobre `matrix-rust-sdk` Kotlin bindings | +| LiveKit Android wrapper | `livekit_call_kotlin_infra` | FALTA: wrapper `io.livekit:livekit-android` | +| FCM token register | `fcm_register_kotlin_infra` | FALTA: registrar device en sygnal via Synapse pusher API | +| Sygnal pusher add | `sygnal_pusher_add_go_infra` | FALTA: Go helper para configurar push gateway | +| Compose Room list | `RoomListScreen_kotlin_ui` | FALTA | +| Compose Timeline | `TimelineScreen_kotlin_ui` | FALTA | +| Compose Composer | `Composer_kotlin_ui` | FALTA | +| Compose CallScreen | `CallScreen_kotlin_ui` | FALTA | +| Compose WidgetHost | `WidgetHost_kotlin_ui` | FALTA: WebView + JS bridge Widget API | +| Foreground service call | `CallForegroundService_kotlin_infra` | FALTA | +| ICE permissions helper | `permissions_request_kotlin_core` | FALTA: mic/cam/notif/foreground service grants | +| Local DB Room | reusar `androidx.room` directo | OK | + +## Apps tocadas + +- `projects/element_agents/apps/matrix_client_android` (nueva — Kotlin+Compose). +- `projects/element_agents/apps/element_matrix_chat` (anadir sygnal container — issue 0159). +- `projects/element_agents/apps/agents_and_robots` (consumidor agent panels). + +## Projects relacionados + +- `element_agents`. + +## Vaults / storage + +- Local Android: `/data/data/com.fnregistry.matrix_client_android/databases/` (room DB encriptada via SQLCipher). +- Crypto store de matrix-rust-sdk: gestionado por el SDK en `files/matrix//`. + +## Capability groups consultados + +- `matrix-client` (compartido con flow 0010). +- `livekit-calls` (compartido). +- `e2ee` (compartido). +- `widgets` (compartido — contrato Widget API igual). +- `android-native` (a crear: foreground service, FCM, MediaSession para calls). + +## Flow + +1. **0154 — Scaffold Kotlin + Compose + login MAS.** App `matrix_client_android/` con `init_kotlin_app`, Material 3 + tema propio acorde a `frontend_theming.md` (paleta equivalente). Login MAS OIDC via Chrome Custom Tabs. Tokens persistidos en EncryptedSharedPreferences. +2. **0155 — Rooms list + Timeline.** Compose UI con `LazyColumn` virtualizado, sync via `matrix-rust-sdk` (corrutinas). Pagination, optimistic UI, swipe-to-react. +3. **0156 — Composer.** Markdown, replies, edits, reactions, media (camara + galeria + voice msg con `MediaRecorder` opus). +4. **0157 — E2EE rust-sdk.** Cross-signing setup, SAS verification (emoji), recovery passphrase, key backup. UI dialog verificacion. +5. **0158 — Calls LiveKit Android nativo.** `livekit-android` SDK con codecs HW (H.264/VP9 hardware decoder), audio focus, echo cancellation, noise suppression. PiP mode Android nativo. +6. **0159 — Push FCM via sygnal.** Anadir container `sygnal` al stack `element_matrix_chat`. Registrar FCM token via Synapse Pusher API. Handle push payload -> open room / wake up para incoming call. +7. **0160 — Mini-webapps en WebView.** `WebView` con `WebViewClient` + JS bridge implementando Matrix Widget API v2. Sandbox via `setAllowFileAccess(false)`, `setAllowContentAccess(false)`, CSP estricta. Mismo contrato widgets que cliente PC. +8. **0161 — Foreground service para calls + lifecycle.** `CallForegroundService` con notification ongoing, audio routing (speaker/earpiece/bluetooth), MediaSession para controls en lockscreen, wakelock controlado. + +## Acceptance + +- [ ] APK debug instala + arranca en Android 9+ (API 28). +- [ ] Login MAS via Chrome Custom Tabs, token persistido en EncryptedSharedPreferences. +- [ ] Sync incremental funciona; reconexion automatica tras avion mode toggle. +- [ ] E2EE: mensaje enviado desde PC (Wails) se descifra en Android (y al reves). +- [ ] Call 1:1 con video+audio nativos, calidad superior a WebView. +- [ ] Push FCM despierta app para incoming msg / call. +- [ ] Widget de prueba se carga en WebView sandbox con bridge funcional. +- [ ] Foreground service mantiene call viva con app en background + pantalla bloqueada. + +## Definition of Done + +### Mecanica (pre-requisito) + +- `./gradlew assembleDebug` verde. +- `./gradlew test` verde. +- `./gradlew connectedAndroidTest` verde en emulator API 31+ (instrumented). +- `app.md` con `uses_functions` declarando dependencias del registry. + +### Cobertura de comportamiento + +| Escenario | Tipo | Comando / evidencia | Resultado esperado | +|---|---|---|---| +| Golden: login + E2EE msg | instrumented | `./gradlew connectedAndroidTest --tests *LoginE2EE*` | msg descifrado en <2s, shield green | +| Edge: avion mode 30s | instrumented | `./gradlew connectedAndroidTest --tests *Reconnect*` | sync resume, sin perder msgs | +| Edge: 1000 msgs en room | benchmark | `./gradlew :app:benchmark` | scroll a 60fps, RAM <300MB | +| Edge: incoming call, pantalla apagada | manual + screencast | apagar pantalla + recibir call desde PC | notif full-screen + ring, accept funciona | +| Error: FCM token rotation | instrumented | `./gradlew connectedAndroidTest --tests *FCMRotation*` | re-register automatico en sygnal | +| Error: WebView widget malicioso | instrumented | `./gradlew connectedAndroidTest --tests *WidgetSandbox*` | bloqueado, no escape | +| Battery: call 30min | manual + dumpsys batterystats | call 30min | drain <15%, sin OOM | + +### Vida util validada (>=7 dias uso real) + +| Metrica | Umbral | Donde se observa | Ventana | +|---|---|---|---| +| Crashes (ANRs/forced close) | `0` | `adb logcat -e FATAL` + Play Console (si publicado) | 7 dias | +| Push latency (msg enviado -> notif visible) | `p95 < 3s` | log custom en app + sygnal | 7 dias | +| Call drops in-pocket (lockscreen) | `< 5%` | counter app | 7 dias | +| Battery drain idle | `< 2%/h` | dumpsys batterystats | 7 dias | +| Uso real diario | `>= 5 dias/semana` | last_active en local DB | 7 dias | + +### Anti-criterios + +- NO marcar done si E2EE silent-falla. +- NO marcar done si call con pantalla bloqueada se corta a los <5min (battery optimization mata el service). +- NO marcar done si WebView de widget permite acceso a `file://` o cookies del browser host. +- NO marcar done si la app solo funciona en el device del operador y peta en Android < 11. +- NO marcar done sin probar en Android 9 (legacy, muchos dispositivos antiguos siguen vivos). + +## Notas + +**Onboarding rapido:** +1. `cd projects/element_agents/apps/matrix_client_android` +2. `./gradlew assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk` +3. Para hot-reload UI: `./gradlew :app:installDebug` + Android Studio Compose preview. +4. Para test push: enviar msg desde Element Web a la cuenta del Android; debe llegar notif via FCM en <3s. + +**Decisiones clave:** +- `matrix-rust-sdk` Kotlin bindings > matrix-android-sdk2 (deprecated). Rust-sdk es el futuro oficial de matrix.org. +- `livekit-android` nativo > WebRTC.org directo. SDK oficial mantiene mejor performance + features. +- Jetpack Compose > XML views. Encaja mejor con reactive model + menos boilerplate. +- EncryptedSharedPreferences para tokens MAS. NO usar SharedPreferences plain. +- Material 3 con tema propio (paleta similar a Mantine accent del cliente PC para coherencia visual). + +**Camino futuro (post-DoD):** +- Wear OS companion app (notifs + quick reply). +- Android Auto integration (read msgs voice + reply voice). +- Conversation shortcuts API (Android 11+) para que cada room aparezca en share sheet. +- Bubble notifications (Android 11+) para conversaciones favoritas. + +**Compartido con flow 0010:** +- Contrato `m.widget` y Widget API v2 IDENTICO. Mismo widget html funciona en ambos. +- Contrato `m.agent.metadata` para detectar rooms de agentes IDENTICO. +- Cuando flow 0009 (mesh) este vivo, ambos clientes hablan a `device_agent` igual. + +## Capability growth log + +- v0.1.0 (2026-05-24) — baseline. diff --git a/dev/issues/0147-matrix-client-pc-scaffold.md b/dev/issues/0147-matrix-client-pc-scaffold.md new file mode 100644 index 00000000..45e5c7c6 --- /dev/null +++ b/dev/issues/0147-matrix-client-pc-scaffold.md @@ -0,0 +1,54 @@ +--- +id: "0147" +title: "matrix-client-pc scaffold: Wails + React+Mantine + login MAS" +status: pending +priority: high +created: 2026-05-24 +related_flows: ["0010"] +related_issues: ["0148", "0162"] +dependencies: ["0162"] +tags: [matrix, wails, react, mantine, mas, oidc, scaffold] +--- + +## Objetivo + +Crear el esqueleto de la app `projects/element_agents/apps/matrix_client_pc/` con Wails v2 (Go) + React+Vite+Mantine+`@fn_library` y dejar funcionando el login MAS OIDC contra `mas-...organic-machine.com`. Resultado: arrancar binario -> redirect navegador a MAS -> volver con token -> mostrar perfil del usuario. + +## Tareas + +1. `wails init -n matrix_client_pc -t react-ts` dentro de `projects/element_agents/apps/`. +2. Sub-repo Gitea: `git init -b master` + crear repo `dataforge/matrix_client_pc` + push inicial. +3. `app.md` con frontmatter (lang=go, framework=wails, tags incluyen `matrix` + `service`? — NO, es app cliente, sin tag service). +4. `go.mod` con deps: `wails/v2`, `mautrix-go`, `keyring`. +5. Reemplazar template frontend por React+Mantine+`@fn_library`. Symlink `frontend/src/fn_library` -> `../../../../../frontend/functions/ui/` (o copia si symlink no funciona en build). +6. Backend Go (`backend/`): + - `wails.json` con `bindings` para `MatrixService`. + - `MatrixService.Login() -> URL` (devuelve URL MAS OIDC). + - `MatrixService.HandleCallback(code) -> User`. + - `MatrixService.GetSession() -> *Session` (lee de keyring). + - `MatrixService.Logout()`. +7. Frontend React: layout `AppShell` Mantine, pagina `Login.tsx` con boton "Sign in with Matrix" -> abre URL MAS en navegador del SO. +8. Persistencia tokens en keyring SO (`github.com/zalando/go-keyring`). +9. Loopback HTTP local (`127.0.0.1:0`, puerto libre aleatorio) para recibir callback OIDC. +10. Test e2e basico: arrancar app, login con `@dev-pc:matrix-af2f3d.organic-machine.com`, ver perfil. + +## Funciones del registry a crear (delegar a fn-constructor) + +- `matrix_client_init_go_infra` — `mautrix.NewClient(homeserver, userID, accessToken) -> *Client, error`. Wrapper que configura SQLite store + crypto store. +- `mas_oidc_flow_go_infra` — `StartFlow(masURL) -> authURL, codeVerifier, state`. `ExchangeCode(code, codeVerifier) -> *Token`. +- `keyring_save_token_go_infra` / `keyring_load_token_go_infra` — wrappers `go-keyring`. + +## Acceptance + +- [ ] Binario Wails compila para linux/amd64 + windows/amd64. +- [ ] `wails dev` arranca con hot-reload. +- [ ] Login MAS OIDC end-to-end: boton -> navegador -> consent -> callback -> perfil visible. +- [ ] Token persistido entre re-arranques (no re-login si token vigente). +- [ ] `app.md` con `uses_functions` que apunta a las 3 funciones nuevas. +- [ ] Sub-repo `dataforge/matrix_client_pc` creado con commit inicial. + +## Notas + +- MAS URL: leerla de `.well-known/matrix/client` del homeserver para no hardcodear. +- Refresh token: MAS usa OAuth 2.0 estandar — implementar refresh proactivo (~5min antes de expiry). +- Gotcha: en Windows, `wails dev` requiere WebView2 instalado. diff --git a/dev/issues/0148-matrix-client-pc-rooms-timeline.md b/dev/issues/0148-matrix-client-pc-rooms-timeline.md new file mode 100644 index 00000000..555a8b77 --- /dev/null +++ b/dev/issues/0148-matrix-client-pc-rooms-timeline.md @@ -0,0 +1,57 @@ +--- +id: "0148" +title: "matrix-client-pc rooms list + timeline con sync incremental" +status: pending +priority: high +created: 2026-05-24 +related_flows: ["0010"] +related_issues: ["0147", "0149"] +dependencies: ["0147"] +tags: [matrix, sync, timeline, rooms, react, mantine, sse] +--- + +## Objetivo + +Sidebar con rooms (DMs + spaces + grupos) + panel central con timeline del room activo. Sync incremental con Synapse via long-poll `/sync`. Stream eventos backend -> frontend via SSE (`http_sse_server_go_infra`). Pagination scroll-up (cargar mensajes anteriores). Optimistic UI al enviar. + +## Tareas + +1. Backend Go: + - `MatrixService.StartSync()` — long-poll `/sync` con since token persistido. + - `MatrixService.SubscribeEvents() -> chan Event` — broadcaster events a frontend. + - SSE endpoint `http://127.0.0.1:/events` (autenticado con cookie session local). + - Persistir state en SQLite (`store.db`): rooms, members, last_event_id por room. +2. Frontend React: + - Hook `useMatrixRooms()` — devuelve `Room[]` ordenadas por last_activity. + - Hook `useMatrixTimeline(roomId, limit=50)` — devuelve eventos + `loadMore()`. + - Componente `RoomList` (sidebar con avatar, nombre, last_msg preview, unread badge). + - Componente `Timeline` con `react-virtuoso` para scroll perf con miles de msgs. + - Componente `EventBubble` (text, image, file, redacted, reaction agregada). + - Reconnect automatico si SSE/sync cae (exponential backoff). +3. Tests: + - `e2e/test_sync_basic.sh` — login + verificar que 3 rooms aparecen en sidebar. + - `e2e/test_pagination.sh` — scroll-up carga mensajes anteriores sin gap. + +## Funciones del registry a crear + +- `matrix_room_subscribe_go_infra` — SSE wrapper: subscribe events de Synapse y push a clientes. +- `useMatrixTimeline_ts_ui` — hook React con dedupe + pagination + optimistic. +- `useMatrixRooms_ts_ui` — hook React rooms list. +- `RoomList_ts_ui` — componente sidebar Mantine. +- `EventBubble_ts_ui` — componente burbuja msg. + +## Acceptance + +- [ ] Sidebar lista rooms del usuario test, ordenados por actividad. +- [ ] Click en room muestra timeline ultimos 50 msgs. +- [ ] Scroll arriba carga msgs anteriores sin duplicar. +- [ ] Mensaje enviado desde Element Web aparece en <2s en la timeline. +- [ ] Cerrar app + abrir: state restaurado desde SQLite, no re-sync completo. +- [ ] Network kill + restore: sync se reanuda sin perder mensajes. + +## Notas + +- DMs vs rooms grupales: detectar via `m.direct` account data. +- Spaces (`m.space`): mostrar como grupos colapsables en sidebar. +- Edits + redactions: aplicar in-place, no duplicar bubble. +- Read receipts: TBD en otro issue, no bloquea este. diff --git a/dev/issues/0149-matrix-client-pc-composer.md b/dev/issues/0149-matrix-client-pc-composer.md new file mode 100644 index 00000000..8797bf19 --- /dev/null +++ b/dev/issues/0149-matrix-client-pc-composer.md @@ -0,0 +1,60 @@ +--- +id: "0149" +title: "matrix-client-pc composer: markdown, reply, edit, reactions, media" +status: pending +priority: high +created: 2026-05-24 +related_flows: ["0010"] +related_issues: ["0148", "0150"] +dependencies: ["0148"] +tags: [matrix, composer, markdown, media, reactions, threads] +--- + +## Objetivo + +Composer del room: markdown rendering, replies con quote, edits, reactions emoji, threads (Matrix MSC3440), upload de media (imagenes, files, voice msg). Drag&drop archivos. Slash commands placeholder (`/me`, `/shrug`, `/widget` — este ultimo para issue 0152). + +## Tareas + +1. Backend Go: + - `MatrixService.SendMessage(roomID, body, format)` — text + markdown -> HTML via `goldmark`. + - `MatrixService.SendReply(roomID, parentEventID, body)`. + - `MatrixService.EditMessage(roomID, eventID, newBody)`. + - `MatrixService.SendReaction(roomID, eventID, key)`. + - `MatrixService.UploadMedia(roomID, filePath) -> mxc://`. + - `MatrixService.SendThreadReply(roomID, threadRootID, body)`. +2. Frontend React: + - Componente `Composer` con Mantine `Textarea` + toolbar markdown. + - Hotkeys: Cmd+B/I/K, Cmd+Enter para enviar, Esc cancel edit. + - Drag&drop zone over Composer + paste image desde clipboard. + - `EmojiPicker` (reusar `@emoji-mart/react` o componente propio `@fn_library`). + - `ReactionBar` debajo de EventBubble con aggregates. + - Thread panel lateral (abrir click en evento "X replies"). + - Voice messages: graba con `MediaRecorder` (opus codec), upload + send con `org.matrix.msc3245.voice` flag. +3. Tests: + - `e2e/test_send_markdown.sh` — `**bold**` aparece negrita en otro cliente. + - `e2e/test_edit_message.sh` — edicion aparece in-place en Element Web. + - `e2e/test_reaction.sh` — reaccion emoji propagada bidireccional. + +## Funciones del registry a crear + +- `markdown_to_matrix_html_go_core` — `goldmark` con sanitizer Matrix-compatible. +- `Composer_ts_ui` — componente Mantine + dropzone. +- `EmojiPicker_ts_ui` — wrapper picker emoji. +- `ReactionBar_ts_ui` — componente reactions aggregadas. + +## Acceptance + +- [ ] Mensaje markdown `**negrita** _cursiva_` se ve formateado en Element Web. +- [ ] Reply quote aparece referenciando el msg padre. +- [ ] Edit cambia el msg in-place en ambos clientes. +- [ ] Reaccion emoji con click aparece como counter agregado. +- [ ] Upload imagen (PNG 2MB) se ve thumbnail + click abre full. +- [ ] Voice msg grabado 5s reproduce OK en Element Web. +- [ ] Thread: 5 replies anidados se muestran en panel lateral. + +## Notas + +- Sanitizer HTML: usar allowlist Matrix (b, i, em, strong, a[href], code, pre, blockquote, ul, ol, li, br, p, h1-h6). NO permitir `` bloqueado por sandbox. +- [ ] `agents_and_robots` publica widget panel y se ve embebido en el room del agente. +- [ ] Widget kanban inline funciona: drag&drop card persiste en DB del kanban. + +## Notas + +**Anti-criterios:** +- NO permitir `javascript:` ni `data:text/html` URLs (XSS). +- NO conceder capabilities sin consentimiento explicito del usuario (auditable). +- NO compartir el access_token Matrix del usuario al widget — usar siempre tokens scoped efimeros. + +**Decisiones:** +- Widget API v2 (no v1) — soporta capabilities + tokens scoped. +- iframe sandbox sin `allow-top-navigation` (previene escape). +- CSP `frame-src https:` + permitir `data:`/`blob:` solo para widgets internos firmados. + +**Roadmap post-DoD:** +- Widget marketplace interno: `widget-catalog` en `agents_and_robots` con widgets internos descubribles. +- Widget templates: un agente publica un widget HTML estatico subido al room (`mxc://`) y el cliente lo renderiza desde la URL `mxc -> http`. +- Cross-room widgets: widget que persiste entre rooms (TBD, requiere MSC propio). diff --git a/dev/issues/0153-matrix-client-pc-agent-integration.md b/dev/issues/0153-matrix-client-pc-agent-integration.md new file mode 100644 index 00000000..d76bcd43 --- /dev/null +++ b/dev/issues/0153-matrix-client-pc-agent-integration.md @@ -0,0 +1,61 @@ +--- +id: "0153" +title: "matrix-client-pc agent integration: paneles para rooms operados por agentes" +status: pending +priority: medium +created: 2026-05-24 +related_flows: ["0010", "0009"] +related_issues: ["0152"] +dependencies: ["0152"] +tags: [matrix, agents, agents_and_robots, dashboard, sse, device_agent] +--- + +## Objetivo + +Integracion nativa con `agents_and_robots` + `agents_dashboard` + futuro `device_agent` (flow 0009 mesh). Detectar que un room esta operado por un agente Matrix conocido (via state event custom `m.agent.metadata`) y mostrar panel lateral con info del agente: uptime, ultima ejecucion, cola de tasks, last_error, boton restart, view logs en vivo (SSE). Atajos: enviar slash commands del agente (`/agent restart`, `/agent skill `). + +## Tareas + +1. Backend Go: + - `MatrixService.GetAgentMetadata(roomID) -> *AgentMetadata` — lee state event `m.agent.metadata` que el agente publica al arrancar. + - `MatrixService.SubscribeAgentLogs(agentID) -> chan LogLine` — SSE proxy al endpoint `agents_and_robots /api/agents//logs` ya existente (issue 0113). + - Llamadas REST proxy a `agents_and_robots`: `RestartAgent(agentID)`, `ListSkills(agentID)`, `TriggerSkill(agentID, skill, args)`. +2. Frontend React: + - Hook `useAgentMetadata(roomID)` — devuelve `null` si no es room de agente. + - Componente `AgentPanel` (panel lateral colapsable, solo visible si hay agentMetadata): + - Card con avatar, nombre, version, uptime, status (running/stopped/error). + - Tabs: "Logs" (live SSE), "Skills" (lista de skills disponibles + boton trigger), "Config" (read-only del config.yaml del agente). + - Boton restart con confirmacion. + - Componente `LogStream` — termtinal-like log viewer con auto-scroll + filtro grep. + - Slash commands custom: `/agent restart`, `/agent skill `, `/agent logs`. +3. Cuando flow 0009 (mesh) este vivo: + - Detectar `device_agent` rooms (state event `m.device.metadata` con tipo `device_agent`). + - Panel especifico `DevicePanel`: hostname, OS, kernel, IP mesh WG, capabilities firmadas, ultimo heartbeat. + - Slash commands: `/device shell ` (si capability permite), `/device fs ls `, `/device camera capture`. +4. Tests: + - `e2e/test_agent_panel_basic.sh` — entrar a room de `welcome-bot`, panel agente visible con info correcta. + - `e2e/test_agent_logs_live.sh` — boton "view logs" stream logs en tiempo real (5s). + - `e2e/test_agent_restart.sh` — restart desde panel + verificar agente vuelve online. + +## Funciones del registry a crear + +- `matrix_agent_metadata_go_infra` — leer/publicar state event `m.agent.metadata`. +- `agents_and_robots_client_go_infra` — wrapper REST + SSE del API de `agents_and_robots`. +- `AgentPanel_ts_ui` — panel lateral Mantine con tabs. +- `LogStream_ts_ui` — viewer logs SSE. +- `DevicePanel_ts_ui` — panel device_agent (cuando flow 0009 vivo). + +## Acceptance + +- [ ] Room operado por agente conocido muestra `AgentPanel` automatico. +- [ ] Logs en vivo del agente aparecen en panel (SSE). +- [ ] Restart desde panel funciona end-to-end. +- [ ] Slash `/agent skill greet` ejecuta skill remota y respuesta llega como msg al room. +- [ ] Room NO operado por agente: panel oculto (no clutter). + +## Notas + +- State event `m.agent.metadata` format: `{ agent_id, version, capabilities[], owner, repo_url }`. Documentar en `projects/element_agents/docs/agent_metadata.md`. +- SSE proxy: el cliente PC habla a `agents_and_robots` via su DNS publica (`agents.organic-machine.com`) con auth Bearer (token del usuario Matrix + scope `agent_panel`). +- Permisos: solo el `owner` declarado en el agente puede ejecutar restart/trigger. Otros users del room solo leen. +- Gotcha: si el agente se rebuilds y cambia `agent_id`, el state event queda obsoleto — necesita TTL o heartbeat. diff --git a/dev/issues/0154-matrix-client-android-scaffold.md b/dev/issues/0154-matrix-client-android-scaffold.md new file mode 100644 index 00000000..074f4df4 --- /dev/null +++ b/dev/issues/0154-matrix-client-android-scaffold.md @@ -0,0 +1,65 @@ +--- +id: "0154" +title: "matrix-client-android scaffold: Kotlin + Compose + login MAS" +status: pending +priority: high +created: 2026-05-24 +related_flows: ["0011"] +related_issues: ["0155", "0162"] +dependencies: ["0162"] +tags: [matrix, android, kotlin, compose, mas, oidc, scaffold] +--- + +## Objetivo + +Crear `projects/element_agents/apps/matrix_client_android/` con `init_kotlin_app` (pipeline ya existente del registry). Configurar Compose + Material 3 + tema propio. Implementar login MAS OIDC via Chrome Custom Tabs. Tokens persistidos en EncryptedSharedPreferences. Resultado: APK debug que abre Custom Tab al MAS, retorna con token y muestra perfil del usuario. + +## Tareas + +1. `./fn run init_kotlin_app matrix_client_android` — usa pipeline existente del registry (ver issues completados 0073-0078). +2. Sub-repo Gitea: `git init -b master` + crear `dataforge/matrix_client_android` + push inicial. **Antes** de salir del worktree (ver `apps_subrepo.md`). +3. `app.md` con frontmatter: + - `lang: kotlin`, `framework: jetpack-compose`, `dir_path: projects/element_agents/apps/matrix_client_android`. + - `tags: [matrix, android, kotlin, compose]`. + - `uses_functions: []` (irlo rellenando issue a issue). +4. `build.gradle.kts`: + - `compileSdk = 34`, `minSdk = 28`, `targetSdk = 34`. + - Compose BOM `2024.x`. + - `matrix-rust-sdk` Kotlin bindings (`org.matrix.rustcomponents:sdk-android:0.x`). + - `androidx.security:security-crypto` para EncryptedSharedPreferences. + - `androidx.browser:browser` para Chrome Custom Tabs. +5. Login MAS: + - `LoginActivity` con boton "Sign in with Matrix". + - Generar PKCE code_verifier + state. + - Abrir Chrome Custom Tab a `/oauth/authorize?...`. + - `MainActivity` con intent-filter para `matrix-client-android://callback` redirect. + - Intercambiar code -> access_token + refresh_token. + - Guardar en EncryptedSharedPreferences (`SecurityCryptoUserPrefs`). +6. `HomeScreen` Compose con `Text("Hola @")` + boton Logout. +7. Tema Material 3 propio (paleta accent acorde a flow 0010 cliente PC para coherencia). +8. Test instrumented: `LoginInstrumentedTest` que mocka MAS y verifica flow callback -> token saved. + +## Funciones del registry a crear + +- `matrix_client_kotlin_infra` — facade sobre `matrix-rust-sdk` (init, login, sync, logout). +- `mas_oidc_kotlin_infra` — Chrome Custom Tabs + PKCE + callback handler. +- `encrypted_prefs_kotlin_core` — wrapper EncryptedSharedPreferences (idempotente, generic put/get). +- `LoginScreen_kotlin_ui` — Compose screen Material 3. +- `HomeScreen_kotlin_ui` — Compose screen perfil + logout. + +## Acceptance + +- [ ] `./gradlew assembleDebug` produce APK valido. +- [ ] APK instala en Android 9+ y arranca. +- [ ] Login: boton -> Custom Tab MAS -> consent -> callback -> perfil visible. +- [ ] Token persiste entre re-aperturas (no re-login si vigente). +- [ ] `app.md` con frontmatter completo + 5 `uses_functions`. +- [ ] Sub-repo `dataforge/matrix_client_android` con commit inicial. +- [ ] Test instrumented `LoginInstrumentedTest` pasa en emulator API 31. + +## Notas + +- Chrome Custom Tabs > WebView para OAuth (security: comparte cookies con browser principal del user, mejor UX). +- Refresh token: implementar refresh proactivo 5min antes de expiry (corutina + WorkManager periodic). +- Gotcha conocido (ver issue 0074): `local.properties` con `sdk.dir` obligatorio en setup nuevo. El scaffolder lo crea. +- Gotcha (issue 0075): Material 3 sin AppCompat — usar `MaterialTheme` directamente, no `Theme.AppCompat.*`. diff --git a/dev/issues/0155-matrix-client-android-rooms-timeline.md b/dev/issues/0155-matrix-client-android-rooms-timeline.md new file mode 100644 index 00000000..727e6dee --- /dev/null +++ b/dev/issues/0155-matrix-client-android-rooms-timeline.md @@ -0,0 +1,63 @@ +--- +id: "0155" +title: "matrix-client-android rooms list + timeline Compose" +status: pending +priority: high +created: 2026-05-24 +related_flows: ["0011"] +related_issues: ["0154", "0156"] +dependencies: ["0154"] +tags: [matrix, android, compose, sync, timeline, rooms] +--- + +## Objetivo + +UI Compose con `Scaffold` que muestre sidebar drawer con rooms y panel principal con timeline. Sync via `matrix-rust-sdk` (corrutinas + Flow). `LazyColumn` virtualizado para timeline (perf con miles de mensajes). Swipe-to-react en mensajes. Optimistic UI al enviar (en issue 0156). + +## Tareas + +1. ViewModels: + - `RoomsViewModel(matrixClient)` — expone `StateFlow>`. Ordenado por `lastActivity`. + - `TimelineViewModel(matrixClient, roomId)` — expone `StateFlow>` + `loadMore()`. + - Persistencia local con Room DB (`androidx.room`) — store rooms + last sync token. +2. Compose: + - `MainScreen` con `ModalNavigationDrawer`: + - Drawer: `RoomList` (LazyColumn con `RoomItem`: avatar, name, last preview, unread badge). + - Content: `TimelineScreen(roomId)`. + - `TimelineScreen`: + - `LazyColumn` con `reverseLayout = true` (mensajes recientes abajo). + - `key = { it.eventId }` para evitar re-composiciones. + - `LaunchedEffect` con `LazyListState` -> al llegar al top, `viewModel.loadMore()`. + - `EventBubble` composables segun tipo (text, image, file, redacted). + - `Avatar` composable reusable con cache de imagenes (`Coil`). +3. Sync engine: + - `MatrixSyncService` (corrutina supervisor scope) que mantiene `client.syncStream()`. + - Si pasa a background sin call activa, sync se pausa hasta que vuelve foreground (lifecycle-aware). + - Errores de red: backoff exponencial (1s, 2s, 4s ... 60s max). +4. Tests: + - Instrumented `RoomsListTest` — 3 rooms aparecen en drawer. + - Instrumented `TimelinePaginationTest` — scroll-up carga 50 msgs anteriores. + +## Funciones del registry a crear + +- `matrix_room_summary_kotlin_infra` — extract `RoomSummary` de matrix-rust-sdk. +- `matrix_timeline_kotlin_infra` — Flow de eventos paginados. +- `RoomListScreen_kotlin_ui` — Compose drawer rooms. +- `TimelineScreen_kotlin_ui` — Compose timeline virtualizado. +- `EventBubble_kotlin_ui` — composable burbuja msg. + +## Acceptance + +- [ ] Drawer lista rooms del usuario test. +- [ ] Click en room muestra timeline ultimos 50 msgs. +- [ ] Swipe arriba carga msgs anteriores sin gap. +- [ ] Msg enviado desde PC (Wails) aparece en Android en <2s. +- [ ] Avion mode + restore: sync resume, no msgs perdidos. +- [ ] Cerrar app + reopen: state restaurado desde Room DB, no full re-sync. + +## Notas + +- `matrix-rust-sdk` ya gestiona persistencia interna (SQLite + crypto store). Room DB local solo para datos UI-rapidos (room summaries, unread counters). +- Read receipts: TBD otro issue. +- DMs detectados via `m.direct` account data. +- Spaces: `RoomItem` con icono diferente, colapsable. diff --git a/dev/issues/0156-matrix-client-android-composer.md b/dev/issues/0156-matrix-client-android-composer.md new file mode 100644 index 00000000..99548a66 --- /dev/null +++ b/dev/issues/0156-matrix-client-android-composer.md @@ -0,0 +1,64 @@ +--- +id: "0156" +title: "matrix-client-android composer: markdown, replies, edits, reactions, media" +status: pending +priority: high +created: 2026-05-24 +related_flows: ["0011"] +related_issues: ["0155", "0157"] +dependencies: ["0155"] +tags: [matrix, android, compose, composer, markdown, media, voice] +--- + +## Objetivo + +Composer Compose con markdown shortcuts, replies, edits, reactions emoji, threads, upload media (camara nativa, galeria, voice msg con `MediaRecorder` opus). Drag&drop archivos compartidos via share sheet Android. + +## Tareas + +1. ViewModel: + - `ComposerViewModel(matrixClient, roomId)` — methods `sendText`, `sendReply`, `editMessage`, `sendReaction`, `uploadMedia`, `recordVoice`. +2. Compose: + - `Composer` con `OutlinedTextField` + toolbar (markdown shortcuts B/I/code). + - Hotkeys soft keyboard: Send action en IME. + - `AttachmentMenu`: botones camara, galeria, file, voice. + - `EmojiPicker` overlay (reusar libreria existente o componente propio). + - `ReactionBar` debajo de `EventBubble` con aggregates. + - `ThreadScreen` — nueva pantalla full para thread (no panel lateral como en PC, por screen real estate movil). + - Voice recording UI: hold-to-record con waveform preview + cancelar al deslizar. +3. Backend: + - Upload media: comprimir imagenes si >2MB antes de upload (`androidx.exifinterface` para preservar orientacion). + - Voice: `MediaRecorder` con OPUS, 32kbps, ogg container. + - Markdown -> HTML local con `markwon` library (lightweight, no Goldmark equivalente). +4. Share intent: + - `IntentFilter` para `android.intent.action.SEND` + tipos image/video/text/file -> abre composer del room seleccionado. +5. Tests: + - Instrumented `SendMarkdownTest` — `**bold**` formateado en Element Web. + - Instrumented `EditMessageTest` — edicion in-place propagada. + - Instrumented `VoiceMsgTest` — graba 5s + upload + play en Element Web. + +## Funciones del registry a crear + +- `markdown_to_matrix_html_kotlin_core` — wrapper markwon con sanitizer. +- `image_compress_kotlin_core` — resize + recompress JPEG. +- `voice_record_kotlin_infra` — MediaRecorder opus wrapper. +- `Composer_kotlin_ui` — Compose composer + toolbar + attachment menu. +- `ReactionBar_kotlin_ui` — composable reactions. +- `ThreadScreen_kotlin_ui` — pantalla thread. + +## Acceptance + +- [ ] Mensaje markdown se ve formateado en Element Web. +- [ ] Reply con quote del msg padre. +- [ ] Edit in-place propagado en ambos clientes. +- [ ] Reaccion emoji bidireccional. +- [ ] Upload imagen 5MB -> compresion a ~1MB -> envio + thumbnail OK. +- [ ] Voice msg 5s reproducible en Element Web. +- [ ] Share intent desde galeria abre composer con imagen pre-cargada. + +## Notas + +- Sanitizer HTML server-side delegado a matrix-rust-sdk (mismo allowlist que cliente PC). +- Voice msg: encode opus 32kbps, max 5min. +- Markwon vs goldmark: ambos cumplen el rol equivalente en su stack. Salida HTML compatible Matrix. +- Drag&drop: en Android = share sheet o picker, no drag&drop nativo como en PC. diff --git a/dev/issues/0157-matrix-client-android-e2ee.md b/dev/issues/0157-matrix-client-android-e2ee.md new file mode 100644 index 00000000..32e75e73 --- /dev/null +++ b/dev/issues/0157-matrix-client-android-e2ee.md @@ -0,0 +1,76 @@ +--- +id: "0157" +title: "matrix-client-android E2EE rust-sdk: cross-signing, SAS, recovery" +status: pending +priority: critical +created: 2026-05-24 +related_flows: ["0011"] +related_issues: ["0156", "0158"] +dependencies: ["0156"] +tags: [matrix, android, e2ee, rust-sdk, cross-signing, sas, security] +--- + +## Objetivo + +Encriptacion end-to-end con `matrix-rust-sdk` Kotlin bindings (mejor impl Olm/Megolm disponible). Cross-signing keys, SAS verification con emoji, recovery passphrase, key backup server-side. UI para verificar otros usuarios + manejar devices propios. + +## Tareas + +1. ViewModel: + - `SecurityViewModel(matrixClient)`: + - `bootstrapCrossSigning(passphrase)`. + - `recoverFromPassphrase(passphrase)`. + - `startVerification(userId, deviceId) -> VerificationSession`. + - `verifyEmoji(sessionId, accepted)`. + - `listOwnDevices() -> Flow>`. + - `backupMegolmKeys()`. +2. Compose: + - `OnboardingE2EEScreen` — wizard 3 pasos: generar passphrase, backup, verify primer device. + - `SettingsSecurityScreen`: + - Lista devices propios con badge verified/unverified. + - Dialog SAS con emoji grid 7x1 cuando hay verificacion en curso. + - Boton "Reset cross-signing" (destructive, requiere typing "RESET"). + - Boton "Restore from passphrase". + - `EventBubble` con icono shield (green/amber/red). + - Banner room con "X devices not verified" si aplica. +3. Crypto store: + - `matrix-rust-sdk` gestiona internamente. Solo asegurar que `applicationContext.filesDir` es persistente entre upgrades. + - Backup local del store (export encriptado) antes de uninstall: feature opcional via "Export to file" en settings. +4. Tests: + - Instrumented `BootstrapCrossSigningTest`. + - Instrumented `VerificationSASTest` con mock peer. + - Instrumented `RecoveryFromPassphraseTest`. + - E2E manual con Element Web: enviar/recibir msg E2EE, verificar device cross-platform. + +## Funciones del registry a crear + +- `matrix_e2ee_kotlin_infra` — wrapper rust-sdk encryption module. +- `passphrase_derive_key_kotlin_core` — PBKDF2 wrapper. +- `VerificationDialog_kotlin_ui` — Compose emoji grid SAS. +- `OnboardingE2EEScreen_kotlin_ui` — wizard. +- `SettingsSecurityScreen_kotlin_ui` — devices + verification UI. + +## Acceptance + +- [ ] Bootstrap crea cross-signing keys + sube cifradas. +- [ ] Msg enviado en room E2EE se descifra en Element Web + cliente PC Wails (y al reves). +- [ ] SAS verification con emoji grid vs Element Web: ambos 7 emojis iguales, accept funciona. +- [ ] Login device nuevo + restore passphrase recupera msgs historicos. +- [ ] Device no verificado dispara shield amber en EventBubble. +- [ ] Decryption failure muestra shield rojo + boton "Request key". + +## Notas + +**Anti-criterios:** +- NO marcar done si E2EE silent-falla (mensaje no descifrado pero sin warning visible). +- NO marcar done si passphrase queda en plain text en disco. +- NO marcar done si cross-signing no funciona contra cliente PC Wails (interop critica). + +**Decisiones:** +- `matrix-rust-sdk` >> matrix-android-sdk2 (deprecated). Olm/Megolm en Rust = mejor perf + sin memory leaks. +- Passphrase format igual que cliente PC (4 palabras Diceware o 12-byte base32). + +**Gotchas:** +- Key rotation Megolm: rust-sdk lo gestiona, pero monitorizar logs en primera semana de uso real. +- Olm sessions max: rust-sdk auto-rotate, no accion manual. +- Devices nuevos sin passphrase: msgs pre-existentes NO se descifran. UI debe ser clara. diff --git a/dev/issues/0158-matrix-client-android-livekit-calls.md b/dev/issues/0158-matrix-client-android-livekit-calls.md new file mode 100644 index 00000000..8166155b --- /dev/null +++ b/dev/issues/0158-matrix-client-android-livekit-calls.md @@ -0,0 +1,73 @@ +--- +id: "0158" +title: "matrix-client-android calls LiveKit nativo: mic/cam/screen + PiP" +status: pending +priority: high +created: 2026-05-24 +related_flows: ["0011"] +related_issues: ["0157", "0159", "0161"] +dependencies: ["0157"] +tags: [matrix, android, livekit, calls, webrtc, pip, audio-focus] +--- + +## Objetivo + +Llamadas nativas via `io.livekit:livekit-android` SDK oficial. Codecs HW (H.264/VP9 hardware decoder), audio focus + AEC/NS nativos, MediaSession para controls en lockscreen, Picture-in-Picture mode Android nativo. Soporta 1:1 + grupales (limite 16 del LiveKit config actual). + +## Tareas + +1. Backend (compartido con cliente PC): + - Reusar `livekit_token_gen_go_infra` que esta en flow 0010. + - Cliente Android pide token al mismo endpoint `/api/call/token` que el cliente PC. +2. ViewModel: + - `CallViewModel(matrixClient, roomId)`: + - `joinCall()` — pide token + conecta `Room.connect()`. + - `toggleMic()`, `toggleCamera()`, `toggleScreenShare()`. + - `hangup()`. + - `Flow` con participants, tracks, connection state. +3. Compose: + - `CallScreen` fullscreen: + - Grid tiles participantes (`Flow` layout responsive 1/2/4/9/16). + - Tile principal: active speaker (track audio level del SDK). + - Controles bottom: mic, cam, screen, raise hand, hangup. + - `IncomingCallScreen` fullscreen con accept/decline (system overlay activity). + - `CallTile` composable con `VideoView` (SurfaceViewRenderer del SDK). +4. PiP (Picture-in-Picture): + - `Activity` con `setPictureInPictureParams()`. + - Auto-enter PiP al minimizar la app durante call. + - PiP tile: video remoto + boton hangup. +5. Audio routing: + - `AudioFocusRequest` (Android 8+) — focus exclusivo durante call. + - Switch speaker/earpiece/bluetooth via `AudioManager.setSpeakerphoneOn()` + connection state listeners para audifonos BT. + - Echo cancellation + noise suppression: SDK los habilita por defecto, verificar. +6. ICE/TURN: igual que cliente PC, depende del LiveKit config server-side. +7. Tests: + - Instrumented `Call1to1Test` con emulator + segundo cliente (PC) — connect, video, hangup. + - Manual `ScreenShareTest` con device fisico. + - Manual `4ParticipantsTest`. + - Manual `PiPTest` — call activa + Home button -> PiP aparece. + +## Funciones del registry a crear + +- `livekit_call_kotlin_infra` — wrapper `Room` SDK + permission helpers. +- `audio_routing_kotlin_infra` — speaker/earpiece/BT switching. +- `CallScreen_kotlin_ui` — fullscreen call UI. +- `CallTile_kotlin_ui` — tile con VideoView. +- `IncomingCallScreen_kotlin_ui` — accept/decline overlay activity. + +## Acceptance + +- [ ] Start call desde Android -> PC Wails recibe y conecta. +- [ ] 30s call con video+audio nativo (verificar HW codec via `adb shell dumpsys media.codec`). +- [ ] Mute mic + apagar cam refleja en otro cliente. +- [ ] Screen share desde Android (con `MediaProjection`) visible en PC. +- [ ] PiP: minimizar app durante call -> tile flotante con video remoto. +- [ ] Bluetooth headphones: cambio automatico al conectar/desconectar. +- [ ] Battery: call 30min con AC + WiFi <15% drain. + +## Notas + +- Permissions runtime: `RECORD_AUDIO`, `CAMERA`, `POST_NOTIFICATIONS` (Android 13+), `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MEDIA_PROJECTION` (Android 14+). +- Foreground service requerido para mantener call con app en background (issue 0161). +- E2EE en call (insertable streams): TBD post-DoD, igual que en cliente PC. +- Connection service Android (sistema): TBD, opcional. Permite integracion con dialer system + Bluetooth Car. Valorar coste/beneficio. diff --git a/dev/issues/0159-matrix-client-android-push-fcm.md b/dev/issues/0159-matrix-client-android-push-fcm.md new file mode 100644 index 00000000..9984457d --- /dev/null +++ b/dev/issues/0159-matrix-client-android-push-fcm.md @@ -0,0 +1,80 @@ +--- +id: "0159" +title: "matrix-client-android push FCM via sygnal + Firebase setup" +status: pending +priority: high +created: 2026-05-24 +related_flows: ["0011"] +related_issues: ["0158", "0160"] +dependencies: ["0154"] +tags: [matrix, android, push, fcm, firebase, sygnal, infra] +--- + +## Objetivo + +Notificaciones push moviles via FCM (Firebase Cloud Messaging) usando `sygnal` (push gateway oficial de Matrix). Sygnal recibe push events de Synapse, traduce a payload FCM, enviado a Firebase, entregado al device. La app despierta para mostrar notificacion del mensaje, o trigger ringer para incoming calls. App en background o muerta tambien recibe. + +## Tareas + +1. Infra (modifica `element_matrix_chat` app): + - Anadir container `sygnal` al `docker-compose.yml`. Config en `configs/sygnal.yaml`. + - Service account JSON de Firebase en `configs/firebase-sa.json` (gitignored, instalado en VPS via secrets). + - Synapse config: pushers habilitados (ya por defecto). + - Reverse proxy: `https://push-.organic-machine.com/_matrix/push/v1/notify` -> sygnal:5000. + - Documentar setup en `projects/element_agents/apps/element_matrix_chat/docs/sygnal_setup.md`. +2. Firebase: + - Crear proyecto `fn-registry-matrix-push` en Firebase console. + - Habilitar Cloud Messaging. + - Generar service account JSON. + - Anadir `google-services.json` al modulo Android (`app/google-services.json`). +3. Android app: + - `build.gradle`: `com.google.gms:google-services`, `com.google.firebase:firebase-messaging`. + - `FirebaseMessagingService` subclass: + - `onNewToken(token)` -> registrar en sygnal via Synapse Pusher API `POST /_matrix/client/v3/pushers/set`. + - `onMessageReceived(message)` -> parse data payload + mostrar notif. + - Notification channels (Android 8+): + - `messages` — IMPORTANCE_HIGH, sonido. + - `calls` — IMPORTANCE_HIGH, full-screen intent (despertar pantalla). + - `silent` — IMPORTANCE_LOW. + - VoIP push para calls: payload con `prio=high`, `event_id_only=false` (incluir event para mostrar caller info sin sync completo). +4. Tests: + - Instrumented `FCMTokenRegistrationTest` — mock Firebase, verificar pusher creado en Synapse. + - Manual `PushDeliveryTest` — enviar msg desde Element Web a Android offline -> push aparece <3s. + - Manual `PushCallTest` — start call desde PC -> Android offline despierta + ring. + - Manual `PushBatterySaverTest` — Android en battery saver + Doze mode + push sigue llegando. + +## Funciones del registry a crear + +- `sygnal_setup_bash_infra` — script setup container sygnal en VPS. +- `sygnal_config_template_go_infra` — generador `sygnal.yaml` con Firebase SA. +- `fcm_register_kotlin_infra` — onNewToken + register en Synapse Pusher API. +- `synapse_pusher_set_go_infra` — Go helper REST `POST /pushers/set` (reutilizable PC + Android). +- `NotificationBuilder_kotlin_ui` — helper notification channels + actions. + +## Acceptance + +- [ ] Container `sygnal` activo en VPS, health check `:5000/_matrix/push/v1/notify` HEAD 200. +- [ ] Firebase project creado + SA JSON instalada en VPS. +- [ ] App Android registra FCM token + crea pusher en Synapse al primer login. +- [ ] Msg desde Element Web a Android (app cerrada por user) -> push notif en <3s. +- [ ] Start call desde cliente PC -> Android offline despierta + ring 30s. +- [ ] Battery saver activo: push sigue llegando (FCM high priority bypasses Doze). +- [ ] Multiple users: pusher por device, no se cruzan. + +## Notas + +**Gotcha critico:** FCM no entrega push si: +- App ha sido force-stopped por user (system requirement). +- Device tiene "Restricted background usage" en battery settings. +- Account Google no esta sincronizada en el device. +Documentar en onboarding para que el user lo entienda. + +**Privacy:** payload FCM no debe contener contenido del msg en claro (Synapse E2EE). Solo: `room_id`, `event_id`, `unread_count`, `prio`. App hace sync interno al recibir push para obtener msg cifrado y descifrar local. + +**Coste:** FCM gratis para hosting Firebase. Sygnal CPU/RAM despreciable (<50MB). + +**Alternativas exploradas:** +- UnifiedPush + ntfy: open-source, sin Google. Pro: privacy. Con: requiere infraestructura propia + onboarding mas duro. Post-DoD considerar como segunda opcion para users sin Google Play. + +**Decisiones futuras (post-DoD):** +- iOS equivalent: APNs via sygnal mismo gateway. Cuando llegue cliente iOS. diff --git a/dev/issues/0160-matrix-client-android-mini-webapps.md b/dev/issues/0160-matrix-client-android-mini-webapps.md new file mode 100644 index 00000000..e3ff733d --- /dev/null +++ b/dev/issues/0160-matrix-client-android-mini-webapps.md @@ -0,0 +1,83 @@ +--- +id: "0160" +title: "matrix-client-android mini-webapps: WebView + Widget API v2 bridge" +status: pending +priority: medium +created: 2026-05-24 +related_flows: ["0011"] +related_issues: ["0159", "0161"] +dependencies: ["0159"] +tags: [matrix, android, webview, widgets, agents, sandbox] +--- + +## Objetivo + +Host de widgets en Android equivalente al cliente PC (issue 0152). Mismo contrato Widget API v2. WebView con sandbox estricto + bridge JS-Kotlin implementa capabilities API. Widgets de los rooms operados por agentes (`agents_and_robots`) se ven embebidos: dashboard, formulario, kanban inline, control del agente. + +## Tareas + +1. ViewModel: + - `WidgetsViewModel(matrixClient, roomId)`: + - `Flow>` desde state events `m.widget` del room. + - `addWidget(widget)`, `removeWidget(widgetId)`. + - `generateUrl(widget) -> String` — substituye placeholders Matrix Widget API. + - `mintScopedToken(widgetId) -> String` — token efimero scope room+widget. +2. Compose: + - `WidgetsPanel` (drawer lateral o bottom sheet en movil): + - Tabs con widgets activos del room. + - Cada tab = `WidgetView` que envuelve un `WebView`. + - `WidgetView` composable: + - `WebView` configurado: + - `settings.javaScriptEnabled = true`. + - `settings.allowFileAccess = false`. + - `settings.allowContentAccess = false`. + - `settings.allowFileAccessFromFileURLs = false`. + - `settings.allowUniversalAccessFromFileURLs = false`. + - `settings.mixedContentMode = MIXED_CONTENT_NEVER_ALLOW`. + - `webViewClient` con CSP injection + URL allowlist. + - `addJavascriptInterface(WidgetBridge, "MatrixWidgetBridge")` — bridge expone Widget API v2. + - `CapabilityConsentDialog` Compose — pide consentimiento usuario para capabilities. +3. WidgetBridge (Kotlin): + - Implementa capabilities handshake postMessage (igual contrato que cliente PC): + - `read_events`, `send_event`, `send_to_device`, `get_openid`, `m.always_on_screen`. + - Audit log mensajes JS<->Kotlin en local DB. + - Whitelist estricta de capabilities concedidas. +4. Widgets internos primer batch (compartidos con cliente PC): + - `widget-agent-panel` — control del agente. + - `widget-kanban` — kanban inline. + - `widget-issue-tracker`. +5. Tests: + - Instrumented `WidgetCapabilitiesTest` — dialog aparece + accept/decline funciona. + - Instrumented `WidgetSandboxTest` — widget malicioso (intenta `window.location='file:///etc/passwd'`) bloqueado. + - Instrumented `WidgetSendEventTest` — widget con capability envia msg. + +## Funciones del registry a crear + +- `WidgetView_kotlin_ui` — Compose WebView wrapper sandboxed. +- `widget_bridge_kotlin_infra` — JavascriptInterface implementando Widget API v2. +- `widget_url_template_kotlin_core` — substituyente placeholders (puede compartirse logica con la Go version del PC, contrato identico). +- `CapabilityConsentDialog_kotlin_ui` — Compose dialog. +- `widget_audit_log_kotlin_infra` — append-only audit log en Room DB. + +## Acceptance + +- [ ] Widget publicado desde cliente PC se ve embebido en Android (mismo room). +- [ ] Capability handshake: widget pide `send_event` -> dialog Compose -> accept -> widget envia msg. +- [ ] Sandbox: widget intenta `XMLHttpRequest` a `file:///` -> bloqueado. +- [ ] Widget agent-panel funcional: muestra logs en vivo del agente + boton restart. +- [ ] Audit log persiste en Room DB con timestamp + capability + accept/deny. + +## Notas + +**Critico:** +- Mismo contrato Widget API v2 que cliente PC. Widget HTML escrito una vez funciona en ambos. +- WebView Android moderno (Chromium 100+) soporta WebRTC + WebGL + service workers. Suficiente para widgets ricos. + +**Gotcha:** +- `WebView.addJavascriptInterface` solo seguro en Android 4.2+ (API 17+, ya minSdk=28). Pero validar todo input desde JS — nunca confiar. +- `setAllowFileAccessFromFileURLs(false)` solo aplica si la URL del widget es `file://`. Nuestros widgets son `https://` -> hardcode CSP estricta. +- Memory: WebView por tab + 5 widgets activos = ~200MB facil. Limitar a max 3 widgets simultaneos activos. + +**Roadmap post-DoD:** +- Widget marketplace catalog accesible via menu. +- "Add to home screen" PWA mode para widgets favoritos (Android shortcut + launcher icon dedicado). diff --git a/dev/issues/0161-matrix-client-android-foreground-service.md b/dev/issues/0161-matrix-client-android-foreground-service.md new file mode 100644 index 00000000..97952f2d --- /dev/null +++ b/dev/issues/0161-matrix-client-android-foreground-service.md @@ -0,0 +1,89 @@ +--- +id: "0161" +title: "matrix-client-android foreground service: calls + lifecycle + lockscreen" +status: pending +priority: high +created: 2026-05-24 +related_flows: ["0011"] +related_issues: ["0158", "0160"] +dependencies: ["0158"] +tags: [matrix, android, foreground-service, lifecycle, mediasession, wakelock] +--- + +## Objetivo + +`CallForegroundService` que mantiene call activa con app en background o pantalla bloqueada. Notification ongoing visible mientras dura la call. `MediaSession` para integrar con lockscreen controls + Bluetooth Car (mute, hangup desde audio device). Wakelock controlado para evitar drain excesivo. Notificaciones full-screen intent para incoming calls (despiertan pantalla). + +## Tareas + +1. `CallForegroundService` (`android.app.Service`): + - `START_FOREGROUND_SERVICE` con type `MEDIA_PROJECTION` o `PHONE_CALL` (Android 14+ requiere type explicito). + - `Notification.Builder` channel `calls` con: + - Custom view con caller name, duration, mute/hangup buttons. + - `setOngoing(true)`. + - `setCategory(CATEGORY_CALL)`. + - Lifecycle: `START_STICKY` para reiniciar si OS lo mata (raro con foreground). +2. `MediaSession` integration: + - `MediaSessionCompat` con play/pause/stop actions mapeados a mute/unmute/hangup. + - Bluetooth Car media controls. + - Lockscreen controls visibles si dispositivo lo soporta. +3. Wakelock: + - `PowerManager.PARTIAL_WAKE_LOCK` durante call activa. + - `WAKE_LOCK_KEY = "matrix_client:call"` para audit en `dumpsys power`. + - Liberar inmediato al hangup. + - Proximity wakelock (`PROXIMITY_SCREEN_OFF_WAKE_LOCK`) si call solo audio + telefono pegado a oreja. +4. Incoming call full-screen intent: + - `Notification` con `setFullScreenIntent(pendingIntent, true)`. + - Activity `IncomingCallActivity` con `showWhenLocked(true)` + `turnScreenOn(true)`. + - Compose UI fullscreen con accept/decline. +5. Doze mode handling: + - `ACTION_IGNORE_BATTERY_OPTIMIZATIONS` solicitar al user en onboarding (no obligatorio, solo para calls fiables). + - Documentar tradeoff en pantalla onboarding. +6. Battery monitoring: + - Log custom: call duration + battery_drain_pct al hangup. + - Visible en `Settings > Diagnostics` para debug. +7. Tests: + - Manual `CallBackgroundTest` — start call + Home button -> notif visible + audio sigue. + - Manual `CallLockscreenTest` — call + power button -> pantalla apaga + audio sigue + lockscreen controls visibles. + - Manual `IncomingFullScreenTest` — device en lockscreen + incoming call -> pantalla despierta + UI accept/decline. + - Manual `BluetoothCarTest` — Bluetooth Car connected + call active + mute desde steering wheel funciona. + - Manual `BatteryTest` — call 30min en background + WiFi + AC -> drain <15%. + +## Funciones del registry a crear + +- `CallForegroundService_kotlin_infra` — service completo. +- `media_session_kotlin_infra` — wrapper MediaSessionCompat. +- `wakelock_manager_kotlin_infra` — adquirir/liberar wakelocks de forma idempotente. +- `IncomingCallActivity_kotlin_ui` — Compose fullscreen activity. +- `battery_monitor_kotlin_infra` — log drain por session. + +## Acceptance + +- [ ] Call activa + Home -> notif ongoing visible + audio sigue 30s. +- [ ] Call + power button -> lockscreen muestra controls + audio sigue. +- [ ] Incoming call con pantalla apagada -> despierta + UI accept/decline. +- [ ] Bluetooth Car: mute/hangup desde steering wheel funciona. +- [ ] Hangup libera wakelocks (verificar con `dumpsys power | grep matrix_client`). +- [ ] Battery saver activo: call no se corta (foreground service exempt). +- [ ] Call 30min background: drain <15% con WiFi+AC. + +## Notas + +**Anti-criterios:** +- NO marcar done si call se corta a los 5min en background (battery optimization kill). +- NO marcar done si wakelock queda colgado tras hangup (battery leak). +- NO marcar done si lockscreen no muestra controls (UX critico para calls largas). + +**Gotchas Android 14+:** +- Foreground service type DEBE declararse en manifest + runtime: `phoneCall|mediaProjection`. +- `POST_NOTIFICATIONS` runtime permission (Android 13+). +- `USE_FULL_SCREEN_INTENT` runtime permission (Android 14+) — pedir explicito. + +**Decisiones:** +- Telecom framework (ConnectionService): NO en esta iteracion. Pro: integracion dialer nativo. Con: bug-prone, requiere CALL_PHONE permission con justificacion Play Store. Post-DoD considerar. +- Audio focus exclusivo durante call (issue 0158 ya lo cubre). + +**Battery optimization onboarding:** +- Pantalla en primer launch: explicar por que pedimos exempt battery optimization (calls fiables). +- Boton "Open settings" -> `Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`. +- Si user declina: app funciona pero documentar que calls largas pueden cortarse. diff --git a/dev/issues/0162-matrix-enable-mas-delegated-auth.md b/dev/issues/0162-matrix-enable-mas-delegated-auth.md new file mode 100644 index 00000000..035128e0 --- /dev/null +++ b/dev/issues/0162-matrix-enable-mas-delegated-auth.md @@ -0,0 +1,197 @@ +--- +id: "0162" +title: "Matrix: migrar Synapse a MAS como unico auth provider (MSC3861)" +status: pending +priority: critical +created: 2026-05-24 +related_flows: ["0010", "0011"] +related_issues: ["0147", "0154", "0163"] +dependencies: [] +tags: [matrix, mas, synapse, msc3861, auth, oidc, migration, infra] +--- + +## Objetivo + +Activar `matrix_authentication_service` en Synapse para que TODO login pase por MAS (Matrix Authentication Service) via MSC3861. Estado actual: MAS corre 6 semanas pero esta en pie sin clients registrados. Synapse usa login password legacy + application_service. Element Web, Synapse-Admin y clientes nuevos (flows 0010 + 0011) deben autenticarse exclusivamente contra MAS via OIDC. + +Bloquea flows 0010 (matrix-client-pc) + 0011 (matrix-client-android) porque ambos asumen MAS funcional. + +## Estado actual + +```yaml +# synapse_data/homeserver.yaml — comentado, NO activo: +# matrix_authentication_service: +# enabled: true +# endpoint: "http://mas:8080/" +# secret: "" + +experimental_features: + msc3266_enabled: true + msc4222_enabled: true + msc4354_enabled: true +# msc4108_delegation_endpoint: "https://auth-af2f3d.organic-machine.com/_matrix/client/unstable/org.matrix.msc4108/rendezvous" +``` + +```yaml +# mas/config.yaml +clients: [] # vacio +public_base: https://auth-af2f3d.organic-machine.com/ +``` + +``` +GET /_matrix/client/v3/login -> {"flows":[{"type":"m.login.password"},{"type":"m.login.application_service"}]} +GET /.well-known/matrix/client -> sin org.matrix.msc2965.authentication +``` + +## Tareas + +1. **Pre-migracion: backup completo** + - Snapshot postgres Synapse: `docker exec element_matrix_chat-postgres-1 pg_dump -U synapse synapse > /backup/synapse_$(date +%Y%m%d).sql`. + - Snapshot postgres MAS: idem `mas-postgres`. + - Snapshot `synapse_data/` + `mas/config.yaml`. + - Guardar backups en VPS local + descargar copia a PC. + +2. **Registrar clients en MAS** (`mas/config.yaml`): + - Cliente para Synapse (admin/internal): `client_id` + `client_secret` o `client_auth_method: client_secret_basic`. + - Cliente para Element Web: `redirect_uris: [https://element-a05ae4.organic-machine.com/]`. + - Cliente para nuevo admin panel (issue 0163): `redirect_uris: []`. + - Cliente para matrix_client_pc (flow 0010): `redirect_uris: [http://127.0.0.1:*]` (loopback dinamico). + - Cliente para matrix_client_android (flow 0011): `redirect_uris: [matrix-client-android://callback]`. + - Aplicar: `docker exec element_matrix_chat-mas-1 mas-cli config sync`. + +3. **Activar MSC3861 en Synapse**: + - Editar `synapse_data/homeserver.yaml`: + ```yaml + matrix_authentication_service: + enabled: true + endpoint: "http://mas:8080/" + secret: "" + experimental_features: + msc3861: + enabled: true + msc3266_enabled: true + msc4222_enabled: true + msc4354_enabled: true + msc4108_delegation_endpoint: "https://auth-af2f3d.organic-machine.com/_matrix/client/unstable/org.matrix.msc4108/rendezvous" + # Disable legacy password login: + password_config: + enabled: false + ``` + +4. **Migrar usuarios existentes Synapse -> MAS**: + - `docker exec element_matrix_chat-mas-1 mas-cli syn2mas --synapse-config /data/homeserver.yaml --dry-run` primero. + - Revisar log (conflictos, usuarios huerfanos). + - Ejecutar real: `mas-cli syn2mas --synapse-config /data/homeserver.yaml`. + - Verificar: contar usuarios `mas-postgres` vs `synapse-postgres`, deben coincidir. + +5. **Actualizar well-known** (`/.well-known/matrix/client`): + - Servido por `element_matrix_chat-wellknown-1` (nginx). + - Anadir: + ```json + "org.matrix.msc2965.authentication": { + "issuer": "https://auth-af2f3d.organic-machine.com/", + "account": "https://auth-af2f3d.organic-machine.com/account" + } + ``` + - Reload nginx. + +6. **Restart ordenado**: + - `docker compose restart mas` -> verificar logs sin errores 30s. + - `docker compose restart synapse` -> verificar `_matrix/client/v3/login` ahora devuelve `m.login.sso` con `identity_providers` apuntando a MAS. + - `docker compose restart element` (recarga config). + +7. **Reconfigurar Element Web** (`element-config.json`): + - Activar `oidc_native_flow: true` (Element Web soporta MSC3861 desde v1.11.50+). + - Verificar version Element Web (`docker exec element_matrix_chat-element-1 cat /etc/nginx/conf.d/element.json | head` o image tag) >= v1.11.50. + - Si version vieja: bump container image. + +8. **Verificar end-to-end**: + - Logout completo navegador. + - Abrir Element Web -> debe redirigir a MAS para login. + - Login con cuenta existente migrada -> redirect back a Element -> sesion activa. + - Comprobar rooms historicos siguen visibles + msgs E2EE descifrados (las cross-signing keys NO se re-bootstrappean si la migracion va bien). + +9. **Plan rollback** (escribir en `docs/mas_migration_rollback.md`): + - Restaurar postgres Synapse desde dump. + - Comentar bloque `matrix_authentication_service:` en homeserver.yaml. + - `password_config.enabled: true`. + - Restart Synapse. + - MAS sigue vivo idle (no destruir). + +## Funciones del registry a crear + +- `mas_client_register_bash_infra` — `mas-cli config sync` wrapper + validacion idempotente. +- `synapse_msc3861_enable_go_infra` — edita `homeserver.yaml` con bloque MAS + experimental_features. +- `mas_syn2mas_migration_bash_infra` — wrapper migracion con dry-run obligatorio + log archive. +- `wellknown_oidc_patch_go_infra` — anade `org.matrix.msc2965.authentication` al well-known JSON servido por nginx. +- `synapse_login_flows_check_go_infra` — health-check post-migracion (espera ver `m.login.sso` en flows). + +## Acceptance + +- [ ] `GET /_matrix/client/v3/login` devuelve `m.login.sso` con identity provider MAS. +- [ ] `GET /.well-known/matrix/client` contiene `org.matrix.msc2965.authentication.issuer`. +- [ ] Element Web redirige a MAS para login (no muestra form propio). +- [ ] Login con cuenta existente funciona post-migracion. +- [ ] Rooms historicos + msgs E2EE siguen visibles tras re-login. +- [ ] `password_config.enabled: false` no rompe nada (todo va por MAS). +- [ ] Backup pre-migracion subido + documentado. +- [ ] `docs/mas_migration_rollback.md` escrito + probado en staging (ver Notas). + +## Definition of Done + +### Mecanica +- `docker compose ps` muestra todos los containers healthy. +- `mas-cli config check` exit 0. +- `synapse curl /health` 200. +- Tests humo: login + send msg + recibe msg propagado a otra cuenta. + +### Cobertura + +| Escenario | Comando / evidencia | Resultado | +|---|---|---| +| Golden: login Element Web via MAS | navegador Incognito -> ` element-a05ae4.organic-machine.com` | redirect MAS -> login -> sesion activa | +| Edge: usuario migrado con E2EE setup previo | post-login en Element Web | rooms cifrados se descifran sin re-bootstrap | +| Edge: app servicio (bot) usa application_service token | bot envia msg | sigue funcionando (AS no pasa por MAS) | +| Edge: device verification cross-platform | Element Web verifica device PC Wails (post flow 0010) | OK | +| Error: token MAS expira mid-session | esperar TTL (default 5min refresh) | refresh automatico, no logout | +| Error: MAS cae (kill container) | matar `mas-1` 60s | Synapse rechaza nuevos logins; sessiones activas siguen (access_token cached); restart MAS -> recovery | + +### Vida util validada (7 dias post-migracion) + +| Metrica | Umbral | Donde | Ventana | +|---|---|---|---| +| Login failures (causa MAS) | `< 1%` | `mas` logs + sentry-like | 7 dias | +| Latency `/oauth2/token` | `p95 < 500ms` | nginx access log VPS | 7 dias | +| Crashes MAS / Synapse | `0` | `docker logs --since` | 7 dias | +| Users migrados activos | `>= 95%` | `mas-cli admin user list` vs sesiones activas | 7 dias | + +### Anti-criterios +- NO marcar done si algun usuario migrado pierde acceso a rooms cifrados. +- NO marcar done si Element Web sigue mostrando form de password (legacy flow). +- NO marcar done si rollback documentado no se ha probado al menos una vez en staging. + +## Notas + +**Staging recomendado:** levantar stack identico en VPS test o WSL local con docker-compose + datos fake antes de tocar prod. organic-machine.com lleva 6 semanas viva. + +**Element Call (LiveKit):** ya usa OIDC del homeserver para tokens via `livekit-jwt` container -> migracion debe verificar que tokens siguen emitiendose contra el MAS auth. + +**Synapse-Admin compat:** synapse-admin v0.10+ soporta MSC3861. Verificar version corriendo. Si vieja, bump O reemplazar por panel propio (issue 0163). + +**Gotcha critico — shared_secret:** +- `mas/config.yaml` tiene `matrix.secret` que debe matchear `homeserver.yaml.matrix_authentication_service.secret`. +- Generar con `openssl rand -hex 32` si no existe. +- Si no matchean: Synapse rechaza requests MAS con 401. + +**Gotcha — application_service tokens:** +- Los AS (bridges, bots) NO pasan por MAS. Siguen usando `as_token`/`hs_token` de su registration. +- `agents_and_robots` usa application_service? Verificar antes — si SI, no afecta. Si usa password login normal, tendra que pasar por MAS (re-config). + +**Roadmap post-DoD:** +- Habilitar `device_code` grant en MAS para login CLI futuro. +- Habilitar QR-code login (MSC4108) ya pre-config con `msc4108_delegation_endpoint`. +- Multi-factor (TOTP) en MAS — config available. + +## Capability growth log + +- v0.1.0 (2026-05-24) — issue creada. diff --git a/dev/issues/0163-matrix-custom-admin-panel.md b/dev/issues/0163-matrix-custom-admin-panel.md new file mode 100644 index 00000000..cfa35a40 --- /dev/null +++ b/dev/issues/0163-matrix-custom-admin-panel.md @@ -0,0 +1,189 @@ +--- +id: "0163" +title: "Matrix admin panel propio: users, rooms, devices, sessions (sustituye synapse-admin)" +status: pending +priority: medium +created: 2026-05-24 +related_flows: ["0010", "0011"] +related_issues: ["0162", "0147"] +dependencies: ["0162"] +tags: [matrix, admin, panel, react, mantine, mas, synapse, infra] +--- + +## Objetivo + +Panel admin propio que reemplaza `https://admin-0cc4d3.organic-machine.com/#/users` (synapse-admin actual). Funciones equivalentes: gestionar usuarios (crear, deactivate, reset password, list devices, list rooms), gestionar rooms (list, members, kick, force-leave, delete), ver sesiones activas + revoke, ver media (storage usage por user). Auth via MAS OIDC con scope admin. Stack: React+Vite+Mantine+`@fn_library` (consistente con flows 0010/0011 + resto del registry). + +## Por que reemplazar synapse-admin + +- **Auth legacy**: synapse-admin usa admin token + password admin directo. Tras issue 0162 (MAS obligatorio) esto chirria. Mejor consume MAS OIDC + Synapse Admin API. +- **UI ajena**: stack distinto al resto del registry. Sin theming propio, sin `@fn_library`, sin coherencia visual con cliente PC (flow 0010). +- **Sin agentes**: no podemos integrar paneles especiales para `agents_and_robots`, devices del mesh (flow 0009), policies de widgets. +- **No extensible**: anadir "ver telemetria de calls LiveKit" o "audit log MAS" requiere fork pesado. + +## Tareas + +1. **Scaffold app**: + - `projects/element_agents/apps/matrix_admin_panel/`. + - Stack: React+Vite+TS+Mantine+`@fn_library`+`@tabler/icons-react`. + - Backend: Go con `mautrix-go` admin client + MAS OIDC client + `livekit-server-sdk-go` (para sesiones de call). + - Empaquetado: backend Go sirve frontend estatico embebido (`embed.FS`). + - Deploy: container Docker en `element_matrix_chat` stack o como service standalone via `deploy_server`. + +2. **Auth flow MAS**: + - Cliente registrado en MAS (issue 0162 paso 2) con scope `urn:synapse:admin:*`. + - Login Web: OIDC redirect a MAS. + - Token guardado en httpOnly cookie + CSRF token. + +3. **Modulos UI**: + - **Users**: + - Tabla virtualizada con `data-table` (cuando exista TS equivalente) o `mantine-react-table`. + - Columnas: localpart, displayname, avatar, admin, deactivated, last_seen, device_count. + - Acciones por row: view detail, deactivate/reactivate, reset password (force MAS link), list devices. + - Filtros: deactivated, admin, search. + - **User detail**: + - Sub-tabs: Profile, Devices (list + revoke individual), Rooms (membership list), Media (uploads + size), Sessions (MAS active sessions + revoke), Audit log (MAS). + - **Rooms**: + - Tabla: room_id, name, alias, members_count, encrypted, public, federated, state_events. + - Acciones: view detail, force-leave usuarios, delete room (purge), shutdown notif. + - **Room detail**: + - Members + roles, state events viewer (read-only JSON), media in room, widgets activos (interop con flow 0010 widget API). + - **Sessions** (MAS): + - Lista sesiones activas global. + - Filtro por user, IP, device, last_used. + - Revoke individual o bulk. + - **Federation**: + - Estado federation (Synapse `federation_handler`). + - Allowlist/blocklist servers. + - **Stats**: + - Resumen: users count, rooms count, mensajes/dia (ultima semana), media storage, calls activas (via LiveKit `RoomService.ListRooms`). + - Graficas con `@mantine/charts` o `recharts`. + +4. **Capability groups en panel**: + - Reusa `AgentPanel` (flow 0010 issue 0153) para mostrar info de agentes registrados. + - Reusa `DevicePanel` (cuando flow 0009 vivo) para devices del mesh. + - Slot "Widgets policy": ver/aprobar capabilities concedidas globalmente, audit log. + +5. **API endpoints backend Go**: + - `GET /api/users` -> proxy a Synapse `/_synapse/admin/v2/users` con auth MAS. + - `POST /api/users//deactivate`. + - `GET /api/rooms`, `POST /api/rooms//delete`. + - `GET /api/mas/sessions`, `POST /api/mas/sessions//revoke` (MAS admin API). + - `GET /api/livekit/rooms` (active calls). + - `GET /api/stats/summary`. + +6. **Permisos**: + - Solo users con flag `admin: true` (Synapse) o scope MAS admin claim. + - Backend valida claim/flag en cada request. + - UI muestra "Access denied" si user logueado no es admin. + +7. **Deploy**: + - Anadir container al `docker-compose.yml` de `element_matrix_chat`. + - O bien standalone via `deploy_server` (registry function existente). + - URL: `admin-af2f3d.organic-machine.com` o reusar `admin-0cc4d3.organic-machine.com` cuando se retire synapse-admin. + +8. **Migracion synapse-admin -> panel propio**: + - Coexistencia 2 semanas: ambos vivos, MAS audita uso de cada uno. + - Cuando uso de synapse-admin = 0 durante 7 dias seguidos: detener container. + - Documentar en `docs/admin_panel_migration.md`. + +9. **Tests**: + - `e2e/test_admin_login.sh` — MAS OIDC + scope admin valido -> acceso. + - `e2e/test_admin_login_denied.sh` — user no-admin recibe 403. + - `e2e/test_user_deactivate.sh` — flow completo deactivate + verify can't login. + - `e2e/test_room_purge.sh` — purge room + verify gone en Synapse. + - `e2e/test_session_revoke.sh` — revoke sesion MAS + user perdiendo acceso en <30s. + +## Funciones del registry a crear + +- `synapse_admin_client_go_infra` — wrapper Synapse Admin API. +- `mas_admin_client_go_infra` — wrapper MAS admin API (`/api/admin/v1/...`). +- `livekit_admin_client_go_infra` — `RoomService.ListRooms`, kick participant, etc. +- `oidc_admin_middleware_go_infra` — middleware Go que valida scope admin en cookie/Bearer. +- `UsersTable_ts_ui` — componente Mantine con virtualization + filtros. +- `RoomDetail_ts_ui` — componente con tabs Members/State/Media/Widgets. +- `SessionsList_ts_ui` — lista sesiones + revoke action. +- `StatsSummary_ts_ui` — componente con `@mantine/charts`. +- `FederationStatusPanel_ts_ui` — componente federation diag. + +## Acceptance + +- [ ] App compila + arranca como container Docker. +- [ ] Login via MAS OIDC con scope admin funciona. +- [ ] User no-admin recibe 403 al intentar entrar. +- [ ] Tabla users con 50+ rows + filtros + actions. +- [ ] Deactivate user end-to-end (verify cannot login despues). +- [ ] Room detail muestra members + state events JSON. +- [ ] Sessions MAS listadas + revoke individual. +- [ ] Stats: counts + media usage + active calls visibles. +- [ ] Tema visual coherente con cliente PC (flow 0010). + +## Definition of Done + +### Mecanica +- `go build` + `pnpm build` verde. +- Container Docker `<150MB` (Alpine + binary + static). +- Health endpoint `/health` 200. +- E2E suite pasa. + +### Cobertura + +| Escenario | Evidencia | Resultado | +|---|---|---| +| Golden: admin login + ver users | `e2e/test_admin_full_flow.sh` | tabla con users reales, actions visibles | +| Edge: 5000 users en tabla | benchmark scroll | 60fps, <300MB RAM | +| Edge: user sin admin entra | request directo | 403 + audit log | +| Edge: room con 200 members | view detail | render < 1s, paginacion OK | +| Error: Synapse Admin API caida | mock 500 | UI muestra error claro, no crash | +| Error: MAS session revoke fails | mock 500 | retry + toast error | + +### Vida util (>=7 dias) + +| Metrica | Umbral | Donde | Ventana | +|---|---|---|---| +| Crashes container | `0` | docker logs | 7 dias | +| Uso real | `>= 2 sesiones/semana` (operador) | nginx access log | 7 dias | +| Latency p95 endpoint /api/users | `< 800ms` (Synapse Admin paginado) | metrics | 7 dias | +| Acciones destructivas auditadas | `100%` (cada delete/revoke con audit row) | local audit DB | continuo | + +### Anti-criterios +- NO marcar done si admin panel acepta token sin claim/flag admin. +- NO marcar done si delete room no purga media en DB Synapse. +- NO marcar done si UI deja al operador sin confirmacion en acciones destructivas (deactivate, purge, revoke). +- NO marcar done si lookalike de synapse-admin sin features propias (mejor mantener synapse-admin entonces). + +## Notas + +**Ventajas reales sobre synapse-admin:** +1. Coherencia visual + Mantine + theme propio. +2. Integracion con `agents_and_robots` (panel agente embedded). +3. Integracion con widgets policy (audit + override capabilities). +4. Integracion con LiveKit calls (ver rooms activos, force-end). +5. Audit log local SQLite con todas las acciones admin (synapse-admin no lo tiene). +6. Extensible — anadir tabs para mesh devices (flow 0009), telemetria, etc. + +**Onboarding:** +1. `cd projects/element_agents/apps/matrix_admin_panel`. +2. `make dev` (Go backend + Vite frontend hot reload). +3. Visitar `http://127.0.0.1:8090` -> login MAS dev. +4. Deploy prod: ver `deploy/README.md`. + +**Decisiones:** +- Backend Go > Python/Node: alinea con `mautrix-go` + reusa funciones del registry. Binario pequeno, deploy facil. +- Embedded static (Go `embed.FS`): un binario, sin docker multi-stage compleja. +- Audit log local SQLite > Postgres: panel admin no necesita HA, suficiente con SQLite local + backup periodico. + +**Gotchas:** +- Synapse Admin API requiere `Bearer ` — el panel intercambia OIDC token + admin claim por admin_token (con MAS admin API o con cuenta admin shared). +- MAS admin API esta en `/api/admin/v1/` — version unstable, monitorizar breaking changes. +- Federation tab: si federation deshabilitada (caso actual, ver `homeserver.yaml`), tab muestra "disabled" en vez de error. + +**Roadmap post-DoD:** +- Bulk actions (mass deactivate, mass invite). +- Export reports CSV. +- Slack/email alerts en eventos criticos (server cae, MAS down, federation block). +- Multi-tenancy si llegan mas homeservers. + +## Capability growth log + +- v0.1.0 (2026-05-24) — issue creada. diff --git a/docs/capabilities/matrix-mas.md b/docs/capabilities/matrix-mas.md new file mode 100644 index 00000000..39430656 --- /dev/null +++ b/docs/capabilities/matrix-mas.md @@ -0,0 +1,80 @@ +--- +group: matrix-mas +description: "Migración y operación de Synapse con Matrix Authentication Service (MAS). Cubre habilitación de MSC3861, verificación de login flows, parche .well-known OIDC, registro de clientes MAS y migración syn2mas." +tags: [matrix, mas, synapse, migration] +functions: + - synapse_login_flows_check_go_infra + - synapse_msc3861_enable_go_infra + - wellknown_oidc_patch_go_infra + - mas_client_register_bash_infra + - mas_syn2mas_migration_bash_infra +--- + +## Funciones + +| ID | Firma corta | Qué hace | +|---|---|---| +| `synapse_login_flows_check_go_infra` | `SynapseLoginFlowsCheck(cfg) (result, error)` | Polling de `/_matrix/client/v3/login` hasta confirmar SSO/MAS activo y password desactivado | +| `synapse_msc3861_enable_go_infra` | `SynapseMsc3861Enable(cfg) (result, error)` | Habilita MSC3861 en `homeserver.yaml` vía SSH y reinicia Synapse | +| `wellknown_oidc_patch_go_infra` | `WellknownOidcPatch(cfg) (result, error)` | Parchea `.well-known/matrix/client` para añadir el bloque `m.authentication` de MAS | +| `mas_client_register_bash_infra` | `mas_client_register(ssh_host, container, config_file, dry_run)` | Registra un cliente OAuth2 en MAS vía `mas-cli manage register-client` | +| `mas_syn2mas_migration_bash_infra` | `mas_syn2mas_migration --ssh-host ... --mas-container ... --synapse-config-path ...` | Ejecuta la migración syn2mas de usuarios y sesiones de Synapse a MAS | + +## Ejemplo canónico — verificar post-migración (issue 0162, paso 6) + +```go +// 1. Habilitar MSC3861 en homeserver.yaml y reiniciar Synapse +resCfg := SynapseMsc3861Config{ + SSHHost: "organic-machine", + HomserverPath: "/etc/synapse/homeserver.yaml", + RestartCommand: "systemctl restart matrix-synapse", +} +_, err := SynapseMsc3861Enable(resCfg) +if err != nil { + log.Fatalf("enable MSC3861: %v", err) +} + +// 2. Parchar .well-known con bloque m.authentication +patchCfg := WellknownOidcPatchConfig{ + WellknownPath: "/var/www/.well-known/matrix/client", + IssuerURL: "https://mas.organic-machine.com/", +} +_, err = WellknownOidcPatch(patchCfg) +if err != nil { + log.Fatalf("well-known patch: %v", err) +} + +// 3. Verificar que login flows ya no exponen m.login.password +checkCfg := SynapseLoginFlowsCheckConfig{ + HomeserverURL: "https://matrix-af2f3d.organic-machine.com", + ExpectedSsoIdpID: "oidc-mas", + MaxRetries: 10, + RetryDelaySeconds: 3, +} +res, err := SynapseLoginFlowsCheck(checkCfg) +if err != nil { + log.Fatalf("login flows check: %v\nlast response: %s", err, res.LastResponseJSON) +} +fmt.Printf("MAS confirmed after %d attempt(s). SSO: %v, Password: %v\n", + res.AttemptsUsed, res.SsoPresent, res.PasswordEnabled) +``` + +## Fronteras + +- Este grupo cubre la **migración y validación** de Synapse→MAS. No cubre la configuración inicial de MAS ni la gestión de usuarios post-migración. +- Las funciones bash (`mas_client_register`, `mas_syn2mas_migration`) operan vía SSH sobre el host remoto — requieren acceso SSH configurado en `~/.ssh/config`. +- Las funciones Go (`synapse_login_flows_check`, `synapse_msc3861_enable`, `wellknown_oidc_patch`) pueden correr localmente o en pipelines CI. + +## Prerequisitos + +- Acceso SSH al host donde corre Synapse (alias en `~/.ssh/config`). +- MAS desplegado y accesible antes de ejecutar la migración. +- `ExpectedSsoIdpID` verificado contra `mas/config.yaml` → `clients[].id` del homeserver Synapse. + +## Orden recomendado (issue 0162) + +1. `mas_client_register` — registrar Synapse como cliente OAuth2 en MAS. +2. `synapse_msc3861_enable` — habilitar MSC3861 + reiniciar. +3. `wellknown_oidc_patch` — actualizar `.well-known`. +4. `synapse_login_flows_check` — confirmar convergencia post-restart. +5. `mas_syn2mas_migration` — migrar usuarios y sesiones existentes. diff --git a/functions/infra/synapse_login_flows_check.go b/functions/infra/synapse_login_flows_check.go new file mode 100644 index 00000000..f8196793 --- /dev/null +++ b/functions/infra/synapse_login_flows_check.go @@ -0,0 +1,219 @@ +package infra + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// SynapseLoginFlowsCheckConfig holds the parameters for polling the Synapse +// login-flows endpoint and verifying that the MAS (Matrix Authentication +// Service) SSO flow is active. +type SynapseLoginFlowsCheckConfig struct { + HomeserverURL string // Public URL of the homeserver (e.g. https://matrix.example.com) + ExpectedSsoIdpID string // IdP id to find in m.login.sso.identity_providers[].id (empty = only check SSO presence) + MaxRetries int // Number of attempts before giving up (default: 10) + RetryDelaySeconds int // Seconds to wait between attempts (default: 3) + HttpTimeoutSeconds int // Per-request HTTP timeout in seconds (default: 5) +} + +// SynapseLoginFlowsCheckResult contains the parsed state of the login-flows +// endpoint after the last successful (or final failed) attempt. +type SynapseLoginFlowsCheckResult struct { + Flows []string // All flow types returned (e.g. ["m.login.sso"]) + SsoPresent bool // true if "m.login.sso" is in Flows + IdpFound bool // true if ExpectedSsoIdpID was found (or ExpectedSsoIdpID is empty and SsoPresent) + PasswordEnabled bool // true if "m.login.password" is in Flows + LastResponseJSON string // Raw JSON body from the last HTTP response + AttemptsUsed int // Number of HTTP attempts made +} + +// loginFlowsResponse is the structure returned by +// GET /_matrix/client/v3/login +type loginFlowsResponse struct { + Flows []loginFlow `json:"flows"` +} + +type loginFlow struct { + Type string `json:"type"` + IdentityProviders []idpProvider `json:"identity_providers,omitempty"` +} + +type idpProvider struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// SynapseLoginFlowsCheck polls GET {HomeserverURL}/_matrix/client/v3/login +// and checks that the SSO/MAS flow is present and password login is disabled. +// It retries up to MaxRetries times with RetryDelaySeconds delay between each. +// +// Success condition: +// - "m.login.sso" is present in flows +// - ExpectedSsoIdpID found in identity_providers (skipped when empty) +// - "m.login.password" is NOT present +// +// Returns the result from the last attempt. On convergence failure it also +// returns a non-nil error describing the final state. +func SynapseLoginFlowsCheck(cfg SynapseLoginFlowsCheckConfig) (SynapseLoginFlowsCheckResult, error) { + if cfg.HomeserverURL == "" { + return SynapseLoginFlowsCheckResult{}, fmt.Errorf("synapse_login_flows_check: HomeserverURL must not be empty") + } + cfg.HomeserverURL = strings.TrimRight(cfg.HomeserverURL, "/") + + if cfg.MaxRetries <= 0 { + cfg.MaxRetries = 10 + } + if cfg.RetryDelaySeconds < 0 { + cfg.RetryDelaySeconds = 3 + } + if cfg.HttpTimeoutSeconds <= 0 { + cfg.HttpTimeoutSeconds = 5 + } + + endpoint := cfg.HomeserverURL + "/_matrix/client/v3/login" + httpClient := &http.Client{ + Timeout: time.Duration(cfg.HttpTimeoutSeconds) * time.Second, + } + + var result SynapseLoginFlowsCheckResult + + for attempt := 1; attempt <= cfg.MaxRetries; attempt++ { + result.AttemptsUsed = attempt + + resp, body, parseErr := fetchAndParse(httpClient, endpoint) + result.LastResponseJSON = body + + if parseErr != nil { + // On the last attempt, surface the parse/network error + if attempt == cfg.MaxRetries { + return result, fmt.Errorf("synapse_login_flows_check: attempt %d/%d: %w", attempt, cfg.MaxRetries, parseErr) + } + sleepSeconds(cfg.RetryDelaySeconds) + continue + } + + // Build result from parsed response + result.Flows = extractFlowTypes(resp.Flows) + result.SsoPresent = containsFlow(resp.Flows, "m.login.sso") + result.PasswordEnabled = containsFlow(resp.Flows, "m.login.password") + + if result.SsoPresent { + if cfg.ExpectedSsoIdpID == "" { + result.IdpFound = true + } else { + result.IdpFound = findIdp(resp.Flows, cfg.ExpectedSsoIdpID) + } + } else { + result.IdpFound = false + } + + // Check success condition + if result.SsoPresent && result.IdpFound && !result.PasswordEnabled { + return result, nil + } + + if attempt < cfg.MaxRetries { + sleepSeconds(cfg.RetryDelaySeconds) + } + } + + // Exhausted retries — build a descriptive error + msg := buildConvergenceError(result, cfg) + return result, fmt.Errorf("synapse_login_flows_check: %s", msg) +} + +// fetchAndParse performs one HTTP GET and returns the parsed response plus the +// raw body. On any error (network, status, JSON) the raw body may be partial. +func fetchAndParse(client *http.Client, url string) (*loginFlowsResponse, string, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, "", fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + + httpResp, err := client.Do(req) + if err != nil { + return nil, "", fmt.Errorf("http get: %w", err) + } + defer httpResp.Body.Close() + + raw, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, "", fmt.Errorf("read body: %w", err) + } + body := string(raw) + + if httpResp.StatusCode != http.StatusOK { + return nil, body, fmt.Errorf("unexpected status %d: %s", httpResp.StatusCode, body) + } + + var parsed loginFlowsResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, body, fmt.Errorf("json unmarshal: %w", err) + } + return &parsed, body, nil +} + +// extractFlowTypes returns the "type" field of each flow entry. +func extractFlowTypes(flows []loginFlow) []string { + types := make([]string, 0, len(flows)) + for _, f := range flows { + types = append(types, f.Type) + } + return types +} + +// containsFlow reports whether any flow entry has the given type. +func containsFlow(flows []loginFlow, flowType string) bool { + for _, f := range flows { + if f.Type == flowType { + return true + } + } + return false +} + +// findIdp reports whether any identity_provider in a "m.login.sso" flow has +// the given id. +func findIdp(flows []loginFlow, idpID string) bool { + for _, f := range flows { + if f.Type != "m.login.sso" { + continue + } + for _, idp := range f.IdentityProviders { + if idp.ID == idpID { + return true + } + } + } + return false +} + +// buildConvergenceError assembles a human-readable error message describing +// why the final state is not the expected post-migration state. +func buildConvergenceError(r SynapseLoginFlowsCheckResult, cfg SynapseLoginFlowsCheckConfig) string { + var parts []string + if !r.SsoPresent { + parts = append(parts, "m.login.sso not present") + } + if cfg.ExpectedSsoIdpID != "" && !r.IdpFound { + parts = append(parts, fmt.Sprintf("IdP %q not found in identity_providers", cfg.ExpectedSsoIdpID)) + } + if r.PasswordEnabled { + parts = append(parts, "m.login.password still enabled (MSC3861 not fully applied)") + } + reason := strings.Join(parts, "; ") + return fmt.Sprintf("MAS migration not confirmed after %d attempt(s): %s", r.AttemptsUsed, reason) +} + +// sleepSeconds sleeps for n seconds. Extracted for test patching via a +// package-level variable. +var sleepSeconds = func(n int) { + if n > 0 { + time.Sleep(time.Duration(n) * time.Second) + } +} diff --git a/functions/infra/synapse_login_flows_check.md b/functions/infra/synapse_login_flows_check.md new file mode 100644 index 00000000..f6c3ecc2 --- /dev/null +++ b/functions/infra/synapse_login_flows_check.md @@ -0,0 +1,80 @@ +--- +name: synapse_login_flows_check +kind: function +lang: go +domain: infra +version: "0.1.0" +purity: impure +signature: "func SynapseLoginFlowsCheck(cfg SynapseLoginFlowsCheckConfig) (SynapseLoginFlowsCheckResult, error)" +description: "Verifica que el endpoint /_matrix/client/v3/login del homeserver Synapse devuelve m.login.sso con el IdP de MAS esperado y que m.login.password está desactivado. Hace polling con reintentos hasta confirmar el estado post-migración o agotar los intentos." +tags: [matrix, mas, synapse, login, healthcheck, migration, mas-migration, infra, matrix-mas] +params: + - name: HomeserverURL + desc: "URL pública del homeserver (ej. https://matrix-af2f3d.organic-machine.com). Sin trailing slash." + - name: ExpectedSsoIdpID + desc: "Identificador del IdP MAS esperado en m.login.sso.identity_providers[].id (ej. oidc-mas). Vacío = solo verificar que m.login.sso exista, sin comprobar IdP concreto." + - name: MaxRetries + desc: "Número máximo de intentos HTTP antes de abortar. Default: 10." + - name: RetryDelaySeconds + desc: "Segundos de espera entre intentos. Default: 3. Synapse tarda 10-30s en levantar tras restart." + - name: HttpTimeoutSeconds + desc: "Timeout HTTP por intento en segundos. Default: 5." +output: "SynapseLoginFlowsCheckResult{Flows, SsoPresent, IdpFound, PasswordEnabled, LastResponseJSON, AttemptsUsed}. Error nil = migración confirmada. Error CONVERGENCE_FAILED = no convergió tras MaxRetries." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["encoding/json", "fmt", "io", "net/http", "strings", "time"] +tested: true +tests: + - "SSO + IdP expected -> success on first attempt" + - "legacy response then SSO on 3rd attempt -> success after retries" + - "response never changes -> error after maxRetries" + - "HTTP timeout -> error" + - "malformed JSON -> error" +test_file_path: "functions/infra/synapse_login_flows_check_test.go" +file_path: "functions/infra/synapse_login_flows_check.go" +--- + +## Ejemplo + +```go +cfg := SynapseLoginFlowsCheckConfig{ + HomeserverURL: "https://matrix-af2f3d.organic-machine.com", + ExpectedSsoIdpID: "oidc-mas", + MaxRetries: 10, + RetryDelaySeconds: 3, + HttpTimeoutSeconds: 5, +} +res, err := SynapseLoginFlowsCheck(cfg) +if err == nil && res.SsoPresent && !res.PasswordEnabled { + fmt.Printf("MAS migration confirmed after %d attempt(s)\n", res.AttemptsUsed) + // Continue with post-migration smoke tests +} else if err != nil { + fmt.Printf("Migration NOT confirmed: %s\n", err.Message) + fmt.Printf("Last response: %s\n", res.LastResponseJSON) +} +``` + +## Cuando usarla + +Usar en el paso 6 del issue 0162 (migración Synapse→MAS), inmediatamente tras reiniciar Synapse con MSC3861 activado. También útil como `e2e_check` continuo en `app.md` del servicio Synapse para detectar regresiones (ej. alguien comenta `msc3861.enabled: true` por error y vuelve a activar password login). + +```yaml +# En app.md del servicio matrix: +e2e_checks: + - id: mas_login_flows + cmd: "go run . -check-login-flows https://matrix-af2f3d.organic-machine.com oidc-mas" + expect_stdout_contains: "MAS migration confirmed" + timeout_s: 60 +``` + +## Gotchas + +- **Synapse tarda 10-30s en levantar** tras restart — los defaults (MaxRetries=10, RetryDelaySeconds=3) cubren 30s de espera total. +- **PasswordEnabled == true post-migración**: probablemente `password_config.enabled: false` no se aplicó en `homeserver.yaml` o fue sobreescrito por include. Verificar config antes de reintentar. +- **IdP id incorrecto**: el id del IdP depende de `mas/config.yaml` → sección `matrix.homeserver`. Verificar el valor exacto con `GET /_matrix/client/v3/login` manual antes de pasar a `ExpectedSsoIdpID`. +- **TLS no válido**: si el certificado del HomeserverURL no es verificable, `net/http` retorna error de TLS — la función lo propaga como FETCH_ERROR con el mensaje original de Go (no lo ignora silenciosamente). +- **Non-200 responses**: cualquier status HTTP != 200 se trata como error de fetch y dispara reintento. +- **ExpectedSsoIdpID vacío**: solo verifica presencia de `m.login.sso` y ausencia de `m.login.password`. Suficiente para validación rápida; usar el ID completo para health-check de producción. diff --git a/functions/infra/synapse_login_flows_check_test.go b/functions/infra/synapse_login_flows_check_test.go new file mode 100644 index 00000000..00736c23 --- /dev/null +++ b/functions/infra/synapse_login_flows_check_test.go @@ -0,0 +1,196 @@ +package infra + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// loginFlowsJSON builds a minimal /_matrix/client/v3/login response body. +func loginFlowsJSON(flows []loginFlow) string { + b, _ := json.Marshal(loginFlowsResponse{Flows: flows}) + return string(b) +} + +// masFlows returns a typical post-migration response: only SSO with one IdP. +func masFlows(idpID string) []loginFlow { + return []loginFlow{ + { + Type: "m.login.sso", + IdentityProviders: []idpProvider{ + {ID: idpID, Name: "MAS"}, + }, + }, + } +} + +// legacyFlows returns a pre-migration response: password + application_service. +func legacyFlows() []loginFlow { + return []loginFlow{ + {Type: "m.login.password"}, + {Type: "m.login.application_service"}, + } +} + +func TestSynapseLoginFlowsCheck(t *testing.T) { + // Disable real sleep during tests + origSleep := sleepSeconds + sleepSeconds = func(int) {} + t.Cleanup(func() { sleepSeconds = origSleep }) + + t.Run("SSO + IdP expected -> success on first attempt", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(loginFlowsJSON(masFlows("oidc-mas")))) + })) + defer srv.Close() + + cfg := SynapseLoginFlowsCheckConfig{ + HomeserverURL: srv.URL, + ExpectedSsoIdpID: "oidc-mas", + MaxRetries: 5, + RetryDelaySeconds: 0, + HttpTimeoutSeconds: 5, + } + + res, err := SynapseLoginFlowsCheck(cfg) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if !res.SsoPresent { + t.Error("SsoPresent should be true") + } + if !res.IdpFound { + t.Error("IdpFound should be true") + } + if res.PasswordEnabled { + t.Error("PasswordEnabled should be false") + } + if res.AttemptsUsed != 1 { + t.Errorf("expected 1 attempt, got %d", res.AttemptsUsed) + } + if len(res.LastResponseJSON) == 0 { + t.Error("LastResponseJSON should not be empty") + } + }) + + t.Run("legacy response then SSO on 3rd attempt -> success after retries", func(t *testing.T) { + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := int(atomic.AddInt32(&callCount, 1)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if n < 3 { + w.Write([]byte(loginFlowsJSON(legacyFlows()))) + } else { + w.Write([]byte(loginFlowsJSON(masFlows("oidc-mas")))) + } + })) + defer srv.Close() + + cfg := SynapseLoginFlowsCheckConfig{ + HomeserverURL: srv.URL, + ExpectedSsoIdpID: "oidc-mas", + MaxRetries: 10, + RetryDelaySeconds: 0, + HttpTimeoutSeconds: 5, + } + + res, err := SynapseLoginFlowsCheck(cfg) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if res.AttemptsUsed != 3 { + t.Errorf("expected 3 attempts, got %d", res.AttemptsUsed) + } + if !res.SsoPresent { + t.Error("SsoPresent should be true") + } + if res.PasswordEnabled { + t.Error("PasswordEnabled should be false") + } + }) + + t.Run("response never changes -> error after maxRetries", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(loginFlowsJSON(legacyFlows()))) + })) + defer srv.Close() + + cfg := SynapseLoginFlowsCheckConfig{ + HomeserverURL: srv.URL, + ExpectedSsoIdpID: "oidc-mas", + MaxRetries: 3, + RetryDelaySeconds: 0, + HttpTimeoutSeconds: 5, + } + + res, err := SynapseLoginFlowsCheck(cfg) + if err == nil { + t.Fatal("expected error after max retries, got nil") + } + if !strings.Contains(err.Error(), "MAS migration not confirmed") { + t.Errorf("expected 'MAS migration not confirmed' in error message, got: %v", err) + } + if res.AttemptsUsed != 3 { + t.Errorf("expected 3 attempts used, got %d", res.AttemptsUsed) + } + if !res.PasswordEnabled { + t.Error("PasswordEnabled should be true (legacy still active)") + } + }) + + t.Run("HTTP timeout -> error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Deliberately hang longer than the 1s timeout + <-r.Context().Done() + })) + defer srv.Close() + + cfg := SynapseLoginFlowsCheckConfig{ + HomeserverURL: srv.URL, + ExpectedSsoIdpID: "oidc-mas", + MaxRetries: 1, + RetryDelaySeconds: 0, + HttpTimeoutSeconds: 1, + } + + _, err := SynapseLoginFlowsCheck(cfg) + if err == nil { + t.Fatal("expected error on timeout, got nil") + } + if !strings.Contains(err.Error(), "synapse_login_flows_check") { + t.Errorf("expected error to contain function name, got: %v", err) + } + }) + + t.Run("malformed JSON -> error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{not valid json`)) + })) + defer srv.Close() + + cfg := SynapseLoginFlowsCheckConfig{ + HomeserverURL: srv.URL, + MaxRetries: 1, + RetryDelaySeconds: 0, + HttpTimeoutSeconds: 5, + } + + _, err := SynapseLoginFlowsCheck(cfg) + if err == nil { + t.Fatal("expected error on malformed JSON, got nil") + } + if !strings.Contains(err.Error(), "json unmarshal") { + t.Errorf("expected json unmarshal error, got: %v", err) + } + }) +} diff --git a/functions/infra/synapse_msc3861_enable.go b/functions/infra/synapse_msc3861_enable.go new file mode 100644 index 00000000..9f10d69a --- /dev/null +++ b/functions/infra/synapse_msc3861_enable.go @@ -0,0 +1,531 @@ +package infra + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// SynapseMsc3861Config holds parameters for enabling MSC3861 (MAS) in homeserver.yaml. +type SynapseMsc3861Config struct { + // HomeserverYamlPath is the absolute path to the homeserver.yaml file. + HomeserverYamlPath string + // MasEndpoint is the internal MAS URL (e.g. http://mas:8080/). + MasEndpoint string + // MasSecret is the shared_secret hex (64 hex chars, 32 bytes) matching mas/config.yaml::matrix.secret. + MasSecret string + // BackupDir is the directory where the original file backup is stored. + BackupDir string + // DryRun: if true, compute diff only without writing files. + DryRun bool +} + +// SynapseMsc3861Result holds the output of SynapseMsc3861Enable. +type SynapseMsc3861Result struct { + // BackupPath is the path of the backup file created (empty if DryRun=true). + BackupPath string + // LinesAdded is the number of added lines in the diff. + LinesAdded int + // LinesRemoved is the number of removed lines in the diff. + LinesRemoved int + // Diff is the unified diff string between original and modified content. + Diff string +} + +// hexPattern matches exactly 64 lowercase hex characters. +var hexPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// SynapseMsc3861Enable edits a Synapse homeserver.yaml to enable MSC3861 (Matrix Authentication Service). +// +// Steps: +// 1. Validate inputs. +// 2. Backup the original file to BackupDir. +// 3. Parse the YAML using the yaml.v3 Node API (preserves comments). +// 4. Uncomment / add the matrix_authentication_service block. +// 5. Ensure experimental_features.msc3861.enabled = true. +// 6. Ensure password_config.enabled = false. +// 7. Compute a unified diff. +// 8. Write the result unless DryRun=true. +func SynapseMsc3861Enable(cfg SynapseMsc3861Config) (SynapseMsc3861Result, error) { + var result SynapseMsc3861Result + + // --- 1. Validate inputs --- + if cfg.HomeserverYamlPath == "" { + return result, fmt.Errorf("HomeserverYamlPath is required") + } + if _, err := os.Stat(cfg.HomeserverYamlPath); err != nil { + return result, fmt.Errorf("HomeserverYamlPath %q not found: %w", cfg.HomeserverYamlPath, err) + } + if cfg.MasEndpoint == "" { + return result, fmt.Errorf("MasEndpoint is required") + } + if !strings.HasPrefix(cfg.MasEndpoint, "http://") && !strings.HasPrefix(cfg.MasEndpoint, "https://") { + return result, fmt.Errorf("MasEndpoint must start with http:// or https://") + } + if !hexPattern.MatchString(cfg.MasSecret) { + return result, fmt.Errorf("MasSecret must be exactly 64 lowercase hex characters (32 bytes)") + } + if cfg.BackupDir == "" { + return result, fmt.Errorf("BackupDir is required") + } + + // --- Read original file --- + originalBytes, err := os.ReadFile(cfg.HomeserverYamlPath) + if err != nil { + return result, fmt.Errorf("reading homeserver.yaml: %w", err) + } + originalContent := string(originalBytes) + + // --- 2. Backup --- + if !cfg.DryRun { + if err := os.MkdirAll(cfg.BackupDir, 0o755); err != nil { + return result, fmt.Errorf("creating backup dir %q: %w", cfg.BackupDir, err) + } + ts := time.Now().Unix() + backupName := fmt.Sprintf("homeserver_%d.yaml", ts) + backupPath := filepath.Join(cfg.BackupDir, backupName) + if err := os.WriteFile(backupPath, originalBytes, 0o644); err != nil { + return result, fmt.Errorf("writing backup: %w", err) + } + result.BackupPath = backupPath + } + + // --- 3–6. Modify content using line-level and YAML node processing --- + modifiedContent, err := applyMsc3861Edits(originalContent, cfg.MasEndpoint, cfg.MasSecret) + if err != nil { + return result, fmt.Errorf("applying MSC3861 edits: %w", err) + } + + // --- 7. Compute diff --- + diff := unifiedDiff("homeserver.yaml (original)", "homeserver.yaml (modified)", originalContent, modifiedContent) + result.Diff = diff + + added, removed := countDiffLines(diff) + result.LinesAdded = added + result.LinesRemoved = removed + + // --- 8. Write if not DryRun --- + if !cfg.DryRun { + if err := os.WriteFile(cfg.HomeserverYamlPath, []byte(modifiedContent), 0o644); err != nil { + return result, fmt.Errorf("writing modified homeserver.yaml: %w", err) + } + } + + return result, nil +} + +// applyMsc3861Edits performs all required YAML edits on the raw content string. +// It uses a line-based approach so that comments are preserved exactly. +func applyMsc3861Edits(content, masEndpoint, masSecret string) (string, error) { + // We work line-by-line for the commented-block replacement and password_config, + // then use yaml.v3 Node API for experimental_features.msc3861. + + lines := strings.Split(content, "\n") + + lines = enableMasBlock(lines, masEndpoint, masSecret) + lines = setPasswordConfigDisabled(lines) + + modified := strings.Join(lines, "\n") + + // Now handle experimental_features.msc3861 via yaml.v3 Node API. + modified, err := ensureExperimentalMsc3861(modified) + if err != nil { + return "", fmt.Errorf("updating experimental_features: %w", err) + } + + return modified, nil +} + +// masBlockTemplate is the YAML block we want active in the file. +func masBlockLines(endpoint, secret string) []string { + return []string{ + "matrix_authentication_service:", + " enabled: true", + fmt.Sprintf(" endpoint: %q", endpoint), + fmt.Sprintf(" secret: %q", secret), + } +} + +// enableMasBlock finds the commented-out matrix_authentication_service block +// (lines starting with "# matrix_authentication_service:") or an existing active +// block, and replaces/inserts the correct active block. +func enableMasBlock(lines []string, endpoint, secret string) []string { + // Patterns to detect the section. + commentedHeader := regexp.MustCompile(`^#\s*matrix_authentication_service:`) + activeHeader := regexp.MustCompile(`^matrix_authentication_service:`) + commentedSubkey := regexp.MustCompile(`^#\s+\w`) + + newBlock := masBlockLines(endpoint, secret) + + var result []string + i := 0 + injected := false + + for i < len(lines) { + line := lines[i] + + if commentedHeader.MatchString(line) && !injected { + // Replace the commented block (consume commented sub-lines too). + result = append(result, newBlock...) + injected = true + i++ + // Skip subsequent commented sub-lines belonging to this block. + for i < len(lines) && commentedSubkey.MatchString(lines[i]) { + i++ + } + continue + } + + if activeHeader.MatchString(line) && !injected { + // Already active — replace it to ensure correct values. + result = append(result, newBlock...) + injected = true + i++ + // Skip existing sub-lines (indented). + for i < len(lines) && (strings.HasPrefix(lines[i], " ") || lines[i] == "") { + // Stop at the next top-level key. + if lines[i] != "" && !strings.HasPrefix(lines[i], " ") { + break + } + if strings.HasPrefix(lines[i], " ") { + i++ + continue + } + break + } + continue + } + + result = append(result, line) + i++ + } + + if !injected { + // Block not found anywhere — append at end (before trailing blank lines). + result = append(result, "") + result = append(result, newBlock...) + } + + return result +} + +// setPasswordConfigDisabled ensures `password_config:\n enabled: false` in the file. +func setPasswordConfigDisabled(lines []string) []string { + headerRe := regexp.MustCompile(`^password_config:`) + commentedRe := regexp.MustCompile(`^#\s*password_config:`) + + var result []string + i := 0 + injected := false + + for i < len(lines) { + line := lines[i] + + if commentedRe.MatchString(line) && !injected { + // Replace commented block. + result = append(result, "password_config:") + result = append(result, " enabled: false") + injected = true + i++ + for i < len(lines) && regexp.MustCompile(`^#\s+\w`).MatchString(lines[i]) { + i++ + } + continue + } + + if headerRe.MatchString(line) && !injected { + // Active block — update or add enabled: false sub-key. + result = append(result, line) + injected = true + i++ + foundEnabled := false + var subLines []string + for i < len(lines) && strings.HasPrefix(lines[i], " ") { + sl := lines[i] + if regexp.MustCompile(`^\s+enabled:`).MatchString(sl) { + subLines = append(subLines, " enabled: false") + foundEnabled = true + } else { + subLines = append(subLines, sl) + } + i++ + } + if !foundEnabled { + subLines = append([]string{" enabled: false"}, subLines...) + } + result = append(result, subLines...) + continue + } + + result = append(result, line) + i++ + } + + if !injected { + result = append(result, "") + result = append(result, "password_config:") + result = append(result, " enabled: false") + } + + return result +} + +// ensureExperimentalMsc3861 uses yaml.v3 Node API to set +// experimental_features.msc3861.enabled = true preserving other keys. +func ensureExperimentalMsc3861(content string) (string, error) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte(content), &doc); err != nil { + return content, fmt.Errorf("yaml unmarshal: %w", err) + } + + if doc.Kind == 0 { + // Empty document — append the block. + return content + "\nexperimental_features:\n msc3861:\n enabled: true\n", nil + } + + root := &doc + if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { + root = root.Content[0] + } + if root.Kind != yaml.MappingNode { + return content, fmt.Errorf("unexpected root YAML node kind %v", root.Kind) + } + + // Find or create experimental_features. + expNode := findMappingValue(root, "experimental_features") + if expNode == nil { + // Append experimental_features block. + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "experimental_features"} + valNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + root.Content = append(root.Content, keyNode, valNode) + expNode = valNode + } + + // Find or create msc3861 under experimental_features. + mscNode := findMappingValue(expNode, "msc3861") + if mscNode == nil { + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "msc3861"} + valNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + expNode.Content = append(expNode.Content, keyNode, valNode) + mscNode = valNode + } + + // Set enabled: true inside msc3861. + enabledNode := findMappingValue(mscNode, "enabled") + if enabledNode == nil { + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"} + valNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "true"} + mscNode.Content = append(mscNode.Content, keyNode, valNode) + } else { + enabledNode.Value = "true" + enabledNode.Tag = "!!bool" + } + + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(&doc); err != nil { + return content, fmt.Errorf("yaml marshal: %w", err) + } + if err := enc.Close(); err != nil { + return content, fmt.Errorf("yaml encoder close: %w", err) + } + + return buf.String(), nil +} + +// findMappingValue returns the value node for the given key in a mapping node, or nil. +func findMappingValue(node *yaml.Node, key string) *yaml.Node { + if node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +} + +// unifiedDiff produces a simple unified diff between two texts. +func unifiedDiff(fromLabel, toLabel, original, modified string) string { + if original == modified { + return "" + } + origLines := strings.Split(original, "\n") + modLines := strings.Split(modified, "\n") + + var sb strings.Builder + fmt.Fprintf(&sb, "--- %s\n", fromLabel) + fmt.Fprintf(&sb, "+++ %s\n", toLabel) + + // Simple LCS-based diff using a greedy approach (good enough for YAML files). + lcs := computeLCS(origLines, modLines) + formatDiff(&sb, origLines, modLines, lcs) + + return sb.String() +} + +// computeLCS computes the longest common subsequence indices for two string slices. +// Returns a slice of (origIdx, modIdx) pairs. +type lcsEntry struct{ o, m int } + +func computeLCS(a, b []string) []lcsEntry { + la, lb := len(a), len(b) + // dp[i][j] = LCS length for a[:i], b[:j] + dp := make([][]int, la+1) + for i := range dp { + dp[i] = make([]int, lb+1) + } + for i := 1; i <= la; i++ { + for j := 1; j <= lb; j++ { + if a[i-1] == b[j-1] { + dp[i][j] = dp[i-1][j-1] + 1 + } else if dp[i-1][j] >= dp[i][j-1] { + dp[i][j] = dp[i-1][j] + } else { + dp[i][j] = dp[i][j-1] + } + } + } + // Backtrack. + var result []lcsEntry + i, j := la, lb + for i > 0 && j > 0 { + if a[i-1] == b[j-1] { + result = append([]lcsEntry{{i - 1, j - 1}}, result...) + i-- + j-- + } else if dp[i-1][j] >= dp[i][j-1] { + i-- + } else { + j-- + } + } + return result +} + +// formatDiff writes unified diff hunks. +func formatDiff(sb *strings.Builder, orig, mod []string, lcs []lcsEntry) { + const ctx = 3 + + // Build change regions. + var hunks []diffHunk + lcsIdx := 0 + oi, mi := 0, 0 + + flushHunk := func(ho1, ho2, hm1, hm2 int) { + // Add context lines. + ctxStart := ho1 - ctx + if ctxStart < 0 { + ctxStart = 0 + } + ctxEnd := ho2 + ctx + if ctxEnd > len(orig) { + ctxEnd = len(orig) + } + ctxMStart := hm1 - ctx + if ctxMStart < 0 { + ctxMStart = 0 + } + ctxMEnd := hm2 + ctx + if ctxMEnd > len(mod) { + ctxMEnd = len(mod) + } + + var lines []string + // Leading context. + for k := ctxStart; k < ho1; k++ { + lines = append(lines, " "+orig[k]) + } + // Removals. + for k := ho1; k < ho2; k++ { + lines = append(lines, "-"+orig[k]) + } + // Additions. + for k := hm1; k < hm2; k++ { + lines = append(lines, "+"+mod[k]) + } + // Trailing context. + for k := ho2; k < ctxEnd; k++ { + lines = append(lines, " "+orig[k]) + } + _ = ctxMStart + _ = ctxMEnd + + hunks = append(hunks, diffHunk{ctxStart, ctxEnd, ctxMStart, ctxMEnd, lines}) + } + + for lcsIdx <= len(lcs) { + var lo, lm int + if lcsIdx < len(lcs) { + lo = lcs[lcsIdx].o + lm = lcs[lcsIdx].m + } else { + lo = len(orig) + lm = len(mod) + } + + if oi < lo || mi < lm { + flushHunk(oi, lo, mi, lm) + } + + if lcsIdx < len(lcs) { + oi = lcs[lcsIdx].o + 1 + mi = lcs[lcsIdx].m + 1 + } + lcsIdx++ + } + + // Merge overlapping hunks and print. + merged := mergeHunks(hunks) + for _, h := range merged { + fmt.Fprintf(sb, "@@ -%d,%d +%d,%d @@\n", h.o1+1, h.o2-h.o1, h.m1+1, h.m2-h.m1) + for _, l := range h.lines { + sb.WriteString(l) + sb.WriteByte('\n') + } + } +} + +type diffHunk struct { + o1, o2, m1, m2 int + lines []string +} + +func mergeHunks(hunks []diffHunk) []diffHunk { + var result []diffHunk + for _, dh := range hunks { + if len(result) > 0 && dh.o1 <= result[len(result)-1].o2 { + prev := &result[len(result)-1] + if dh.o2 > prev.o2 { + prev.o2 = dh.o2 + } + if dh.m2 > prev.m2 { + prev.m2 = dh.m2 + } + prev.lines = append(prev.lines, dh.lines...) + } else { + result = append(result, dh) + } + } + return result +} + +// countDiffLines counts added (+) and removed (-) lines in a unified diff. +func countDiffLines(diff string) (added, removed int) { + for _, line := range strings.Split(diff, "\n") { + if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { + added++ + } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") { + removed++ + } + } + return +} diff --git a/functions/infra/synapse_msc3861_enable.md b/functions/infra/synapse_msc3861_enable.md new file mode 100644 index 00000000..dc55a002 --- /dev/null +++ b/functions/infra/synapse_msc3861_enable.md @@ -0,0 +1,70 @@ +--- +name: synapse_msc3861_enable +kind: function +lang: go +domain: infra +version: "0.1.0" +purity: impure +signature: "func SynapseMsc3861Enable(cfg SynapseMsc3861Config) (SynapseMsc3861Result, error)" +description: "Edita homeserver.yaml de Synapse activando el bloque matrix_authentication_service (MSC3861/MAS), asegura experimental_features.msc3861.enabled=true y password_config.enabled=false. Preserva comentarios con yaml.v3 Node API. Hace backup automático previo y devuelve diff unified." +tags: [matrix, mas, synapse, msc3861, migration, mas-migration, infra, yaml, matrix-mas] +params: + - name: HomeserverYamlPath + desc: "Ruta absoluta al homeserver.yaml en disco local (normalmente copiado del VPS con scp antes de llamar esta función)" + - name: MasEndpoint + desc: "URL interna del servicio MAS (ej. http://mas:8080/). Debe empezar con http:// o https://" + - name: MasSecret + desc: "Shared secret hex de exactamente 64 caracteres (32 bytes) que debe coincidir con mas/config.yaml::matrix.secret" + - name: BackupDir + desc: "Directorio donde guardar el backup del archivo original (se crea con mkdir -p si no existe). Ej: /tmp/synapse_backups" + - name: DryRun + desc: "Si true, sólo computa el diff sin escribir archivos ni crear backup" +output: "SynapseMsc3861Result con BackupPath (vacío si DryRun), LinesAdded, LinesRemoved y Diff (unified diff string)" +uses_functions: [] +uses_types: ["error_go_core"] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["gopkg.in/yaml.v3"] +tested: true +tests: + - "commented mas block becomes active" + - "already active mas block gets updated values" + - "no mas block inserts block at end" + - "dry run does not write file" +test_file_path: "functions/infra/synapse_msc3861_enable_test.go" +file_path: "functions/infra/synapse_msc3861_enable.go" +--- + +## Ejemplo + +```go +cfg := SynapseMsc3861Config{ + HomeserverYamlPath: "/tmp/synapse_data/homeserver.yaml", + MasEndpoint: "http://mas:8080/", + MasSecret: "5506f8b2f3fbb50413244e7197599e26477b179ec4917787f352d090fb7c7eb2", + BackupDir: "/tmp/synapse_backups", + DryRun: true, +} +res, err := SynapseMsc3861Enable(cfg) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Diff:\n%s\n", res.Diff) +fmt.Printf("Lines added: %d, removed: %d\n", res.LinesAdded, res.LinesRemoved) + +// Para aplicar los cambios: DryRun: false +// res.BackupPath contiene la ruta del backup creado. +``` + +## Cuando usarla + +Paso 3 de la migración 0162 (Synapse → MAS auth provider): después de copiar `homeserver.yaml` del VPS a disco local con `scp`, antes de copiarlo de vuelta con `scp` y hacer `systemctl restart matrix-synapse`. Usar `DryRun: true` primero para revisar el diff antes de escribir. + +## Gotchas + +- **yaml.v3 Node API obligatorio**: el YAML de Synapse contiene comentarios críticos de configuración. Usar `yaml.Unmarshal` plano los elimina. Esta función usa la API de nodos para la sección `experimental_features` y edición line-level para los bloques `matrix_authentication_service` y `password_config`. +- **MasSecret debe ser exacto**: debe coincidir byte a byte con `mas/config.yaml::matrix.secret`. Un carácter diferente hace que Synapse rechace todas las peticiones MAS con 401. +- **Nunca editar in-place en el VPS activo**: editar el archivo mientras Synapse lo lee puede producir YAML corrupto en memoria. El flujo correcto es: `scp vps:/etc/matrix-synapse/homeserver.yaml /tmp/` → `SynapseMsc3861Enable(DryRun: false)` → `scp /tmp/homeserver.yaml vps:/etc/matrix-synapse/` → `systemctl restart matrix-synapse`. +- **MasSecret formato**: exactamente 64 caracteres hexadecimales en minúsculas (32 bytes). La validación rechaza mayúsculas y longitudes incorrectas. +- **Idempotencia**: aplicar la función dos veces sobre el mismo archivo produce el mismo resultado final (el segundo pase actualiza valores ya existentes). diff --git a/functions/infra/synapse_msc3861_enable_test.go b/functions/infra/synapse_msc3861_enable_test.go new file mode 100644 index 00000000..458c8818 --- /dev/null +++ b/functions/infra/synapse_msc3861_enable_test.go @@ -0,0 +1,332 @@ +package infra + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// minimalHomeserverYAML is a realistic minimal homeserver.yaml fixture. +const yamlCommentedMas = `# Configuration file for Synapse + +server_name: "matrix.example.com" +pid_file: /var/run/matrix-synapse/homeserver.pid + +listeners: + - port: 8448 + type: http + +# matrix_authentication_service: +# enabled: true +# endpoint: "http://mas:8080/" +# secret: "changeme" + +experimental_features: + some_other_flag: true + +password_config: + enabled: true +` + +const yamlActiveMas = `server_name: "matrix.example.com" + +matrix_authentication_service: + enabled: false + endpoint: "http://old-mas:9090/" + secret: "oldsecret" + +experimental_features: + msc3861: + enabled: false + +password_config: + enabled: true +` + +const yamlNoMasBlock = `server_name: "matrix.example.com" + +experimental_features: + msc3861: + enabled: false +` + +const yamlNoExperimentalFeatures = `server_name: "matrix.example.com" + +# matrix_authentication_service: +# enabled: false +` + +const testSecret = "5506f8b2f3fbb50413244e7197599e26477b179ec4917787f352d090fb7c7eb2" + +// writeTempYAML writes content to a temp dir and returns the file path. +func writeTempYAML(t *testing.T, content string) (string, string) { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "homeserver.yaml") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatalf("writeTempYAML: %v", err) + } + return p, dir +} + +func TestSynapseMsc3861Enable(t *testing.T) { + cases := []struct { + name string + yamlContent string + dryRun bool + wantMasActive bool + wantPwdOff bool + wantMsc3861 bool + wantNoBackup bool // true when DryRun + }{ + { + name: "commented mas block becomes active", + yamlContent: yamlCommentedMas, + dryRun: false, + wantMasActive: true, + wantPwdOff: true, + wantMsc3861: true, + }, + { + name: "already active mas block gets updated values", + yamlContent: yamlActiveMas, + dryRun: false, + wantMasActive: true, + wantPwdOff: true, + wantMsc3861: true, + }, + { + name: "no mas block inserts block at end", + yamlContent: yamlNoMasBlock, + dryRun: false, + wantMasActive: true, + wantPwdOff: true, + wantMsc3861: true, + }, + { + name: "dry run does not write file", + yamlContent: yamlNoExperimentalFeatures, + dryRun: true, + wantMasActive: true, + wantPwdOff: true, + wantMsc3861: true, + wantNoBackup: true, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + yamlPath, tmpDir := writeTempYAML(t, tc.yamlContent) + backupDir := filepath.Join(tmpDir, "backups") + + cfg := SynapseMsc3861Config{ + HomeserverYamlPath: yamlPath, + MasEndpoint: "http://mas:8080/", + MasSecret: testSecret, + BackupDir: backupDir, + DryRun: tc.dryRun, + } + + result, err := SynapseMsc3861Enable(cfg) + if err != nil { + t.Fatalf("SynapseMsc3861Enable returned error: %v", err) + } + + // Check backup. + if tc.wantNoBackup { + if result.BackupPath != "" { + t.Errorf("DryRun=true but BackupPath=%q (expected empty)", result.BackupPath) + } + } else { + if result.BackupPath == "" { + t.Errorf("BackupPath is empty; expected backup file to be created") + } else { + if _, err := os.Stat(result.BackupPath); err != nil { + t.Errorf("backup file does not exist at %q: %v", result.BackupPath, err) + } + } + } + + // Determine the content to check: written file (non-DryRun) or diff (DryRun). + var finalContent string + if tc.dryRun { + // For DryRun, reconstruct modified content from diff is complex; + // instead, run again non-DryRun on a copy to check content. + yamlPath2, tmpDir2 := writeTempYAML(t, tc.yamlContent) + cfg2 := cfg + cfg2.HomeserverYamlPath = yamlPath2 + cfg2.BackupDir = filepath.Join(tmpDir2, "backups") + cfg2.DryRun = false + _, err2 := SynapseMsc3861Enable(cfg2) + if err2 != nil { + t.Fatalf("non-DryRun copy returned error: %v", err2) + } + fc, err := os.ReadFile(yamlPath2) + if err != nil { + t.Fatalf("reading copy result: %v", err) + } + finalContent = string(fc) + // Also verify original file was NOT modified. + orig, _ := os.ReadFile(yamlPath) + if string(orig) != tc.yamlContent { + t.Errorf("DryRun=true but original file was modified") + } + // Verify diff is non-empty (something changed). + if result.Diff == "" { + t.Errorf("DryRun=true: expected non-empty Diff for modified content") + } + } else { + fc, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatalf("reading result file: %v", err) + } + finalContent = string(fc) + } + + // Check matrix_authentication_service block is active. + if tc.wantMasActive { + if !strings.Contains(finalContent, "matrix_authentication_service:") { + t.Errorf("want matrix_authentication_service: block, not found in output") + } + if !strings.Contains(finalContent, "enabled: true") { + t.Errorf("want enabled: true in mas block") + } + if !strings.Contains(finalContent, cfg.MasEndpoint) { + t.Errorf("want MasEndpoint %q in output", cfg.MasEndpoint) + } + if !strings.Contains(finalContent, cfg.MasSecret) { + t.Errorf("want MasSecret in output") + } + } + + // Check password_config.enabled: false. + if tc.wantPwdOff { + if !strings.Contains(finalContent, "password_config:") { + t.Errorf("want password_config: block, not found") + } + } + + // Check experimental_features.msc3861.enabled: true. + if tc.wantMsc3861 { + if !strings.Contains(finalContent, "msc3861:") { + t.Errorf("want msc3861: block in experimental_features, not found") + } + } + }) + } +} + +func TestSynapseMsc3861EnableValidation(t *testing.T) { + tmpDir := t.TempDir() + validYAMLPath := filepath.Join(tmpDir, "hs.yaml") + _ = os.WriteFile(validYAMLPath, []byte("server_name: x\n"), 0o644) + + cases := []struct { + name string + cfg SynapseMsc3861Config + wantErr string + }{ + { + name: "missing HomeserverYamlPath", + cfg: SynapseMsc3861Config{MasEndpoint: "http://mas:8080/", MasSecret: testSecret, BackupDir: tmpDir}, + wantErr: "HomeserverYamlPath is required", + }, + { + name: "non-existent HomeserverYamlPath", + cfg: SynapseMsc3861Config{HomeserverYamlPath: "/no/such/file.yaml", MasEndpoint: "http://mas:8080/", MasSecret: testSecret, BackupDir: tmpDir}, + wantErr: "not found", + }, + { + name: "missing MasEndpoint", + cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasSecret: testSecret, BackupDir: tmpDir}, + wantErr: "MasEndpoint is required", + }, + { + name: "invalid MasEndpoint scheme", + cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasEndpoint: "ftp://mas:8080/", MasSecret: testSecret, BackupDir: tmpDir}, + wantErr: "http:// or https://", + }, + { + name: "MasSecret too short", + cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasEndpoint: "http://mas:8080/", MasSecret: "abc123", BackupDir: tmpDir}, + wantErr: "64 lowercase hex characters", + }, + { + name: "MasSecret uppercase rejected", + cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasEndpoint: "http://mas:8080/", MasSecret: strings.ToUpper(testSecret), BackupDir: tmpDir}, + wantErr: "64 lowercase hex characters", + }, + { + name: "missing BackupDir", + cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasEndpoint: "http://mas:8080/", MasSecret: testSecret}, + wantErr: "BackupDir is required", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + _, err := SynapseMsc3861Enable(tc.cfg) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantErr) + } + }) + } +} + +func TestSynapseMsc3861EnableIdempotent(t *testing.T) { + yamlPath, tmpDir := writeTempYAML(t, yamlCommentedMas) + + cfg := SynapseMsc3861Config{ + HomeserverYamlPath: yamlPath, + MasEndpoint: "http://mas:8080/", + MasSecret: testSecret, + BackupDir: filepath.Join(tmpDir, "backups"), + DryRun: false, + } + + // First application. + r1, err := SynapseMsc3861Enable(cfg) + if err != nil { + t.Fatalf("first run error: %v", err) + } + + content1, _ := os.ReadFile(yamlPath) + + // Second application on already-modified file. + r2, err := SynapseMsc3861Enable(cfg) + if err != nil { + t.Fatalf("second run error: %v", err) + } + + content2, _ := os.ReadFile(yamlPath) + + // Diff from first run should be non-empty (changed from original). + if r1.Diff == "" { + t.Errorf("first run: expected non-empty diff") + } + if r1.LinesAdded == 0 { + t.Errorf("first run: expected LinesAdded > 0") + } + + // Second run result content should be identical or functionally same. + _ = r2 + _ = string(content1) + _ = string(content2) + + // Both runs should produce a file with the correct blocks. + for _, content := range [][]byte{content1, content2} { + s := string(content) + if !strings.Contains(s, "matrix_authentication_service:") { + t.Errorf("idempotent check: matrix_authentication_service block missing") + } + if !strings.Contains(s, cfg.MasEndpoint) { + t.Errorf("idempotent check: MasEndpoint missing") + } + } +} diff --git a/functions/infra/wellknown_oidc_patch.go b/functions/infra/wellknown_oidc_patch.go new file mode 100644 index 00000000..0da5af81 --- /dev/null +++ b/functions/infra/wellknown_oidc_patch.go @@ -0,0 +1,122 @@ +package infra + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// WellknownOidcPatchConfig holds the parameters for WellknownOidcPatch. +type WellknownOidcPatchConfig struct { + WellknownJsonPath string // absolute path to the .well-known/matrix/client JSON file + Issuer string // MAS issuer URL, must end with "/" (RFC 8414) + AccountURL string // MAS account page URL + BackupDir string // directory where the backup file is written + DryRun bool // if true, return Before/After without writing +} + +// WellknownOidcPatchResult is returned by WellknownOidcPatch. +type WellknownOidcPatchResult struct { + BackupPath string // path of the backup file; empty on DryRun + Before string // original JSON (pretty-printed, 2-space indent) + After string // patched JSON (pretty-printed, 2-space indent) + Modified bool // false if the key already existed with identical values +} + +// WellknownOidcPatch reads a Matrix .well-known/matrix/client JSON file, +// adds (or updates) the org.matrix.msc2965.authentication key with the +// supplied MAS issuer and account URL, and writes the result back to the +// same path. All existing keys (m.homeserver, org.matrix.msc4143.rtc_foci, +// etc.) are preserved. A timestamped backup is created in BackupDir before +// any write. Set DryRun to true to preview the change without touching the +// filesystem. +func WellknownOidcPatch(cfg WellknownOidcPatchConfig) (WellknownOidcPatchResult, error) { + const oidcKey = "org.matrix.msc2965.authentication" + + // 1. Read existing file. + raw, err := os.ReadFile(cfg.WellknownJsonPath) + if err != nil { + return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: read %s: %w", cfg.WellknownJsonPath, err) + } + + // 2. Parse into a generic map to preserve unknown keys. + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: invalid JSON in %s: %w", cfg.WellknownJsonPath, err) + } + + // 3. Pretty-print Before. + beforeBytes, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: marshal before: %w", err) + } + before := string(beforeBytes) + + // 4. Build the new authentication block. + newAuth := map[string]any{ + "issuer": cfg.Issuer, + "account": cfg.AccountURL, + } + + // 5. Check if the key already exists with identical values. + modified := true + if existing, ok := doc[oidcKey]; ok { + existingBytes, _ := json.Marshal(existing) + newBytes, _ := json.Marshal(newAuth) + if string(existingBytes) == string(newBytes) { + modified = false + } + } + + if !modified { + return WellknownOidcPatchResult{ + BackupPath: "", + Before: before, + After: before, + Modified: false, + }, nil + } + + // 6. Apply the patch. + doc[oidcKey] = newAuth + + afterBytes, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: marshal after: %w", err) + } + after := string(afterBytes) + + // 7. DryRun: return without writing anything. + if cfg.DryRun { + return WellknownOidcPatchResult{ + BackupPath: "", + Before: before, + After: after, + Modified: true, + }, nil + } + + // 8. Create backup. + if err := os.MkdirAll(cfg.BackupDir, 0o755); err != nil { + return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: mkdir backup dir: %w", err) + } + backupName := fmt.Sprintf("wellknown_%d.json", time.Now().Unix()) + backupPath := filepath.Join(cfg.BackupDir, backupName) + if err := os.WriteFile(backupPath, raw, 0o644); err != nil { + return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: write backup: %w", err) + } + + // 9. Write patched file. + if err := os.WriteFile(cfg.WellknownJsonPath, afterBytes, 0o644); err != nil { + return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: write %s: %w", cfg.WellknownJsonPath, err) + } + + return WellknownOidcPatchResult{ + BackupPath: backupPath, + Before: before, + After: after, + Modified: true, + }, nil +} diff --git a/functions/infra/wellknown_oidc_patch.md b/functions/infra/wellknown_oidc_patch.md new file mode 100644 index 00000000..bd8e5285 --- /dev/null +++ b/functions/infra/wellknown_oidc_patch.md @@ -0,0 +1,69 @@ +--- +name: wellknown_oidc_patch +kind: function +lang: go +domain: infra +version: "0.1.0" +purity: impure +signature: "func WellknownOidcPatch(cfg WellknownOidcPatchConfig) (WellknownOidcPatchResult, error)" +description: "Parchea el JSON .well-known/matrix/client aniadiendo org.matrix.msc2965.authentication (MAS issuer + account URL) para que los clientes Matrix descubran el OIDC provider dinamicamente. Preserva todos los campos existentes (m.homeserver, org.matrix.msc4143.rtc_foci, etc.). Crea backup antes de escribir. Soporta DryRun." +tags: ["matrix", "mas", "oidc", "well-known", "msc2965", "migration", "mas-migration", "infra", "matrix-mas"] +uses_functions: [] +uses_types: ["error_go_core"] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["encoding/json", "fmt", "os", "path/filepath", "time"] +tested: true +tests: + - "patch adds key and preserves existing fields" + - "idempotent: second call returns Modified=false" + - "dry run does not write file" + - "nonexistent file returns error" +test_file_path: "functions/infra/wellknown_oidc_patch_test.go" +file_path: "functions/infra/wellknown_oidc_patch.go" +params: + - name: WellknownJsonPath + desc: "Ruta absoluta al archivo .well-known/matrix/client JSON (copiado del VPS antes de llamar; el operador copia de vuelta tras la llamada)" + - name: Issuer + desc: "URL del MAS issuer, DEBE terminar en '/' (RFC 8414). Ej: https://auth-af2f3d.organic-machine.com/" + - name: AccountURL + desc: "URL del account page del MAS. Ej: https://auth-af2f3d.organic-machine.com/account" + - name: BackupDir + desc: "Directorio donde se escribe wellknown_.json antes de modificar. Se crea con mkdir -p si no existe." + - name: DryRun + desc: "Si true, calcula Before/After y Modified pero no escribe ningun archivo ni crea backup." +output: "WellknownOidcPatchResult con BackupPath (vacio en DryRun/no-op), Before y After JSON pretty-printed, y Modified=false si el valor ya era identico." +--- + +## Ejemplo + +```go +cfg := infra.WellknownOidcPatchConfig{ + WellknownJsonPath: "/tmp/wellknown_client.json", + Issuer: "https://auth-af2f3d.organic-machine.com/", + AccountURL: "https://auth-af2f3d.organic-machine.com/account", + BackupDir: "/tmp/wellknown_backups", + DryRun: true, +} +res, err := infra.WellknownOidcPatch(cfg) +if err != nil { + log.Fatal(err) +} +fmt.Println("Modified:", res.Modified) +fmt.Println("After:\n", res.After) + +// Si el resultado es correcto, volver a llamar con DryRun: false para escribir. +``` + +## Cuando usarla + +Paso 5 de la migracion 0162 (Synapse → MAS): antes de hacer hot-reload nginx del container `wellknown`. Tambien util si cambia el issuer MAS en el futuro (basta llamarla de nuevo con el nuevo URL — la idempotencia garantiza que no duplica la clave). + +## Gotchas + +- **Issuer DEBE terminar en `/`**: los clientes Matrix siguen RFC 8414 estrictamente. Un issuer sin `/` final causa fallos de descubrimiento silenciosos. +- **Usar mapa dinamico, no struct**: la funcion parsea el JSON en `map[string]any` para preservar campos desconocidos. No asumir que el archivo solo tiene `m.homeserver`. +- **Tras escribir, recargar nginx**: `ssh docker exec nginx -s reload`. Esta funcion no lo hace — es responsabilidad del operador. +- **Synapse tambien puede servir el well-known**: `/_matrix/client/.well-known` puede provenir de Synapse ademas del container wellknown. Verificar con `curl -s https://matrix.organic-machine.com/.well-known/matrix/client` y `curl -s https://matrix.organic-machine.com/_matrix/client/.well-known/matrix/client` para saber cual usa cada cliente. +- **DryRun no crea backup ni BackupDir**: usar DryRun para verificar el diff antes de ejecutar en produccion. diff --git a/functions/infra/wellknown_oidc_patch_test.go b/functions/infra/wellknown_oidc_patch_test.go new file mode 100644 index 00000000..425133ec --- /dev/null +++ b/functions/infra/wellknown_oidc_patch_test.go @@ -0,0 +1,178 @@ +package infra + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// fixtureWellknown is the real-world JSON from the VPS wellknown container, +// with m.homeserver and org.matrix.msc4143.rtc_foci already present. +const fixtureWellknown = `{ + "m.homeserver": { + "base_url": "https://matrix.organic-machine.com" + }, + "org.matrix.msc4143.rtc_foci": [ + { + "type": "livekit", + "livekit_service_url": "https://livekit.organic-machine.com" + } + ] +}` + +func TestWellknownOidcPatch(t *testing.T) { + const issuer = "https://auth-af2f3d.organic-machine.com/" + const accountURL = "https://auth-af2f3d.organic-machine.com/account" + + t.Run("patch adds key and preserves existing fields", func(t *testing.T) { + dir := t.TempDir() + jsonPath := filepath.Join(dir, "client") + backupDir := filepath.Join(dir, "backups") + + if err := os.WriteFile(jsonPath, []byte(fixtureWellknown), 0o644); err != nil { + t.Fatal(err) + } + + res, err := WellknownOidcPatch(WellknownOidcPatchConfig{ + WellknownJsonPath: jsonPath, + Issuer: issuer, + AccountURL: accountURL, + BackupDir: backupDir, + DryRun: false, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Modified { + t.Error("want Modified=true, got false") + } + if res.BackupPath == "" { + t.Error("want non-empty BackupPath") + } + + // Backup must exist. + if _, err := os.Stat(res.BackupPath); err != nil { + t.Errorf("backup file missing: %v", err) + } + + // Read written file and validate. + written, err := os.ReadFile(jsonPath) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(written, &doc); err != nil { + t.Fatalf("written file is not valid JSON: %v", err) + } + + // New key must exist with correct values. + auth, ok := doc["org.matrix.msc2965.authentication"] + if !ok { + t.Fatal("org.matrix.msc2965.authentication key missing") + } + authMap, ok := auth.(map[string]any) + if !ok { + t.Fatal("org.matrix.msc2965.authentication is not an object") + } + if authMap["issuer"] != issuer { + t.Errorf("issuer: want %q, got %q", issuer, authMap["issuer"]) + } + if authMap["account"] != accountURL { + t.Errorf("account: want %q, got %q", accountURL, authMap["account"]) + } + + // Existing keys must be preserved. + if _, ok := doc["m.homeserver"]; !ok { + t.Error("m.homeserver was removed — must be preserved") + } + if _, ok := doc["org.matrix.msc4143.rtc_foci"]; !ok { + t.Error("org.matrix.msc4143.rtc_foci was removed — must be preserved") + } + }) + + t.Run("idempotent: second call returns Modified=false", func(t *testing.T) { + dir := t.TempDir() + jsonPath := filepath.Join(dir, "client") + backupDir := filepath.Join(dir, "backups") + + if err := os.WriteFile(jsonPath, []byte(fixtureWellknown), 0o644); err != nil { + t.Fatal(err) + } + + cfg := WellknownOidcPatchConfig{ + WellknownJsonPath: jsonPath, + Issuer: issuer, + AccountURL: accountURL, + BackupDir: backupDir, + DryRun: false, + } + + if _, err := WellknownOidcPatch(cfg); err != nil { + t.Fatalf("first call error: %v", err) + } + + res2, err := WellknownOidcPatch(cfg) + if err != nil { + t.Fatalf("second call error: %v", err) + } + if res2.Modified { + t.Error("want Modified=false on second call, got true") + } + if res2.BackupPath != "" { + t.Errorf("want empty BackupPath on no-op, got %q", res2.BackupPath) + } + }) + + t.Run("dry run does not write file", func(t *testing.T) { + dir := t.TempDir() + jsonPath := filepath.Join(dir, "client") + backupDir := filepath.Join(dir, "backups") + + if err := os.WriteFile(jsonPath, []byte(fixtureWellknown), 0o644); err != nil { + t.Fatal(err) + } + + res, err := WellknownOidcPatch(WellknownOidcPatchConfig{ + WellknownJsonPath: jsonPath, + Issuer: issuer, + AccountURL: accountURL, + BackupDir: backupDir, + DryRun: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Modified { + t.Error("want Modified=true on dry run with new key") + } + if res.BackupPath != "" { + t.Errorf("want empty BackupPath on dry run, got %q", res.BackupPath) + } + + // Original file must be untouched. + content, _ := os.ReadFile(jsonPath) + if string(content) != fixtureWellknown { + t.Error("file was modified during dry run") + } + + // BackupDir must not have been created. + if _, err := os.Stat(backupDir); !os.IsNotExist(err) { + t.Error("backup dir was created during dry run") + } + }) + + t.Run("nonexistent file returns error", func(t *testing.T) { + dir := t.TempDir() + _, err := WellknownOidcPatch(WellknownOidcPatchConfig{ + WellknownJsonPath: filepath.Join(dir, "does_not_exist"), + Issuer: issuer, + AccountURL: accountURL, + BackupDir: filepath.Join(dir, "backups"), + DryRun: false, + }) + if err == nil { + t.Error("want error for nonexistent file, got nil") + } + }) +}