Privacidad (decisión del usuario: al móvil solo datos de agenda):
- _compose_agenda_vcard compone el vCard desde el contacto (fn/tels/emails) + las
direcciones (ADR) y aliases (NICKNAME) de la persona enlazada por note_path, SIN pasar
nunca el dict osint a build_vcard → el vCard jamás lleva X-OSINT-* (DNI/sexo/fecha-nac
quedan solo en DuckDB+Obsidian). Usado en upsert_contact y en el push masivo push_all_dav
(que antes leía solo contacts y perdía las direcciones).
Sync inverso DAVx5→DuckDB (last-write-wins por etag):
- Tras cada push se captura el etag nuevo del recurso (dav_list_resources) y se persiste en
contacts.etag, para no confundir el push propio con una edición del móvil.
- POST /api/sync/dav-pull: pull incremental — compara etags, descarga SOLO los recursos
cambiados/nuevos (dav_get_resource + parse_vcard + upsert), borra los que el móvil quitó,
re-enlaza. Distinto del ingest_dav (DELETE+INSERT ciego): respeta la verdad de la DB salvo
donde el etag prueba un cambio externo.
20 tests verdes (18 + 2 nuevos: vCard sin OSINT con direcciones; pull incremental por etag).
- TrustedHostMiddleware (allowed_hosts 127.0.0.1/localhost/testserver): cierra el vector
por el que una web maliciosa rebindea su dominio a 127.0.0.1 y alcanza /api/query desde
el navegador del usuario (el service no tiene auth por ser local).
- _build_vcalendar escapaba nada: UID/SUMMARY/LOCATION/RRULE crudos permitían iCal
injection. Ahora _ical_escape (summary/location) + _ical_sanitize (uid/rrule, quita
saltos de línea sin tocar los separadores legítimos de la regla).
Auditoría de seguridad: el fallo CRÍTICO (LFI/escritura via /api/query) se cierra con el
sandbox de duckdb_query_readonly en el registry; este commit cubre los hallazgos ALTA
(DNS-rebinding) y MEDIA (iCal injection).
F1 — migraciones: 002_multivalue (persons +telefonos/emails/direcciones/extra_fm JSON,
backfill desde singulares con to_json) + 003_addressbooks (tabla addressbooks + seed
idempotente de la libreta por defecto). Conteos intactos (697/1065/98).
F2 — ingest_vault selectivo (anti-pisado): personas que ya existen en DB solo actualizan
note_path + extra_fm vía duckdb_upsert(update_cols=...), NO pisan los campos OWNED por la
DB; personas nuevas = bootstrap completo. _link_contacts enlaza por listas telefonos[]/
emails[] además del singular. ingest_dav itera todas las libretas de la tabla addressbooks.
F3 — escritura estructurada (server/writes.py + endpoints en main.py): CRUD
/api/person|contact|event, /api/addressbook, /api/calendar, /api/person/{slug}/render
(DB→nota preservando la prosa del cuerpo), /api/push/dav (reconcilia DB→Xandikos). El push
DAV y el render ocurren fuera de la transacción de escritura para no bloquear la DB con
latencia de red. registry_bridge.py importa las funciones nuevas; app.md actualizado.
Verificado: 18 tests verdes; ownership probado sobre datos reales (un centinela DB-owned
sobrevivió a POST /api/ingest/vault sobre las 697 fichas); person CRUD + materialización
de la ficha .md en vivo, con cleanup sin residuo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>