feat(matrix): MAS migration helpers + 2 flows + 15 issues + capability group

Helper functions (matrix-mas capability group):
- mas_client_register_bash_infra: register/sync OAuth clients via mas-cli
- mas_syn2mas_migration_bash_infra: dry-run + apply user migration to MAS
- synapse_msc3861_enable_go_infra: edit homeserver.yaml MSC3861 block (with diff)
- wellknown_oidc_patch_go_infra: patch well-known JSON with msc2965.authentication
- synapse_login_flows_check_go_infra: health-check post-migration login flows

Flows + issues for custom Matrix clients (PC + Android):
- 0010 matrix-client-pc: Wails + React+Mantine (issues 0147-0153)
- 0011 matrix-client-android: Kotlin + Compose (issues 0154-0161)
- 0162 enable MAS as auth provider (Synapse delegate) — EXECUTED on VPS
- 0163 custom admin panel propio (sustituye synapse-admin)

Production state (organic-machine.com):
- Synapse migrated SQLite -> Postgres
- MSC3861 active, password_config disabled
- 21 users + 41 access_tokens migrated via syn2mas
- 4 MAS clients registered (element, matrix_pc, matrix_android, admin_panel)
- synapse-admin container removed + Coolify route deleted
- well-known patched with org.matrix.msc2965.authentication

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
egutierrez
2026-05-24 22:53:33 +02:00
parent 3a8b4c2179
commit daef7ea190
35 changed files with 4491 additions and 0 deletions
@@ -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 <container> 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 <alias>` 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).
+204
View File
@@ -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 <host> --container <name> --config-file <path> [--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
@@ -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
@@ -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 <host> --mas-container <name> --synapse-config-path <path-on-host> --log-dir <local-path> [--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 <container> | 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.
@@ -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 <host> --mas-container <name> \
# --synapse-config-path <path-on-host> --log-dir <local-path> \
# [--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 <host> \
--mas-container <name> \
--synapse-config-path <path-on-host> \
--log-dir <local-path> \
[--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 <container> mas-cli syn2mas --synapse-config <path>
# donde <path> 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
@@ -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
+157
View File
@@ -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 <url>` 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).
+165
View File
@@ -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/<userId>/`.
## 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.
@@ -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.
@@ -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:<puerto>/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.
@@ -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 `<script>`, `<iframe>`, event handlers.
- mxc:// uploads: validar size limit (Synapse default 50MB).
- Voice msg: encode opus 32kbps, max 5min.
+73
View File
@@ -0,0 +1,73 @@
---
id: "0150"
title: "matrix-client-pc E2EE: cross-signing, SAS verification, recovery"
status: pending
priority: critical
created: 2026-05-24
related_flows: ["0010"]
related_issues: ["0149", "0151"]
dependencies: ["0149"]
tags: [matrix, e2ee, olm, megolm, cross-signing, recovery, security]
---
## Objetivo
Encriptacion end-to-end con `mautrix-go` (Olm/Megolm). Cross-signing keys (master/self-signing/user-signing), SAS verification de devices (emoji + decimal), recovery passphrase + key backup en Synapse, manejo de devices no verificados con warning visible. Mensajes en rooms encriptados se envian y descifran correctamente.
## Tareas
1. Backend Go:
- `MatrixService.BootstrapCrossSigning(passphrase)` — genera master/self/user keys, sube a Synapse cifradas con passphrase-derived key.
- `MatrixService.RecoverFromPassphrase(passphrase)` — descarga keys de Synapse y descifra.
- `MatrixService.StartVerification(userID, deviceID) -> *VerificationSession`.
- `MatrixService.VerifyEmoji(sessionID, accepted bool)`.
- `MatrixService.ListDevices() -> []Device` (con verified flag).
- `MatrixService.BackupMegolmKeys()` — key backup server-side.
- Crypto store SQLite separado del state store (mejor para integridad).
2. Frontend React:
- Wizard onboarding E2EE: pasos (1) generar passphrase, (2) backup, (3) verificar device.
- Panel `Settings > Security & Privacy`:
- Lista devices propios con verified state.
- Boton "Verify new device" + dialog SAS con emoji grid.
- "Reset cross-signing" (destructive, requiere confirmacion).
- "Restore from passphrase" (login en device nuevo).
- `EventBubble` muestra shield: green (verified), amber (encrypted, device unverified), red (decryption failed).
- Banner room: "X devices are not verified" si algun miembro tiene devices unverified.
3. Tests:
- `e2e/test_e2ee_send_receive.sh` — msg enviado en room encriptado se descifra en Element Web.
- `e2e/test_cross_signing.sh` — bootstrap + verificar device desde Element Web.
- `e2e/test_recovery.sh` — login en device nuevo + recover keys con passphrase.
- `e2e/test_unverified_warning.sh` — device nuevo aparece como warning en otros clientes.
## Funciones del registry a crear
- `matrix_e2ee_bootstrap_go_infra` — wrapper cross-signing bootstrap.
- `matrix_device_verify_go_infra` — SAS verification flow.
- `matrix_key_backup_go_infra` — server-side key backup wrapper.
- `passphrase_derive_key_go_infra` — PBKDF2/scrypt para derivar key de passphrase.
- `VerificationDialog_ts_ui` — componente emoji grid SAS.
## Acceptance
- [ ] Bootstrap cross-signing crea 3 keys + las sube a Synapse cifradas.
- [ ] Msg enviado a room encriptado se descifra en Element Web (y al reves).
- [ ] SAS verification con emoji grid funciona contra Element Web (ambos lados muestran 7 emojis iguales).
- [ ] Login en device nuevo + restore con passphrase recupera msgs historicos.
- [ ] Device no verificado dispara shield amber en EventBubble.
- [ ] Decryption failure (key no disponible) muestra shield rojo + boton "Request key".
## Notas
**Critico — anti-criterio:**
- NO marcar done si E2EE silent-falla (msg muestra "** Unable to decrypt **" sin shield rojo claro).
- NO marcar done si recovery passphrase queda en plain text en disco (debe vivir solo en keyring/memoria).
**Decisiones:**
- Olm/Megolm via `mautrix-go/crypto` (Go port estable de libolm).
- Alternativa rust-crypto via CGo: descartada, mantiene complejidad build.
- Passphrase format: 4 palabras Diceware o 12-byte base32. Usuario elige al bootstrap.
**Gotchas:**
- Key rotation: rooms encriptados rotan megolm cada 1 semana o 100 msgs (default). Manejar refresh.
- Olm sessions max 100 mensajes: rotar prekey bundles automaticamente.
- Cuando arrancas device nuevo sin passphrase, los msgs pre-existentes NO se descifran — UI debe ser clara.
@@ -0,0 +1,69 @@
---
id: "0151"
title: "matrix-client-pc calls LiveKit: 1:1 + grupales, mic/cam/screen"
status: pending
priority: high
created: 2026-05-24
related_flows: ["0010"]
related_issues: ["0150", "0152"]
dependencies: ["0150"]
tags: [matrix, livekit, calls, webrtc, video, audio, screen-share]
---
## Objetivo
Llamadas via LiveKit SFU (ya activo en `organic-machine.com:7880-7882`). Backend Go genera JWT con `livekit-server-sdk-go`. Frontend React usa `livekit-client` JS para join room, manejar tracks (mic/cam/screen), UI con tiles participantes, controles. Soporta 1:1 + grupales hasta 16 (limite config actual).
## Tareas
1. Backend Go:
- `MatrixService.RequestCallToken(matrixRoomID) -> (token, livekitRoomURL)`.
- Mapea Matrix roomID -> LiveKit room name (hash determinista).
- Genera JWT con claim `room`, `identity` (matrix userID), `ttl 30min`.
- Permisos: `canPublish=true, canSubscribe=true, canPublishData=true`.
- Publicar event Matrix `m.call.member` para sincronizar quien esta en call (MSC3401).
2. Frontend React:
- Hook `useLiveKitCall(matrixRoomID)`:
- Pide token al backend.
- Conecta `Room` de `livekit-client`.
- Expone participants, tracks, localTracks, state.
- Auto-publish microfono on connect (mute default).
- Componente `CallPanel`:
- Grid tiles participantes (1, 2, 4, 9, 16 layout).
- Tile principal con speaker activo (active-speaker detection del SDK).
- Controles bottom: mic, cam, screen share, raise hand, leave.
- PiP mode: cuando minimizado, tile flotante en esquina.
- Boton "Start call" en header del room (icono telefono).
- Boton "Join call" si hay call activa (segun `m.call.member` events).
- Notifs ring incoming call: audio + desktop notif.
3. Backend ICE/TURN:
- Verificar LiveKit config tiene TURN configurado (NAT traversal). Si no, anadir coturn container.
4. Tests:
- `e2e/test_call_1to1.sh` — 2 clientes (Wails + Element Web), 30s call, audio+video flow.
- `e2e/test_call_screen_share.sh` — compartir pantalla, otro cliente ve el track.
- `e2e/test_call_4_participants.sh` — 4 clientes simultaneos, no crash.
## Funciones del registry a crear
- `livekit_token_gen_go_infra` — JWT generator con `livekit-server-sdk-go`.
- `matrix_call_member_go_infra` — wrapper para publicar/leer `m.call.member` state events.
- `useLiveKitCall_ts_ui` — hook React.
- `CallPanel_ts_ui` — componente UI completo de call.
- `CallTile_ts_ui` — tile individual con video + nombre + speaker indicator.
## Acceptance
- [ ] Boton "Start call" en room DM con otro user.
- [ ] Otro cliente (Element Web) ve ring + acepta -> 2 tiles con video+audio.
- [ ] Mute mic + apagar cam funciona y se refleja en el otro lado.
- [ ] Screen share: tile separado aparece para todos los participantes.
- [ ] 4 participantes simultaneos sin crash ni audio cortado.
- [ ] Hangup limpia recursos (no tracks fantasma, no peer connections abiertas).
## Notas
- LiveKit room name: `sha256(matrix_room_id + secret)` truncado a 32 chars. Asi cualquier cliente que conozca el matrix_room_id puede computar el room name (no es secret).
- Token TTL 30min, refresh proactivo a los 25min.
- Codecs: H.264 + VP8 fallback para compatibilidad navegadores. Audio: Opus 32kbps.
- E2EE en calls: LiveKit soporta E2EE simetrico (insertable streams API). TBD para version posterior — flow inicial usa SRTP only (cifrado SFU<->client, no e2e).
- Sygnal push para incoming calls: enviar VoIP push con TTL bajo para wake-up moviles (relevante para issue 0158 Android).
@@ -0,0 +1,81 @@
---
id: "0152"
title: "matrix-client-pc mini-webapps embebidas: Matrix Widget API v2"
status: pending
priority: high
created: 2026-05-24
related_flows: ["0010"]
related_issues: ["0151", "0153"]
dependencies: ["0151"]
tags: [matrix, widgets, webapps, iframe, sandbox, agents, postmessage]
---
## Objetivo
Implementar host de widgets segun Matrix Widget API v2 (MSC2762, MSC2871, MSC2974). Cada room puede tener widgets activos publicados como state events `m.widget`. Los widgets son URLs cargadas en iframes sandboxed con bridge postMessage que da capabilities controladas (leer eventos del room, enviar eventos, mostrar UI overlay, etc.). Agentes de `agents_and_robots` pueden publicar widgets en sus rooms (ej. dashboard telemetria, formulario, kanban inline, panel de control del agente).
## Tareas
1. Backend Go:
- `MatrixService.ListWidgets(roomID) -> []Widget` — lee state events `m.widget` del room.
- `MatrixService.AddWidget(roomID, widget Widget)` — publica state event.
- `MatrixService.RemoveWidget(roomID, widgetID)`.
- `MatrixService.GenerateWidgetURL(widget Widget, userID) -> string` — substituye `$matrix_user_id`, `$matrix_room_id`, `$matrix_display_name`, `$matrix_avatar_url`, `$matrix_widget_id`, `$theme` en la URL del widget.
- Slash command `/widget <url>` handler en composer (issue 0149) que crea state event con widget temporal.
- `MatrixService.MintWidgetScopedToken(widgetID, userID) -> string` — token efimero con scope reducido (solo el room donde esta el widget).
2. Frontend React:
- Hook `useWidgets(roomID)` — lista widgets activos.
- Componente `WidgetPanel`:
- Tabs por widget activo + boton "+" para anadir.
- Cada widget en iframe con `sandbox="allow-scripts allow-same-origin allow-forms allow-popups-to-escape-sandbox"`.
- `iframe.referrerpolicy="no-referrer"`.
- CSP: `frame-src https: data: blob:`.
- `WidgetBridge` — clase JS que escucha `postMessage` del iframe e implementa Widget API v2:
- `capabilities` handshake: el widget declara que necesita, el host pide consentimiento usuario (dialog Mantine).
- `read_events`, `send_event`, `send_to_device`, `get_openid`, `m.always_on_screen`, etc.
- Whitelist estricta de capabilities concedidas. Audit log de mensajes en `store.db`.
- Layout: widgets se abren en panel lateral derecho (toggleable) o en modal fullscreen.
3. Widgets internos primer batch (proof of concept):
- `widget-jitsi-fallback` — si LiveKit falla, fallback a Jitsi via widget (URL config).
- `widget-agent-panel` — panel de control de agente: estado, ultima ejecucion, restart, view logs. Servido por `agents_and_robots` HTTP API (issue 0113 ya creando agent runner API).
- `widget-kanban` — kanban inline embebido para tasks del room. Reusa `apps/kanban` (Go) servido en LAN.
- `widget-issue-tracker` — widget que abre issue API (`0109m`).
4. Tests:
- `e2e/test_widget_capabilities.sh` — widget pide capability, dialog aparece, deniega/acepta funciona.
- `e2e/test_widget_send_event.sh` — widget con capability `send_event` envia msg al room.
- `e2e/test_widget_sandbox.sh` — widget malicioso (intenta `top.location =`) es bloqueado por sandbox.
## Funciones del registry a crear
- `matrix_widget_state_go_infra` — CRUD state events `m.widget`.
- `widget_url_template_go_core` — substituye placeholders en URL.
- `widget_token_mint_go_infra` — token scoped a un widget+room+user.
- `WidgetBridge_ts_ui` — clase postMessage bridge Widget API v2 completa.
- `WidgetPanel_ts_ui` — UI tabs + iframes + permisos.
- `CapabilityConsentDialog_ts_ui` — dialog Mantine para consentimiento.
## Acceptance
- [ ] `/widget https://my.app` crea state event y abre iframe.
- [ ] Widget declara capability `m.send_event` -> dialog Mantine pide consentimiento.
- [ ] Widget concedido envia msg al room que aparece en timeline.
- [ ] Widget malicioso `<script>top.location='evil.com'</script>` 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).
@@ -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 <name>`).
## 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/<id>/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 <name> <args>`, `/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 <cmd>` (si capability permite), `/device fs ls <path>`, `/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.
@@ -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 `<mas_url>/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 @<userId>")` + 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.*`.
@@ -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<List<RoomSummary>>`. Ordenado por `lastActivity`.
- `TimelineViewModel(matrixClient, roomId)` — expone `StateFlow<List<TimelineEvent>>` + `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.
@@ -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.
@@ -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<List<Device>>`.
- `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.
@@ -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<CallState>` 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.
@@ -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-<hash>.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.
@@ -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<List<Widget>>` 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).
@@ -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.
@@ -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: "<shared_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: [<admin_panel_url>]`.
- 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: "<shared_secret_matching_mas_config>"
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.
@@ -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/<id>/deactivate`.
- `GET /api/rooms`, `POST /api/rooms/<id>/delete`.
- `GET /api/mas/sessions`, `POST /api/mas/sessions/<id>/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 <admin_token>` — 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.
+80
View File
@@ -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.
@@ -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)
}
}
@@ -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.
@@ -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)
}
})
}
+531
View File
@@ -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
}
// --- 36. 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
}
+70
View File
@@ -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).
@@ -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")
}
}
}
+122
View File
@@ -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
}
+69
View File
@@ -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_<unix_ts>.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 <host> docker exec <wellknown_container> 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.
@@ -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")
}
})
}