Ir al contenido

Arquitectura

La arquitectura de EMA Vault refleja su naturaleza de back-office interno: un solo worker Hono sirve la API y la SPA, dos backends de Supabase separados (uno principal en emahub y otro de solo lectura para Ema Well), y un puñado de servicios externos controlados —Anthropic para AI, GitHub para sync de issues, Stripe para los productos B2C que viven en Vault, Loops para correo. Lo que distingue a Vault del resto del ecosistema no es la complejidad sino la anchura del dominio: el mismo worker maneja pipeline comercial, contratos, finanzas, configuración fiscal por país, provisioning cross-product y auditoría unificada.

Servicios externos

Supabase emawell (yvfxhrnghajwblyqtoel · sa-east-1)

Supabase emahub (bzikofdvfvchntvfzyni)

Cloudflare Worker (Hono)

Cliente (browser, staff EMA)

fetch /api/*

fetch /api/*

service role

server-side proxy

/api/emi/*

provisioning

cron + webhook

invitaciones

React 19 SPA

Vite + Router 7 + Tailwind 4

Layout · sidebar 7 grupos

+ EmiDrawer + ThemeToggle

Rutas públicas

/api/health · /api/config

/auth/* · /api/github/webhook

/api/internal/tenants

authMiddleware

JWT + vault_users.is_active

security-headers

HSTS · CSP · COOP/CORP

44 módulos API

Cron horario

GitHub issues sync

emavault

vault_users · vault_permissions

finance · pipeline · product · etc.

emashared

tenants · tenant_aidbox_credentials

emi_invocations · emi_apps · audit_log

public

habits · streaks · metrics

Read-only · Ley 21.719

Anthropic API

Sonnet · Sonnet 4.6

emi.emahealth.io

EMI Worker

Aidbox

provisioning Org + OAuth

GitHub API

issues + webhook

Loops.so

emails operativos

src/client/
├── App.tsx # Router + layout + clean-print routes
├── pages/ # 23 pages
│ ├── Login.tsx # Google OAuth
│ ├── Dashboard.tsx # KPIs ejecutivos
│ ├── Pipeline.tsx # Kanban leads
│ ├── Tenants.tsx # Lista clientes con MRR + alertas
│ ├── TenantDetail.tsx # Tabs: General, Fiscal, Contratos, Finanzas, Alertas
│ ├── Organizations.tsx, OrganizationDetail.tsx
│ ├── People.tsx, Directory.tsx
│ ├── Contracts.tsx, Finance.tsx, Invoicing.tsx
│ ├── FiscalConfig.tsx
│ ├── PricingTool.tsx, ProductsCatalog.tsx
│ ├── Services.tsx # Catálogo
│ ├── product/ # Sub-pages producto: dashboard, issues, backlog,
│ │ # journeys, modulos, tech, releases, roadmap
│ ├── editorial/ # Posts + métricas
│ ├── Checklist.tsx, Obligations.tsx
│ ├── TimeTracking.tsx, VaultTeam.tsx
│ ├── Wiki.tsx, Documents.tsx, Brand.tsx, BrandPrint.tsx
│ ├── Infrastructure.tsx, Settings.tsx
│ ├── Analysis.tsx, AuditLog.tsx
│ ├── EmiAccessAdmin.tsx, EmiAnalytics.tsx
│ ├── Assessments.tsx
│ └── WellMetrics.tsx
├── components/
│ ├── Layout.tsx # Sidebar 7 grupos + topbar + collapsible
│ ├── ds/ # IconBox · StatusDot · ThemeToggle
│ ├── emi/ # EmiDrawer
│ └── product/ # ProductSidebar · ReleaseCard · GanttChart · etc.
├── lib/
│ ├── auth.ts # Supabase auth wrapper
│ ├── supabase.ts # Client pubic
│ ├── api.ts # apiFetch helper (returns parsed JSON)
│ ├── currency.ts # Format CLP/USD/UF
│ └── markdown.ts # simpleMarkdown shared
└── styles.css # DS 3 capas (primitives @theme + semánticos + dark)
src/
├── index.ts # Hono app entry · Env type · mount order
├── env.d.ts # Vite client env types
├── api/ # 44 módulos
│ ├── auth.ts # /auth/me · /auth/callback
│ ├── tenants.ts # /api/tenants/* (provisioning + list)
│ ├── internal-tenants.ts# /api/internal/tenants (X-Platform-Key)
│ ├── clients.ts # /api/clients/* (DEPRECATED — usar tenants)
│ ├── pipeline + leads + assessments + lead-emi-conversations
│ ├── organizations + directory + team + vault-team + vault-permissions
│ ├── contracts + finance + invoices + fiscal + mrr + exchange + obligations
│ ├── catalog + products-catalog + pricing-tool
│ ├── product.ts # 27 endpoints — products, layers, modules, features,
│ │ # journeys, releases, backlog, gaps, GitHub sync
│ ├── github-webhook.ts # webhook + cron horario
│ ├── editorial + checklist + time + wiki + documents + infra + brand
│ ├── audit + changelog + notifications + dashboard + kpis + analysis
│ ├── ai.ts # Anthropic proxy — proposal · contract · summary
│ ├── emi.ts # /api/emi/{chat,access} — proxy server-side
│ ├── emi-access.ts # admin matrix tenant × app
│ ├── emi-analytics.ts # KPIs + time series sobre emi_invocations
│ ├── well-metrics.ts # 5 endpoints read-only sobre proyecto emawell
│ └── quality.ts # Quality dashboard (3 tablas + 10 endpoints)
├── middleware/
│ ├── auth.ts # JWT + vault_users.is_active check
│ └── security-headers.ts# HSTS · CSP · COOP/CORP · X-Frame-Options
└── lib/
├── supabase.ts # client emahub
└── supabase-well.ts # client emawell separado (service role)
Supabase emahubVault WorkerSupabase AuthReact SPAStaff EMASupabase emahubVault WorkerSupabase AuthReact SPAStaff EMASidebar filtra navItemsvia canRead(item.module)click "Continuar con Google"1signInWithOAuth · provider=google2redirect → Google OAuth3select cuenta @emahealth.io4callback con session JWT5GET /auth/meAuthorization: Bearer6SELECT vault_usersWHERE auth_id = JWT.subAND is_active = true7user row + role8UPDATE last_login = now()9{ user, permissions }10 Aidboxemaclinic Workeremalab WorkerVault WorkerStaff EMA(comercial / admin)Aidboxemaclinic Workeremalab WorkerVault WorkerStaff EMA(comercial / admin)POST /api/tenants/:id/provision{products: ['lab', 'clinic']}1SELECT organization + fiscal_config2POST /Organization (root tenant)3Crear OAuth client per-tenant4AES-GCM encrypt client_secret5INSERT tenant_aidbox_credentials6POST /api/platform/tenantsX-Platform-Key7INSERT emashared.tenants + tenant_products8ok9POST /api/platform/tenantsX-Platform-Key10INSERT emashared.tenants + tenant_products11ok12INSERT audit_log13{ tenant_id, slug, products }14POST /api/tenants/:id/admin-invite15POST /api/platform/tenants/:id/admins{email, name}16INSERT user + user_roles + Loops invite17

ok

active

EmiDrawer

browser

POST /api/emi/chat

authMiddleware

JWT + vault_users

Check emi_apps.emavault

='ga'

Check tenant_emi_access

defense-in-depth

fetch emi.emahealth.io/

api/agents/emavault

INSERT emi_invocations

tenant + user + lead + tokens

Stream response

al SPA

src/api/ai.ts expone 3 endpoints server-side:

EndpointModeloUso
POST /api/ai/proposalSonnet 4.6Genera propuesta comercial desde lead/cliente
POST /api/ai/contractSonnet 4.6Resumen de contrato (lectura assistida)
POST /api/ai/summarySonnet 4.6Resumen general (notas, descripciones)

ANTHROPIC_API_KEY queda como secret CF Worker. Nunca expuesto al SPA.

webhook push

paginated fetch

GitHub

emahealth org

POST /api/github/webhook

HMAC sig verified

INSERT/UPDATE

github_issues_cache

Enrich from labels

module: · layer: · priority:

Auto-close features

cuando issue closes

Cron horario

GitHub REST API

src/middleware/security-headers.ts aplica:

Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline' fonts.googleapis.com;
font-src 'self' fonts.gstatic.com;
img-src 'self' data: https://avatars.githubusercontent.com;
connect-src 'self' https://*.supabase.co
https://api.anthropic.com
https://emi.emahealth.io
https://api.github.com;
frame-ancestors 'none';

Plus: HSTS, X-Frame-Options DENY, X-Content-Type-Options nosniff, COOP same-origin, CORP cross-origin.

Migration 008 instala emavault.audit_trigger() SECURITY DEFINER:

  • Aplica a 12 tablas críticas (users, vault_users, organizations, contracts, finance, fiscal_config, invoices, products, etc.).
  • Loggea cada INSERT/UPDATE/DELETE en emashared.audit_log.
  • Sanitiza secrets: cualquier campo con nombres password/token/secret/api_key/private_key/aidbox_client_secret_encrypted se reemplaza por [REDACTED].
  • auth.uid() = null cuando se llama con service-role (esperado).
  • Sub-routes Hono — un archivo por módulo, registrado vía app.route("/api/{resource}", routes) en index.ts.
  • Public routes ANTES del authMiddleware/api/health, /api/config, /auth/*, /api/github/webhook, /api/internal/tenants.
  • apiFetch<T>(...) helper retorna JSON parseado directamente (no Response).
  • Imports relativos (./, ../) — no usa alias @/ (diferente a Clinic/Lab).
  • type sobre interface — convención del repo.
  • Sub-pages lazy-loaded dentro del producto (/producto/:slug/*).