--- name: osint_db lang: py domain: osint version: 0.1.0 description: "Service FastAPI local (solo 127.0.0.1, puerto 8771) dueño único de la base DuckDB data/osint.duckdb: fuente de verdad estructurada del ecosistema OSINT. Indexa el vault de Obsidian osint (notes + persons/organizations/domains/cases/places con note_path), importa las maestras DAV de Xandikos (contacts, events), computa derivadas sin referencias a notas y renderiza tableros Markdown con bloques sentinel idempotentes dentro del vault." tags: [service, osint, duckdb, obsidian, dav, vault] uses_functions: - list_obsidian_notes_py_obsidian - read_obsidian_note_py_obsidian - update_obsidian_note_py_obsidian - create_obsidian_note_py_obsidian - slugify_obsidian_name_py_obsidian - dav_get_collection_py_infra - dav_list_calendars_py_infra - dav_list_addressbooks_py_infra - dav_collection_ctag_py_infra - dav_list_resources_py_infra - dav_get_resource_py_infra - carddav_put_vcard_py_infra - caldav_put_event_py_infra - dav_delete_resource_py_infra - dav_make_addressbook_py_infra - dav_make_calendar_py_infra - pass_get_secret_py_infra - duckdb_query_readonly_py_infra - duckdb_execute_py_infra - duckdb_upsert_py_infra - build_vcard_py_core - render_markdown_table_py_core - upsert_sentinel_block_py_core uses_types: [] framework: "fastapi" entry_point: "server/main.py" dir_path: "projects/osint/apps/osint_db" repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/osint_db" service: port: 8771 health_endpoint: /api/health health_timeout_s: 3 systemd_unit: null systemd_scope: null restart_policy: none runtime: manual pc_targets: - lucas-linux is_local_only: true e2e_checks: - id: tests cmd: ".venv/bin/python -m pytest tests -q" timeout_s: 120 - id: vault_missing cmd: ".venv/bin/python server/main.py --vault /no/existe --port 0" expect_exit: 2 timeout_s: 30 --- ## Qué es Service del project `osint` que posee en exclusiva la base DuckDB `data/osint.duckdb`, la fuente de verdad estructurada del ecosistema OSINT (vault de Obsidian `~/Obsidian/osint` + servidor Xandikos CardDAV/CalDAV). Un plugin de Obsidian se construye en paralelo contra el contrato de esta API, por eso los endpoints de datos responden SIEMPRE HTTP 200 con `status: ok|error` en el body (el plugin parsea el body, no el código HTTP). ## Arquitectura de datos (3 categorías) 1. **Maestras con referencia a notas** (schema `main`): `notes` (índice completo del vault) + `persons`, `organizations`, `domains`, `cases`, `places` (fichas de nivel-1 de cada carpeta de entidades, excluyendo las notas con prefijo `_`). Cada una lleva `note_path`: el path relativo de la nota dentro del vault. **`persons` es dueña de sus campos estructurados** (multi-valor `telefonos`/`emails`/`direcciones` JSON + singulares de compat `telefono`/`email`/`direccion`): la API los edita y los materializa a la nota. El ingest del vault es **selectivo** para `persons` — una ficha que ya existe en la DB solo refresca `note_path` + `extra_fm` (el frontmatter no-owned), conservando los campos OWNED; una ficha nueva se inserta completa (bootstrap desde el frontmatter). `addressbooks` (schema `main`) registra las libretas CardDAV: el ingest DAV las recorre todas (no solo la fija). 2. **Maestras DAV** (schema `main`): `contacts` y `events` importados de Xandikos — fuente de verdad del lado agenda/calendario. `contacts.note_path` se enlaza contra `persons` matcheando por UID `osint-`, por el `dav_uid` extraído del campo `fuente` de la ficha, por teléfono normalizado o por email. 3. **Derivadas** (schema `derived`): SOLO datos computados. **Regla dura: ninguna tabla de `derived` lleva columna que referencie notas** (`note_path` prohibido ahí; hay un test que lo verifica vía `information_schema`). Se reconstruyen completas (DROP + CREATE) en cada ingest: `derived.person_stats` (agregados por contexto/país/tag), `derived.event_monthly` (eventos por calendario y mes) y `derived.contact_link_quality` (contactos enlazados vs no, solo números). Single-writer: SOLO este service escribe la DuckDB. La conexión de escritura se abre bajo demanda (migraciones, ingest, render) serializada con un lock de proceso y se cierra al terminar; las lecturas de `/api/query` abren su propia conexión `read_only` vía `duckdb_query_readonly`. ## Migraciones `migrations/NNN_*.sql` numeradas, aditivas e idempotentes, aplicadas en orden al arrancar. La tabla `_migrations` registra las aplicadas (regla `db_migrations` adaptada a DuckDB). ## Arrancar ```bash cd projects/osint/apps/osint_db uv sync # primera vez: crea .venv .venv/bin/python server/main.py # defaults: vault ~/Obsidian/osint, puerto 8771 .venv/bin/python server/main.py --vault ~/Obsidian/osint --db data/osint.duckdb --port 8771 ``` Health check: `curl http://127.0.0.1:8771/api/health`. ## Endpoints | Método | Ruta | Qué hace | |---|---|---| | GET | `/api/health` | `{"status":"ok","db_path":"...","tables":N}` | | GET | `/api/tables` | inventario: schema, name, kind master/derived, row_count, columnas | | POST | `/api/query` | `{sql, params, max_rows}` → respuesta exacta de `duckdb_query_readonly` (solo lectura) | | GET | `/api/queries` | catálogo de queries con nombre (`server/named_queries.py`) | | POST | `/api/query/named` | `{name, max_rows}` → misma shape que `/api/query` | | POST | `/api/ingest/vault` | escanea el vault completo; notes y entidades de espejo puro se reemplazan, persons se ingesta SELECTIVO (existentes solo `note_path`+`extra_fm`, nuevas bootstrap completo) | | POST | `/api/ingest/dav` | baja TODAS las libretas registradas en `addressbooks` + cada calendario CalDAV, reconstruye contacts/events, enlaza y reconstruye derivadas | | POST | `/api/render/note` | `{note_path, block_id, sql\|query, title?}` → tabla Markdown upsertada como bloque sentinel `osintdb` en la nota (la crea si no existe) | | POST/PUT/DELETE | `/api/person[/{slug}]` | CRUD de personas multi-valor (`telefonos`/`emails`/`direcciones` listas). Tras escribir la DB, materializa la ficha DB→nota (singulares = `lista[0]`) sin tocar la prosa | | POST | `/api/person/{slug}/render` | re-materializa la ficha DB→nota (frontmatter OWNED + merge `extra_fm`, preserva el body) | | POST/PUT/DELETE | `/api/contact[/{uid}]` | CRUD de contactos CardDAV (`tels`/`emails` listas). Tras la DB, push DB→Xandikos (`build_vcard`+`carddav_put_vcard`, o `dav_delete_resource` en delete) fuera de la transacción | | POST/PUT/DELETE | `/api/event[/{uid}]` | CRUD de eventos CalDAV. Push `caldav_put_event`/`dav_delete_resource` | | POST | `/api/addressbook` | `{slug, display_name?, description?, color?}` → `dav_make_addressbook` + INSERT en `addressbooks` | | POST | `/api/calendar` | `{slug, display_name?, color?}` → `dav_make_calendar` (paridad) | | POST | `/api/push/dav` | reconcilia en bloque: recorre `contacts` y `events` de la DB y los empuja a Xandikos (PUT, sin borrar). Útil tras la migración | Queries con nombre incluidas: `personas_por_contexto`, `personas_recientes`, `eventos_proximos`, `contactos_sin_nota`, `stats_personas`, `calidad_enlace_contactos`, `eventos_por_mes`. ## Configuración `server/config.py`: vault (`~/Obsidian/osint`), db (`data/osint.duckdb` relativa a la app), DAV base/colecciones de Xandikos (los mismos valores que `projects/osint/tools/sync_dav_to_osint.py`) y puerto 8771. Overrides por CLI: `--vault`, `--db`, `--port`. La credencial DAV se resuelve SIEMPRE con `pass_get_secret("dav/xandikos-enmanuel")`, nunca hardcodeada. ## Seguridad - El vault y la base contienen datos personales sensibles: el server escucha solo en `127.0.0.1` y `runtime: manual` (no se despliega a VPS, `is_local_only: true`). - `/api/query` es estrictamente read-only (conexión `read_only` de DuckDB). - `/api/render/note` valida que el path destino no escapa del vault (realpath bajo el realpath del vault). - Vault inexistente al arrancar → error claro en stderr + exit 2. ## Tests ```bash cd projects/osint/apps/osint_db .venv/bin/python -m pytest tests -q ``` Vault temporal + DuckDB temporal, sin red: migraciones idempotentes, ingest del vault con fixture (conteos, exclusión de sub-notas y `_`), extracción de `dav_uid` desde `fuente`, `/api/query` ok/error/solo-lectura (siempre HTTP 200), catálogo y queries con nombre, inventario de tablas, regla dura derivadas sin `note_path`, enlace contacto→ficha por teléfono y render de nota con bloque sentinel idempotente + validación de inputs y path traversal. ## Gotchas - DuckDB bloquea el archivo a un escritor exclusivo: mientras un ingest está escribiendo, una lectura `read_only` concurrente puede devolver `{"status":"error"}` por conflicto de lock. El cliente reintenta. - `/api/ingest/dav` requiere `pass` desbloqueado (gpg-agent); si gpg está bloqueado devuelve `{"status":"error"}` con el detalle, sin crash. - Las notas con prefijo `_` (`_indice.md`, `_plantilla.md`) y las sub-notas de documento (`personas//.md`) entran en `notes` pero NO cuentan como entidades.