Metadata-Version: 2.5
Name: datagrowth-common
Version: 0.15.1
Summary: Datagrowth common: auth Supabase o Clerk (cookie JWT + Admin/Backend API), API helpers, structured logging, result pattern, Supabase client, shared models, Jinja2 + Tailwind UI subpackage, prod-readiness QA auditor, observability (Sentry + Prometheus), security middlewares (CSRF, headers, rate-limit) and GDPR self-service router.
Author-email: Datagrowth <pablo.ramos@datagrowth.es>
License: MIT
Keywords: auth,clerk,datagrowth,fastapi,jinja2,structlog,supabase,tailwind
Classifier: Framework :: FastAPI
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.12
Requires-Dist: fastapi>=0.115
Requires-Dist: httpx>=0.28
Requires-Dist: jinja2>=3.1
Requires-Dist: pydantic[email]>=2.0
Requires-Dist: python-multipart>=0.0.9
Requires-Dist: sqlalchemy>=2.0
Requires-Dist: structlog>=24.4
Provides-Extra: clerk
Requires-Dist: cryptography>=42; extra == 'clerk'
Requires-Dist: pyjwt>=2.9; extra == 'clerk'
Provides-Extra: dev
Requires-Dist: cryptography>=42; extra == 'dev'
Requires-Dist: httpx>=0.28; extra == 'dev'
Requires-Dist: prometheus-fastapi-instrumentator>=7.0; extra == 'dev'
Requires-Dist: psycopg[binary]>=3.2; extra == 'dev'
Requires-Dist: pyjwt>=2.9; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: sentry-sdk[fastapi]>=2.17; extra == 'dev'
Requires-Dist: slowapi>=0.1.9; extra == 'dev'
Requires-Dist: uvicorn>=0.30; extra == 'dev'
Provides-Extra: middleware
Requires-Dist: slowapi>=0.1.9; extra == 'middleware'
Provides-Extra: observability
Requires-Dist: prometheus-fastapi-instrumentator>=7.0; extra == 'observability'
Requires-Dist: sentry-sdk[fastapi]>=2.17; extra == 'observability'
Provides-Extra: postgres
Requires-Dist: psycopg[binary]>=3.2; extra == 'postgres'
Provides-Extra: supabase
Requires-Dist: pyjwt>=2.9; extra == 'supabase'
Requires-Dist: supabase>=2.0; extra == 'supabase'
Description-Content-Type: text/markdown

# datagrowth-common

Paquete Python (`src/datagrowth_common/`): auth (Supabase + cookie HMAC),
UI (Jinja2 + CSS + JS), logging, helpers de API y production-readiness.
Se instala vía pip.

Utilidades compartidas entre las apps del ecosistema Datagrowth
(app-backend, ai-automation, fable, chatbot y las apps de cliente
generadas por el pipeline multi-agente).

> El scaffold FastAPI para apps nuevas vive en el repo
> [`datagrowth/app-template`](https://github.com/datagrowth/app-template)
> (GitHub template con el skeleton en la raíz):
> `gh repo create my-new-app --template datagrowth/app-template --private`.
> Su `pyproject.toml` pinnea la versión de este paquete.

Tras esto, abre Cursor / Claude Code en el repo y pega el prompt de onboarding del `README.md` del propio repo recién creado.

## Quickstart como dependencia pip

```bash
pip install "datagrowth-common[supabase,postgres,observability,middleware]>=0.10,<1.0"
```

Las extras `[observability]` (sentry-sdk + prometheus-fastapi-instrumentator) y `[middleware]` (slowapi) son opcionales — la app puede importar los helpers sin instalarlas y los inits devuelven `False` / `None` (no-op). Útil en dev local cuando no quieres pagar el peso de las deps.

Desde `v0.8.1`/`v0.8.2`, el wiring CSRF se entrega como bundle JS del paquete (`/static/dg-common/js/csrf.js`). En v0.8.1 se cableó en `sidebar_shell.html` (cubre logout del topbar y forms de cualquier página autenticada); en v0.8.2 se añadió también a `empty.html` (cubre login/signup/reset). Desde `v0.8.8`, el mismo `csrf.js` monkey-patcha `window.fetch` para inyectar el header `X-CSRF-Token` en cualquier llamada same-origin con método no-safe (POST/PUT/PATCH/DELETE); ambos shells lo cargan **sin `defer`** para que el patch esté activo antes que cualquier `<script>` inline posterior pueda lanzar un request. Sin esto, todo `fetch('/foo', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: ...})` muere con 403 `CSRF_TOKEN_MISMATCH`. Antes cada host app tenía que copiar el script en su `extra_head`, y si lo hacía mal (p.ej. `document.body.addEventListener` en `<head>` con el body aún no parseado) el form enviaba POST sin token y el middleware respondía 403. Ver `CHANGELOG.md [0.8.1]`/`[0.8.2]`/`[0.8.8]` para detalles.

Desde `v0.8.0`, el paquete propaga al template las piezas de production-readiness que hasta ahora vivían solo en `app-backend`: `observability.init_sentry` (init Sentry con scrubbing de PII), `observability.default_instrumentator` (Prometheus FastAPI), middlewares `RequestIdMiddleware`/`SecurityHeadersMiddleware`/`CSRFMiddleware` y helper `build_limiter` (slowapi + Redis), router GDPR self-service `make_me_router` (export ZIP + delete diferido + cancel) y helper `audit.emit` para `user_audit_log` con ip/user_agent. El template trae además `health.py` (`/live`, `/ready`, `/healthz`), workflow `ci.yml` con `prod-ready-audit`, `backup.sh` placeholder, migración `v3_access_event.sql` y plantillas `docs/PRIVACY.md` + `docs/RUNBOOK.md`. Cada pieza es opt-in: las apps que no las necesiten pueden no instalar las extras `[observability]`/`[middleware]` y los inits responden no-op sin romper imports.

A partir de `v0.3.0`, el **único modelo de autenticación** es **Supabase Auth** (cookie httpOnly con JWT). Los helpers contra Authentik (forward-auth y OIDC propio) se eliminaron — ver `CHANGELOG.md` para la guía de migración. La cookie HMAC propia se conserva en `auth.session_hmac` como fallback para apps internas que no usan Supabase Auth.

Desde `v0.7.15`, el item activo del sidebar (`.sidebar-link-active`) es lima sólido + texto navy en lugar del translúcido al 22% que tenía hasta 0.7.14, alineado con el look del backend orquestador. Resuelve via `var(--color-brand-primary-rgb)`/`var(--color-brand-secondary-rgb)`, así cada cliente lo personaliza vía sus triplets del `tokens-override.css`. Desde `v0.7.14`, las apps generadas sirven correctamente sus assets locales (`src/static/brand/tokens-override.css`, `src/static/css/app.css`): el `main.py` del template ahora monta `/static/` apuntando al directorio local del repo además del `/static/dg-common/` del paquete que ya monta `register_ui`. Hasta este fix las apps respondían 404 a `tokens-override.css` aunque el deployer Datagrowth lo hubiera escrito en su filesystem — el bug fue invisible mientras las utility classes Tailwind bakeaban hex literales (pre-0.7.13), pero se hizo crítico con el refactor `var(--color-*-rgb)` de 0.7.13. Apps existentes heredan el mount via patcher `_patch_main_for_local_static_mount` del backend Datagrowth al pulsar "Actualizar common". Desde `v0.7.13`, el paquete deja de imponer la identidad Datagrowth a sus consumidores: `tokens.css` declara los brand colors en **dos formas paralelas** — triplet RGB sin envoltura (`--color-brand-primary-rgb: 71 85 105`) consumido por las utility classes Tailwind con `<alpha-value>`, y forma funcional (`--color-brand-primary`) para CSS directo — con defaults gray-based neutrales. El `tailwind-preset.cjs` pasa de hex literales (`brand.lime: '#b5ff82'`) a `rgb(var(--color-brand-primary-rgb) / <alpha-value>)` para que `bg-brand-lime/15` y similares respeten el `tokens-override.css` del cliente. `sidebar_shell.html` añade dos fallbacks `request.state.*` (`dg_user` para el topbar email/admin/salir, `dg_tokens_override_href` para el `<link>` al CSS por cliente) que permiten a la host app inyectarlos via dep global. Nuevo manifest `setup.json` + `SETUP.md` en el skeleton — el dev del paquete declara campos extra a rellenar al instalar la app (prompts runtime, tokens externos, flags) que el orquestador pinta y persiste. Desde `v0.7.12`, las apps generadas pueden abrirse a desarrolladores externos sin que rompan el rebranding ni el flow de "Actualizar common": nueva skill `dg-customize` + regla `.claude/rules/managed-files.md` que codifican el contrato customizable vs gestionado, `setup.sh` de bootstrap (symlinks `.claude/skills` + `.cursor/skills` → `.agents/skills`) y README rewrite con sección de dev-onboarding. La clase `.sidebar-section-label` aterriza por fin en el compiled CSS (fix: `tailwind.config.js` ahora escanea también `template/src/templates/**/*.html`). Desde `v0.7.11`, el sidebar admin es consistente en todas las pantallas: la dep `populate_template_state` del template inyecta `request.state.dg_user` antes de cada handler HTML, y el partial `sidebar_nav.html` la lee como fallback cuando el handler concreto no pasa `user` al context Jinja (caso típico: `users_router` del paquete). Desde `v0.7`, las apps generadas aplican sus `migrations/*.sql` automáticamente en el lifespan via `apply_migrations(...)`. Desde `v0.5.0`, la gestión de usuarios + RBAC + audit log se monta con `make_users_router(catalog, deps, ...)` (módulo `datagrowth_common.users`). Desde `v0.3.1`, la UI compartida (Jinja2 + Tailwind + JS) se inyecta con `register_ui(app, jinja_env)` (subpaquete `datagrowth_common.ui`).

## Qué incluye

### Supabase Auth (v0.3.0+)

| Módulo | Contenido |
|---|---|
| `auth.supabase` | `verify_supabase_jwt(token)` — valida HS256 + `exp` + `aud`. `SupabaseUser` (modelo Pydantic con `id`, `email`, `role`). `require_supabase_jwt` (FastAPI dep que lee `Authorization: Bearer`). `SupabaseUserDep` (alias tipado). `require_role(role)` (factory de dep con check de `app_metadata.role`). |
| `auth.session` | Cookie httpOnly server-rendered: `set_session(response, access_token, refresh_token)`, `clear_session` (logout; añade `Clear-Site-Data: "cache"`), `get_session_tokens`, `read_session_cookies` (lectura segura frente a cookies plantadas, ver abajo), `require_session` (lee cookie + valida JWT), `SessionUserDep`, `refresh_session_tokens` (canjea refresh → nuevos tokens). Pensado para apps Jinja+HTMX donde el cliente NO maneja JWTs en JS. |
| `auth.router` | `make_auth_router(post_login_redirect, post_logout_redirect)` y `auth_router` por defecto. Endpoints reutilizables: `POST /auth/password`, `POST /auth/register`, `POST /auth/magic-link/request`, `POST /auth/password/reset`, `GET /auth/oauth/{provider}/start`, `GET /auth/oauth/{provider}/callback`, `POST /auth/refresh`, `POST /logout`, `GET /api/me`. La página `/login` HTML la sirve cada app con su template propio (branding). |
| `auth.supabase_admin` | Wrappers async sobre Supabase Admin API con patrón `Result`: `list_users`, `invite_user_by_email`, `delete_user`, `update_user_role`, `create_user_with_password`, `send_password_reset`. |
| `auth.session_hmac` | Cookie de sesión firmada con HMAC propio (`SESSION_SECRET`). Fallback para apps internas que no usan Supabase Auth. |
| `api.users` | `users_router` — APIRouter con `GET /api/users` (paginado, admin), `POST /api/users/invite` (admin), `DELETE /api/users/{id}` (admin), `GET /api/users/me`. |
| `supabase` | `get_supabase_client(schema)` (anon key) y `get_supabase_admin_client(schema)` (service_role key). |

### Clerk Auth (v0.13.0+)

Proveedor de identidad alternativo a Supabase para las apps generadas.
Requiere el extra `[clerk]` y las env vars `CLERK_SECRET_KEY` +
`CLERK_PUBLISHABLE_KEY`.

| Módulo | Contenido |
|---|---|
| `auth.clerk` | `verify_clerk_jwt(token, jwks)` (RS256 puro) y `verify_clerk_jwt_cached(token)` (JWKS cacheado 1h + refetch ante `kid` desconocido). Wrappers async de la Clerk Backend API con patrón `Result`: `list_users`, `get_user`, `get_user_by_email`, `get_user_cached` (TTL 5 min — resuelve el email cuando el session token no trae el claim), `create_user`, `delete_user`, `set_user_password`, `update_user_role` (escribe `public_metadata.role`). `clerk_frontend_api()` deriva el dominio Frontend API desde la publishable key (para el `<script>` de ClerkJS y el CSP). |
| `users.directory` | Seam del directorio de identidades que consume el panel `/admin/users`: `UserDirectory` (Protocol), `SupabaseDirectory` (default), `ClerkDirectory`, `set_user_directory` / `get_user_directory`. `capabilities` declara qué acciones soporta el proveedor y el panel oculta las no soportadas (Clerk no envía emails de reset server-side). |
| `middleware.security` | `build_default_csp()` — con `AUTH_PROVIDER=clerk` el CSP por defecto añade el dominio Frontend API a `script-src`/`connect-src` y `worker-src 'self' blob:` (ClerkJS refresca sesión con un web worker). |

El session token de Clerk no incluye `email` por defecto: o se customiza
en el dashboard (`{"email": "{{user.primary_email_address}}"}`) o la dep
de auth de la app lo resuelve con `get_user_cached`. El router JSON
`api.users` (`/api/users`) sigue siendo Supabase-only.

### UI compartida (v0.3.1+)

| Módulo | Contenido |
|---|---|
| `ui` | `register_ui(app, jinja_env, mount_path="/static/core")` añade un `PackageLoader` al `ChoiceLoader` y monta los assets (`tokens.css`, `tailwind.compiled.css`, `theme.js`, `toasts.js`, `htmx-events.js`). `UI_VERSION` alineado con `__version__`. |
| `ui/templates/core/` | 18 partials Jinja2 con prefijo obligatorio `core/...`: layouts (`sidebar_shell`, `empty`), componentes (`badge`, `button`, `card`, `data_table`, `empty_state`, `form_field`, `icon`, `page_header`, `paginated_table`, `sidebar_link`, `skeleton`, `theme_toggle`, `toast`), admin (`users_panel`, `roles_panel`, `audit_panel`). |
| `ui/tailwind-preset.cjs` | Preset Tailwind empaquetado en el wheel: paleta brand, semantic, surfaces, fonts, radii, shadows + `safelist` con responsive variants (`md:flex`, `lg:grid-cols-3`, …). Importable via `importlib.resources` para builds de frontend dockerizados. |

### Gestión de usuarios + RBAC (v0.5.0+)

| Módulo | Contenido |
|---|---|
| `users` | `make_users_router(catalog, deps, mount_prefix, schema, templates)` — `APIRouter` con 13 endpoints: HTML del panel, listado paginado, invite, create con password, set password, update roles, password reset, delete, permisos efectivos, CRUD de roles, audit log. |
| `users.permissions` | `PermissionCatalog({"runs.read": "Ver runs", ...})` declarado en código (la BD solo guarda asignaciones, no el catálogo). `make_require_permission(perm)` — FastAPI dep con cache por request, deniega por defecto. |
| `users.bootstrap` | `bootstrap_roles(db, system_roles, catalog)` idempotente: upserts roles del sistema + poda `role_permission` huérfanos. Refusa borrar si el catalog está vacío (defensa contra bug de import). |
| `users/migrations/001_users_rbac.sql` | Crea `role`, `user_role`, `role_permission`, `user_audit_log` con FKs `ON DELETE CASCADE` e índices. |
| `users/migrations/002_lock_rbac_tables.sql` | Activa RLS (sin políticas) y hace `REVOKE ALL` a `anon`/`authenticated` en esas tablas, en `user_deletion_request` y en sus secuencias serial/identity, y retira los privilegios por defecto que el rol de las migraciones les da en el schema, para que la Data API de Supabase (PostgREST con la anon key) no pueda leerlas ni escribirlas, tampoco las tablas que se creen después. Solo mira el schema actual; una tabla con otro owner deja un `WARNING` en vez de tumbar el arranque (la consulta de auditoría va en la cabecera del SQL). Idempotente; la app, que conecta como owner, no cambia. |

### Auto-migration (v0.7+)

| Módulo | Contenido |
|---|---|
| `migrations` | `apply_migrations(migrations_dir, *, db_url=None) -> tuple[list[str], Exception | None]`. Aplica `migrations/*.sql` en orden alfabético contra Postgres, idempotente con tracking en `dg_internal._dg_applied_migrations`. Llamar en el lifespan del FastAPI; si falla, `raise` para crash loop. Requiere extra `[postgres]` (psycopg). |

### Production-readiness helpers (v0.8.0+)

| Módulo | Contenido |
|---|---|
| `observability.init_sentry` | Inicializa Sentry si `SENTRY_DSN` está seteada. `component` ("api", "worker", ...) como tag. `before_send` redacta emails crudos, cabeceras sensibles, cookies y keys cuyo nombre contiene `password`/`token`/`secret`. Acepta `extra_before_send` para scrubbing custom. No-op si `sentry-sdk` no está instalado. |
| `observability.default_instrumentator` | Registra `prometheus_fastapi_instrumentator.Instrumentator` y expone `/metrics`. Excluye `/live`, `/ready`, `/health*`, `/metrics`, `/static.*` por defecto. No-op si la lib no está instalada. |
| `middleware.RequestIdMiddleware` | Lee/genera `X-Request-Id`, lo bindea a structlog + Sentry. Devuelve el id en la response. |
| `middleware.SecurityHeadersMiddleware` | HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP configurable. `Cache-Control: private, no-store` (si el handler no puso el suyo) en las respuestas a peticiones con cookie de sesión o `Authorization`, salvo `no_store_exempt_prefixes` (por defecto `/static/`). Deja sus cabeceras en `request.state` para que `register_error_handlers` las ponga también en los 500. Flag `SECURITY_HEADERS_ENABLED`. |
| `middleware.CSRFMiddleware` | Double-submit cookie. Valida `extra_exempt_prefixes` (rechaza `""`/`/`/no-absolutos). `/auth/password` NO exento — adjunta `X-CSRF-Token` (HTMX) o `csrf_token` (form) en el POST. Desde v0.8.6 expone el token como `request.state.dg_csrf_token` para que los templates Jinja lo rendericen server-side via macro `dg/components/csrf.html::csrf_input(request)` — sin dependencia de JS. Desde v0.15.1, antes de mirar el token rechaza las peticiones de otro origen (`Sec-Fetch-Site: same-site|cross-site`, u `Origin`/`Referer` distinto de `Host`/`X-Forwarded-Host`/`trusted_origins`), y sin cookie válida rechaza sin leer el cuerpo. El token va en `X-CSRF-Token` o, solo en forms urlencoded de hasta 64 KB, en `csrf_token`; `multipart/form-data` exige la cabecera. Con `Secure` la cookie es también `__Host-dg_csrf` y un nombre repetido se descarta. Flag `CSRF_ENABLED`; orígenes extra con `trusted_origins=` o `CSRF_TRUSTED_ORIGINS`. |
| `middleware.MetricsAuthMiddleware` | Basic-auth para `/metrics` con `secrets.compare_digest` (sin timing attacks). Default OFF: si `METRICS_USER`/`METRICS_PASS` vacíos, responde 404 (no enumera). |
| `middleware.build_limiter` | Crea limiter de slowapi + handler 429 JSON. Storage Redis si `REDIS_URL` set, fallback memory. Flag `RATE_LIMIT_ENABLED`. Clave por IP con `rate_limit_key` (desde v0.15.1; antes `get_remote_address`, que con `FORWARDED_ALLOW_IPS=*` era el primer salto de `X-Forwarded-For`). `trust_proxy=` como en `client_ip`: `True` si uvicorn confía en `*` por línea de comandos en vez de por la variable. |
| `middleware.client_ip` | IP del cliente detrás de proxy. `trust_proxy=None` (default): salto más a la derecha de `X-Forwarded-For` si `FORWARDED_ALLOW_IPS` es exactamente `*`, si no `request.client` ya resuelto por uvicorn. `True` fuerza el salto más a la derecha; `False` usa `request.client` tal cual. Quita `:puerto`/corchetes, valida y canoniza; `None` si el salto no es una IP. |
| `me.make_me_router` | Router GDPR self-service: `GET /me/data/export` (ZIP), `DELETE /me/data` (delete diferido), `POST /me/data/delete/cancel`. Acepta `extra_exporters` para inyectar blobs propios. La IP del audit log sale de `middleware.client_ip(request, trust_proxy)`: `trust_proxy=None` por default (modo automático, lo recomendable detrás de Traefik), `True` fuerza el salto más a la derecha de `X-Forwarded-For` y `False` usa `request.client` tal cual, fiable solo si uvicorn no confía en `*` (desde v0.15.1; antes el default era `False` y `True` tomaba el primer salto, falseable). |
| `legal.make_legal_router` | Endpoints públicos `/privacy`, `/terms`, `/security` desde markdown. Parser mínimo (h1-h6, listas, tablas GFM, párrafos, `**bold**`, `` `code` ``, `[link](url)`) con `html.escape(quote=True)` antes del inline + whitelist de esquemas (`https`/`http`/`mailto`/`tel`/`/`/`#`) + `rel="noopener noreferrer"` en links — inmune a XSS via attribute injection o `javascript:`. Acepta `md_to_html_fn=` propio para parser completo. |
| `retention.make_retention_purger` | Factory que devuelve `iteration() -> tuple[stats, Exception\|None]` para schedulear con APScheduler/arq/cron. Purga `user_deletion_request` vencidas + emite audit `data-purged`, retención `user_audit_log` (365d default), retención opcional de tabla de logs operativos. Valida identificadores SQL con whitelist regex en construct-time (fail-fast). |
| `audit.emit` | Inserta fila en `user_audit_log` con `ip` + `user_agent`. Patrón de error como valor — un fallo de logging nunca rompe el handler. Scrubea emails del `user_agent` antes de persistir; valida `schema` contra whitelist regex. |
| `env.dg_env` / `is_dev_env` / `is_hardened_env` / `is_prod_env` | Helper canónico para `ENV`. Vocabulario cerrado `dev` \| `test` \| `stg` \| `prod` (default `prod`; alias legacy `development`/`testing`/`staging`/`production` normalizados; valores desconocidos caen a `prod` por fail-safe). **Los gates de seguridad usan `is_hardened_env()`** (todo lo que no es `dev`); `is_prod_env()` responde solo "esto es producción de verdad" y es lo que quiere Sentry o un feature flag que deba quedarse fuera de prod. |
| `errors.register_error_handlers` | Registra handlers para `HTTPException`, `RequestValidationError` y `Exception`. Para navegaciones HTML renderiza `dg/pages/error.html` con el shell completo (autenticado) o `empty.html` (anónimo) + banner inferior-derecho con el mensaje de error. Para HTMX devuelve un fragmento OOB que inyecta el toast en `#dg-toasts` (el wiring `htmx:beforeSwap` en `htmx-events.js` fuerza el swap aunque el status sea 4xx/5xx). Para `Accept: application/json` o paths `/api/*`/`/auth/*` mantiene el JSON `{"detail": ...}`. Mensajes por código (401/403/404/422/429/5xx); >=500 oculta detalles internos al usuario. Llamar después de `register_ui(...)` porque depende de los templates `dg/...`. Desde v0.8.6. |

Uso típico (en `src/main.py` de la app):

```python
from datagrowth_common.legal import make_legal_router
from datagrowth_common.middleware import (
    CSRFMiddleware, MetricsAuthMiddleware, RequestIdMiddleware,
    SecurityHeadersMiddleware, build_limiter,
    is_csrf_enabled, is_metrics_enabled,
    is_rate_limit_enabled, is_security_headers_enabled,
)
from datagrowth_common.observability import default_instrumentator, init_sentry

init_sentry(component="api")

app = FastAPI(...)
limiter, rl_handler = build_limiter()
if limiter is not None and is_rate_limit_enabled():
    app.state.limiter = limiter
    app.add_exception_handler(RateLimitExceeded, rl_handler)
    app.add_middleware(SlowAPIMiddleware)
if is_csrf_enabled():
    app.add_middleware(CSRFMiddleware)
if is_security_headers_enabled():
    app.add_middleware(SecurityHeadersMiddleware)
if is_metrics_enabled():
    app.add_middleware(MetricsAuthMiddleware)
app.add_middleware(RequestIdMiddleware)
default_instrumentator(app)

# Páginas legales públicas (GET /privacy, /terms, /security).
app.include_router(make_legal_router(
    docs_dir=Path("docs/legal"), templates=templates,
))
```

Y para schedular el cron de retención GDPR + audit log:

```python
from datagrowth_common.retention import make_retention_purger

purger = make_retention_purger(
    session_factory=lambda: Session(engine),
    extra_grant_tables=(("user_role", "user_id"),),
)
# Schedular `purger()` cada RETENTION_PURGE_INTERVAL_S segundos
# (APScheduler, arq, cron del SO).
```

### Cuenta self-service + email transaccional (v0.11.0+)

| Módulo | Contenido |
|---|---|
| `account.make_account_router` | Página `/account`: perfil (display name), cambio de contraseña re-verificando la actual contra Supabase Auth (`sign_in_with_password` antes de `set_user_password`), y acciones GDPR pintadas sobre los endpoints de `me`. El host inyecta `auth_dep` + `templates` + un wrapper `account.html` que incluye el fragmento `core/account/account_panel.html`. |
| `email.send_email` | Email transaccional propio de la app vía SMTP (`SMTP_HOST/PORT/USER/PASSWORD/FROM`), STARTTLS o SSL implícito (puerto 465), error-como-valor y logging sin PII (solo dominio del destinatario). `is_smtp_configured()` para degradar (p. ej. /support → mailto). Los emails de AUTH (reset, magic link, invite) NO pasan por aquí: los envía Supabase. |

UX del shell (v0.11.0+): command palette Ctrl+K (`core/components/command_palette.html`, indexa el sidebar sin endpoints), `dgConfirm()` + interceptor de `hx-confirm` (diálogo de confirmación del design system en vez del `confirm()` nativo), macro `modal()` sobre `<dialog>`, validación progresiva de forms (`form-validate.js`, Constraint Validation API, opt-out `data-dg-novalidate`), preload de navegación en hover (extensión `preload` de htmx), View Transitions en swaps, focus ring `focus-visible` en componentes interactivos y soporte `prefers-reduced-motion`.

### Production-readiness auditor (v0.8.0+)

| Módulo | Contenido |
|---|---|
| `qa.run_audit(repo_root)` | Auditor determinista. Ejecuta 26 checks sobre el repo (no-print, structlog, .env.example, migrations, /live, /ready, security headers, CSRF, rate-limit, GDPR endpoints, flujo de reset completable, página de cuenta, /support, SMTP/SUPPORT_EMAIL declaradas, plantillas de email Supabase, CI workflow, secrets hardcoded, colores hardcodeados, etc.) y devuelve `(report, exc)` con blockers + warnings. Cero deps (solo stdlib). |
| `qa.CheckResult` | Dataclass inmutable con `name`, `passed`, `details`, `severity` (`"blocker" \| "warning"`). |

Aplicable al backend interno y a cada app generada por el pipeline. Skill heredable `prod-ready-checklist` en el skeleton (`app-template`) invoca este auditor desde Claude Code.

```python
from pathlib import Path
from datagrowth_common.qa import run_audit

report, exc = run_audit(Path("."))
if exc is None and report["blockers"] == 0:
    print("LISTO PARA RELEASE")
```

### Genérico

| Módulo | Contenido |
|---|---|
| `result` | `Result[T]`, `ok()`, `err()` — patrón de error como valor |
| `logger` | `setup_logging()`, `get_logger()` — structlog preconfigurado (JSON en prod, consola en dev) |
| `api.errors` | `ApiResponse`, `ErrorBody`, `ErrorCode`, `ok_response`, `err_response`, `HTTP_STATUS_FOR_CODE` |
| `api.pagination` | `Paginated[T]`, `PageMeta`, `paginate_params`, `build_meta` |
| `shared_models` | `ClientRead`, `ProjectRead`, `ContactRead` — modelos Pydantic de lectura |
| `updater.checker` | `check_for_updates()`, `run_periodic_check()` — verificador de versiones async |
| `testing.fixtures` | `signed_session_cookie`, `mock_supabase_client` y otros fixtures pytest |

### Eliminado en v0.3.0 (breaking)

| Módulo | Reemplazo |
|---|---|
| `authentik` | `auth.supabase` (validación JWT) |
| `auth_fastapi` | `auth.session.require_session` (cookie JWT) o `auth.supabase.require_supabase_jwt` (Bearer) |
| `auth.local` | `auth.session_hmac` (rename). Las funciones OIDC se eliminan; usa `auth.router.make_auth_router()` para login + OAuth + magic link + reset via Supabase. El alias `auth.local` se eliminó en 0.8.0 — importar `from datagrowth_common.auth import local` lanza `ImportError`. |

## Instalación

Publicado en PyPI: <https://pypi.org/project/datagrowth-common/>.

```bash
pip install "datagrowth-common[supabase,postgres]>=0.10,<1.0"
```

Extras disponibles:

- `[supabase]` — `supabase>=2.0` + `pyjwt>=2.9` (necesario para `verify_supabase_jwt`, `users_router` y el módulo `users`).
- `[clerk]` — `pyjwt>=2.9` + `cryptography>=42` (necesario para `auth.clerk`: el session token de Clerk es RS256).
- `[postgres]` — `psycopg[binary]>=3.2` (necesario para `apply_migrations` y para el módulo `users`, que habla Postgres directo via SQLAlchemy).
- `[dev]` — toolchain de tests (`pytest`, `pytest-asyncio`, `pytest-cov`, `httpx`, `pyjwt`, `cryptography`, `psycopg`).

### En `pyproject.toml` de otra app

```toml
[project]
dependencies = [
    "datagrowth-common[supabase,postgres]>=0.10,<1.0",
]
```

### Editable (desarrollo local del propio paquete)

```bash
pip install -e /ruta/a/datagrowth-common
```

## Uso rápido — Supabase Auth en una app FastAPI

### Modo API (cliente JS o app móvil envía `Authorization: Bearer`)

```python
from fastapi import FastAPI

from datagrowth_common import (
    setup_logging, get_logger,
    SupabaseUserDep, require_role,
)
from datagrowth_common.api.users import users_router

setup_logging()
log = get_logger(__name__)

app = FastAPI()

# Endpoints CRUD de usuarios sobre Supabase Admin API:
#   GET    /api/users         (admin, paginado)
#   POST   /api/users/invite  (admin)
#   DELETE /api/users/{id}    (admin)
#   GET    /api/users/me      (cualquier user autenticado)
app.include_router(users_router)


@app.get("/leads")
async def list_leads(user: SupabaseUserDep) -> dict:
    log.info("leads", user=user.id, role=user.role)
    return {"data": [...]}


@app.delete("/leads/{lead_id}")
async def delete_lead(lead_id: str, _: object = require_role("admin")):
    return {"deleted": lead_id}
```

El cliente envía `Authorization: Bearer <jwt>` donde el JWT lo emitió la propia Supabase de la app (login local, magic link u OIDC). `verify_supabase_jwt` valida firma HS256 contra `SUPABASE_JWT_SECRET`, comprueba `exp` y `aud`, y extrae `sub`, `email` y `app_metadata.role`.

### Modo server-rendered (Jinja + HTMX + cookie httpOnly)

Para apps donde el navegador navega entre páginas HTML (no SPA):

```python
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse

from datagrowth_common import (
    SessionUserDep, make_auth_router, setup_logging, get_logger,
)

setup_logging()
log = get_logger(__name__)
app = FastAPI()

# Router con login propio + OAuth + refresh + logout. La página HTML de
# /login la sirves tú con tu propio branding; el router solo expone los
# endpoints API.
app.include_router(make_auth_router(
    post_login_redirect="/",
    post_logout_redirect="/login",
))


@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, error: str | None = None):
    return templates.TemplateResponse(request, "login.html", {"error": error})


@app.get("/")
async def home(user: SessionUserDep) -> dict:
    return {"email": user.email}
```

`set_session` guarda dos cookies (`dg_session` con el access_token JWT, `dg_session_refresh` con el refresh_token) httpOnly+Secure+SameSite=Lax. `require_session` valida la cookie en cada request. Si caduca, llamas a `POST /auth/refresh` para renovarla.

Con `Secure` (todo entorno salvo `ENV=dev`) cada cookie se escribe también como `__Host-dg_session` / `__Host-dg_session_refresh`, que otro subdominio no puede plantar. Lee siempre con `read_session_cookies(request)` (devuelve `(access, refresh)`) o `get_session_tokens(request)`, nunca con `request.cookies.get(ACCESS_COOKIE)`: prefieren el nombre `__Host-` y descartan un nombre repetido en la cabecera `Cookie` (la huella de una cookie plantada con `Domain` del dominio padre, que `request.cookies` resolvería quedándose con la última). Durante la transición siguen leyendo y escribiendo el nombre antiguo.

## Uso rápido — UI compartida + RBAC + auto-migration

Una app generada por el template monta los tres módulos en `src/main.py`:

```python
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI
from fastapi.templating import Jinja2Templates

from datagrowth_common import (
    apply_migrations, get_logger, make_auth_router, register_ui, setup_logging,
)
from datagrowth_common.users import (
    PermissionCatalog, make_users_router, bootstrap_roles,
)

from src.db import get_session

setup_logging()
log = get_logger(__name__)

_MIGRATIONS_DIR = Path(__file__).resolve().parent.parent / "migrations"

CATALOG = PermissionCatalog({
    "users.manage": "Gestionar usuarios",
    "runs.read": "Ver runs",
})
SYSTEM_ROLES = {
    "admin": list(CATALOG.keys()),  # todo
    "viewer": [],
}


@asynccontextmanager
async def lifespan(app: FastAPI):
    applied, exc = await apply_migrations(_MIGRATIONS_DIR)
    if exc is not None:
        log.error("migrations_failed", error=str(exc)[:500])
        raise exc
    if applied:
        log.info("migrations_applied", count=len(applied), files=applied)

    with next(get_session()) as db:
        bootstrap_roles(db, SYSTEM_ROLES, CATALOG)
        db.commit()
    yield


app = FastAPI(lifespan=lifespan)
templates = Jinja2Templates(directory="src/templates")
register_ui(app, jinja_env=templates.env)  # monta /static/core/* y core/... templates

app.include_router(make_auth_router(post_login_redirect="/", post_logout_redirect="/login"))
app.include_router(make_users_router(
    catalog=CATALOG,
    deps={"db": get_session},
    mount_prefix="/admin/users",
    schema="public",
    templates=templates,
))
```

El skeleton oficial ([`datagrowth/app-template`](https://github.com/datagrowth/app-template)) ya viene con esto cableado — incluido el bootstrap de admin desde `APP_ADMIN_EMAIL`. Ver `src/main.py` y `src/api/admin_users.py` de ese repo como referencia.

## Variables de entorno

### Supabase Auth (v0.2.1+)

| Variable | Descripción | Por defecto |
|---|---|---|
| `SUPABASE_URL` | URL del proyecto Supabase (cloud o self-hosted) | — |
| `SUPABASE_KEY` | Anon/publishable key (lectura+RLS) | — |
| `SUPABASE_SERVICE_ROLE_KEY` | Service role key (Admin API). **NO exponer al frontend** | — |
| `SUPABASE_JWT_SECRET` | Secreto HS256 (Project Settings → API). Usado por `verify_supabase_jwt` | — |
| `SUPABASE_JWT_AUDIENCE` | Audience esperada en el JWT | `authenticated` |
| `SUPABASE_JWT_LEEWAY_S` | Segundos de tolerancia de reloj al validar `iat`/`nbf`/`exp` del JWT (leeway de PyJWT). Útil si el reloj del host va por detrás del de Supabase. Fail-closed (valores inválidos → `0`). | `0` |
| `APP_URL` | URL pública de la app (sin slash final). Usada por `make_auth_router` para construir el `redirect_to` del callback OAuth | derivada de `request.base_url` si vacío |
| `AUTH_REGISTRATION` | `open` permite que cualquiera se registre via `POST /auth/register`; cualquier otro valor lo bloquea (403) | `invite_only` |
| `AUTH_MAGIC_LINK` | `true` habilita `POST /auth/magic-link/request` (OTP via email) | `false` |
| `ENV` | Entorno de la instancia: `dev`, `test`, `stg` o `prod` (default). `test` y `stg` son alternativas según la nomenclatura del cliente. Alias legacy (`development`/`testing`/`staging`/`production`) normalizados. Solo `dev` relaja las defensas (cookies sin `Secure`, logs de consola). | `prod` |

### Clerk Auth (v0.13.0+)

| Variable | Descripción | Por defecto |
|---|---|---|
| `AUTH_PROVIDER` | Proveedor de identidad de la app: `supabase` o `clerk`. Lo leen `build_default_csp()` y la app host (deps de auth, activación de `ClerkDirectory`). | `supabase` |
| `CLERK_SECRET_KEY` | Secret key de la instancia Clerk (Backend API + fetch del JWKS). **Solo server-side.** | — |
| `CLERK_PUBLISHABLE_KEY` | Publishable key (ClerkJS en el login). De ella se deriva el dominio Frontend API. | — |
| `CLERK_JWT_LEEWAY_S` | Segundos de tolerancia de reloj al validar el session token (leeway PyJWT). Fail-closed (valores inválidos → `0`). | `0` |

### Genéricas

| Variable | Descripción | Por defecto |
|---|---|---|
| `ENV` | Entorno de la instancia: `dev`, `test`, `stg` o `prod` (default). `test` y `stg` son alternativas según la nomenclatura del cliente. Alias legacy (`development`/`testing`/`staging`/`production`) normalizados. Solo `dev` relaja las defensas (cookies sin `Secure`, logs de consola). | `prod` |
| `LOG_LEVEL` | `DEBUG`, `INFO`, `WARNING`, `ERROR` | `INFO` |
| `APP_SLUG` | Slug de la app para el checker de actualizaciones | — |
| `TENANT_SLUG` | Slug del tenant para el checker de actualizaciones | — |
| `APP_VERSION` | Versión actual de la app | `0.0.0` |
| `RELEASES_URL` | URL base del backend de releases | `https://backend.dev.datagrowth.es` |

### Email transaccional (`email`, v0.11.0+)

| Variable | Descripción | Por defecto |
|---|---|---|
| `SMTP_HOST` | Servidor SMTP. Vacío = `is_smtp_configured()` False y `send_email` devuelve `smtp-not-configured`. | — |
| `SMTP_PORT` | Puerto. `465` usa SSL implícito; el resto STARTTLS. | `587` |
| `SMTP_USER` | Usuario SMTP (login solo si está seteado) | — |
| `SMTP_PASSWORD` | Contraseña SMTP | — |
| `SMTP_FROM` | Remitente. Si vacío, cae a `SMTP_USER`. | — |

### Cookie HMAC propia (`auth.session_hmac`, opcional)

| Variable | Descripción | Por defecto |
|---|---|---|
| `SESSION_SECRET` | Secreto HMAC para firmar la cookie (≥32 bytes) | — |
| `SESSION_COOKIE` | Nombre de la cookie | `dg_session` |
| `SESSION_TTL_SEC` | TTL de la sesión en segundos | `86400` |

### Postgres (`apply_migrations`, módulo `users`, v0.6.0+)

| Variable | Descripción | Por defecto |
|---|---|---|
| `DATABASE_URL` | URL completa Postgres (`postgresql+psycopg://user:pass@host:port/db`). Si está presente, las piezas individuales se ignoran. | — |
| `POSTGRES_PASSWORD` | Password del usuario Postgres (alternativa a `DATABASE_URL`) | — |
| `SUPABASE_DB_HOST` | Host de Postgres | `db` |
| `SUPABASE_DB_PORT` | Puerto de Postgres | `5432` |
| `SUPABASE_DB_USER` | Usuario Postgres | `postgres` |
| `SUPABASE_DB_NAME` | Nombre de la BD | `postgres` |
| `APP_ADMIN_EMAIL` | Email del admin inicial. El bootstrap resuelve el `user_id` en Supabase Auth y le asigna el rol `admin` (aditivo, no revoca otros). | — |

## Patrón Result

Evita excepciones flotantes retornando el error como valor:

```python
from datagrowth_common import ok, err, Result

async def fetch_data(id: str) -> Result[dict]:
    try:
        data = await some_call(id)
        return ok(data)
    except Exception as e:
        return err(e)

data, error = await fetch_data("123")
if error:
    log.error("fallo fetch", error=str(error))
else:
    print(data)
```

Todas las funciones `auth.supabase_admin.*` y `verify_supabase_jwt` siguen este patrón: nunca lanzan a través de la frontera de módulo.

## Respuesta API estándar

```python
from datagrowth_common.api.errors import ApiResponse, ErrorCode, ok_response, err_response

@app.post("/items")
async def create(body: ItemCreate) -> ApiResponse:
    item, exc = await service.create(body)
    if exc:
        return err_response(ErrorCode.INTERNAL_ERROR, "create-failed")
    return ok_response(item)
```

`ApiResponse` enforza XOR entre `data` y `error` (no se puede tener ambos). El cliente decide éxito leyendo `body.error == null`.

## Checker de actualizaciones

```python
from datagrowth_common.updater.checker import run_periodic_check

def notify(update: dict) -> None:
    print(f"Nueva version disponible: {update['latest']}")

# Llamar en el startup de la app o en un background task
await run_periodic_check(notify, app_slug="fable", tenant_slug="acme")
```

## Tests

```bash
pip install -e ".[dev,supabase]"
pytest -q
```

Cobertura actual (v0.10.x): cookie HMAC (`auth.session_hmac`), validación JWT Supabase, router CRUD de usuarios, cookie httpOnly de sesión Supabase, `auth_router` (login + OAuth con state CSRF + refresh + logout + `/api/me` whitelist), smoke tests del subpaquete `ui` (20 templates + assets), módulo `users` (catálogo, RBAC, `make_users_router`, audit log, validaciones SQL), `apply_migrations` (idempotencia + tracking en `dg_internal._dg_applied_migrations`), observability (`init_sentry` con scrubbing 6-campos PII), middleware (`CSRFMiddleware` + `MetricsAuth` + `RequestId` + `SecurityHeaders` + `build_limiter`), wiring CSRF JS (`static/js/csrf.js`, v0.8.1+, cableado desde `sidebar_shell.html`), `me.make_me_router` GDPR self-service, `legal.make_legal_router` (parser markdown XSS-safe), `retention.make_retention_purger` (whitelist regex SQL identifiers), `audit.emit` (scrub UA + schema validation), `env.dg_env`/`is_dev_env`/`is_prod_env`, helper `qa.run_audit` con 26 checks, `postgres.resolve_postgres_url`/`to_sqlalchemy_url` (unificación cross-repo).

## Versionado y releases

Sigue [SemVer](https://semver.org/). Cambios mayores que rompen API requieren bump major. La release se publica via GitHub Action al hacer `git tag vX.Y.Z && git push --tags` (ver `RELEASING.md`).

| Versión | Cambios principales |
|---|---|
| `v0.15.1` | **Security**: htmx 1.9.12 y su extensión `preload` viajan en el paquete (`ui/static/js/vendor/`, licencia 0BSD) y el shell los carga desde `/static/core/js/vendor/…?v=`; ninguna plantilla del paquete pide scripts a una CDN. `DEFAULT_CSP` (y su variante Clerk) quita `unpkg.com` y `cdn.jsdelivr.net` de `script-src`: una app que cargue algo de una CDN pasa su política con `SecurityHeadersMiddleware(csp=...)`. Nuevo `middleware.client_ip`: el audit log de `make_me_router` y la clave de `build_limiter` usan el salto más a la derecha de `X-Forwarded-For` (el que añade el proxy) cuando uvicorn confía en `*`, en vez del primero, que el cliente podía falsear si el proxy conservaba su cabecera. `make_me_router(trust_proxy=...)` pasa a `None` por defecto (automático). `CSRFMiddleware` valida el origen, rechaza sin leer el cuerpo si no hay cookie, lee `csrf_token` solo de urlencoded ≤ 64 KB (multipart exige `X-CSRF-Token`) y compara en bytes (un token no-ASCII da 403, no 500). Cookies `__Host-` de sesión y CSRF con `Secure`, sin last-wins (`read_session_cookies`). Migración `002_lock_rbac_tables.sql` (RLS + REVOKE a `anon`/`authenticated`). `Cache-Control: private, no-store` en lo autenticado y `Clear-Site-Data: "cache"` en el logout; los 500 llevan las cabeceras de seguridad. |
| `v0.15.0` | **Rediseño de la UI compartida ("Minimal SaaS")**: `sidebar_shell.html` sin barra superior en escritorio — logo, buscador "Buscar o ir a… Ctrl K", navegación, fila de modo claro/oscuro y tarjeta de cuenta viven en la lateral; en móvil queda una barra mínima (`#dg-mobile-topbar`). Menú de cuenta (`#dg-user-menu`, `user-menu.js`) con `block user_menu_items`, que por defecto incluye `partials/user_menu.html` de la app; la paleta Ctrl K también lista sus enlaces. Un solo acento vía `--color-action`/`--color-on-action` (el texto del botón primario deja de usar el secundario de la marca y se lee con cualquier marca), badges tintados en ambos temas, cards más planas, anillo de foco `--color-action-ring`. **Added**: registro de 120 iconos Lucide con alias para los nombres antiguos + `icon_tile`, `status_pill`, `stat_card`, `search_field`, macros `menu_link`/`menu_label`/`menu_separator`, parámetros nuevos en `page_header` y `empty_state`, tokens de tono. Assets del shell con `?v={{ dg_ui_version }}`; `UI_VERSION` vuelve a ir a la par con `__version__`. Sin cambios en la API Python; el CSS de app que apuntaba al antiguo `<header>` deja de aplicar (ver la guía de migración en `CHANGELOG.md`). |
| `v0.14.0` | **`ENV` pasa de dos valores a cuatro**: `dev` \| `test` \| `stg` \| `prod` (antes `test`/`stg` colapsaban a `prod` y la app no podía distinguirlos). Nuevo `is_hardened_env()` — cierto en todo lo que no sea `dev` — que es el helper que deben usar los gates de seguridad; `is_prod_env()` pasa a responder solo "esto es producción de verdad". Dentro del paquete ya está aplicado (`middleware.csrf`, `auth.session`). `init_sentry` reporta el entorno real, así que los errores de `stg` dejan de mezclarse con los de producción. Breaking semántico: revisa tus llamadas a `is_prod_env()`. |
| `v0.13.2` | **Patch sobre 0.13.1 (2 fixes, sin cambios de API)**: `register_error_handlers` propagaba `exc.headers` solo en la rama JSON; las ramas HTML y HTMX construían la respuesta sin ellas — un navegador recibía un 429 o 503 sin `Retry-After`. Ahora las tres ramas propagan las cabeceras (en HTMX las `HX-*` estructurales ganan ante colisión). Fix adicional: el 429 de slowapi salía sin `Retry-After` porque `RateLimitExceeded` no lleva cabeceras; el handler ahora reusa `limiter._inject_headers` cuando la app publica su limiter en `app.state.limiter` — si la inyección falla se loguea y se devuelve el 429 igualmente. Desbloquea en `app-backend` el patcher `_patch_permissions_populate_sync` (gateado a `>=0.13.2`). |
| `v0.13.1` | **Patch sobre 0.13.0 (2 fixes, sin cambios de API)**: `form-validate.js` deja de castigar campos required vacíos que el usuario nunca tocó — tabular por un form recién cargado sembraba "Completa este campo"; ahora el blur solo marca campos con valor inválido o escrito-y-borrado, y los required vacíos sin tocar se reclaman en el submit ("reward early, punish late"). El check `migrations-versioned` de `qa.run_audit` acepta nombres descriptivos de migración (`v2_users_rbac.sql`, el patrón real de las apps generadas) además del estricto `vN.sql`; antes no extraía número de versión y el audit reventaba al construir el detalle. Devuelve blocker explícito si ningún fichero tiene número parseable. |
| `v0.13.0` | **Clerk como proveedor de identidad alternativo** (`auth/clerk.py`, extra `[clerk]`): validación del session token RS256 contra el JWKS de la instancia (`verify_clerk_jwt`/`verify_clerk_jwt_cached`, cache 1h + refetch ante `kid` desconocido) y wrappers async de la Clerk Backend API con patrón Result (`list_users`, `get_user`, `get_user_by_email`, `create_user`, `delete_user`, `set_user_password`, `update_user_role`, `get_user_cached`). **Directorio de identidades acoplable** (`users/directory.py`): el panel `/admin/users` resuelve un `UserDirectory` con dos implementaciones — `SupabaseDirectory` (default, comportamiento histórico) y `ClerkDirectory`; la app host activa Clerk con `set_user_directory(ClerkDirectory())` al arrancar y `users_panel.html` oculta los botones sin soporte según `capabilities`. **CSP consciente de Clerk** (`build_default_csp`): con `AUTH_PROVIDER=clerk` añade el dominio Frontend API (script-src + connect-src) y `worker-src 'self' blob:`. Env vars: `CLERK_SECRET_KEY`, `CLERK_PUBLISHABLE_KEY`, `CLERK_JWT_LEEWAY_S`. Sin breaking changes: sin `set_user_directory(...)` todo se comporta como en `0.12.x`. |
| `v0.12.1` | **Patch sobre 0.12.0**: leeway de reloj opt-in al validar JWT (`SUPABASE_JWT_LEEWAY_S`, default 0, fail-closed) — elimina el monkeypatch por app cuando el reloj del host va por detrás del de Supabase; aliases cortos de tokens CSS (`--color-fg`/`-bg`/`-muted`/`-accent`); check QA `no-hardcoded-colors` (severidad `warning`). **Fix**: `apply_migrations` Windows-safe (worker async en `SelectorEventLoop` dedicado en `os.name=="nt"`; el driver async de `psycopg` no soporta el `ProactorEventLoop`); `core/legal/page.html` tokenizada (`var(--color-*)` + `data-theme` en vez de hex + `prefers-color-scheme`). Sin cambios en la API pública. |
| `v0.12.0` | **BREAKING**: las env vars que la app lee a través del paquete pierden el prefijo `DG_` (`DG_ENV`→`ENV`, `DG_DATABASE_URL`→`DATABASE_URL`, `DG_CSRF_ENABLED`→`CSRF_ENABLED`, …), **sin aliases de retro-compat**. Las apps ya desplegadas reciben el rename de su `.env` vía "Actualizar common"; hasta entonces deben quedarse en `0.11.x`. Sin cambios funcionales — solo cambia la clave de entorno. |
| `v0.11.0` | **Página de cuenta self-service** (`account.make_account_router`: `/account` con perfil + cambio de contraseña re-verificando la actual contra Supabase Auth + acciones GDPR) y **email transaccional** (`email.send_email` vía SMTP, error-como-valor, sin PII). UX del shell: command palette Ctrl+K, `dgConfirm()` + interceptor `hx-confirm`, macro `modal()` sobre `<dialog>`, validación progresiva de forms, preload de navegación en hover, View Transitions y `prefers-reduced-motion`. `prod_ready`: checks nuevos (`password-reset-flow`, `account-page`, `support-page`, env `SMTP_HOST`/`SUPPORT_EMAIL`). |
| `v0.10.1` | **UX de componentes compartidos**: `paginated_table` muestra spinner (`hx-indicator`) y deshabilita los botones de paginación durante la petición HTMX; `htmx-events.js` marca el target de todo swap con `aria-busy="true"` mientras la petición está en vuelo; el hover de `.table-tr` pasa a un tinte 8% del color de texto vía `color-mix` (perceptible en ambos temas y con cualquier override de marca; antes `surface-subtle` sobre panel blanco era ~1.5:1). Sin cambios de API Python. |
| `v0.10.0` | **BREAKING (solo para consumidores del directorio `template/`)**: el skeleton de apps sale del paquete y vive en el repo propio (GitHub template, contenido en la raíz). El paquete queda solo con la lib pip. El backend clona `DG_APP_TEMPLATE_REPO`; las apps generadas no importan `template/` y no se ven afectadas. **Fix**: el 404 de rutas no definidas devolvía JSON crudo en navegación — `register_error_handlers` se registra ahora sobre `starlette.exceptions.HTTPException` (clase padre), que captura tanto las HTTPException de FastAPI como las del router de Starlette. |
| `v0.9.6` | **Fix CSRF en uploads nativos**: el middleware CSRF solo leía el token de bodies `application/x-www-form-urlencoded`. Un form `multipart/form-data` con submit nativo (subir un fichero sin HTMX/`fetch`) no manda el header `X-CSRF-Token`; `csrf.js` inyecta el token como campo hidden, pero viaja en el body multipart que el middleware ignoraba → 403 `CSRF_TOKEN_MISMATCH` en toda subida, con el JSON crudo a pantalla. `_extract_submitted_token` ahora parsea también multipart. Además, un fallo CSRF en navegación nativa (Accept: text/html, sin HTMX/XHR) devuelve una página HTML legible de "sesión caducada" con botón de volver, en vez del JSON crudo a pantalla; HTMX/XHR siguen recibiendo el JSON. Solo lib; sin cambios en el template. |
| `v0.9.5` | **Fix páginas legales 404 en apps**: el `Dockerfile` del template no copiaba `docs/` al runtime, así que `make_legal_router` no se cableaba y los links del footer (`/privacy`, `/terms`, `/security`) daban 404 (mismo patrón que el 503 del backend). Añadido `COPY docs/legal/` + ajuste del `.dockerignore` (`docs/*` + `!docs/legal`). **Fix UI**: footer del shell con texto descentrado — el `padding-bottom: env(safe-area-inset-bottom)` machacaba el `py-2` en desktop; eliminado. **Fix CSS perdido**: `.gitignore` ignoraba todo `src/static/css/` (donde las skills mandan escribir CSS custom) → assets escritos a mano se perdían (404). Ahora solo se ignora `tailwind.compiled.css`; el `.gitignore` pasa a create-only en el update para no revertir ajustes del operador. |
| `v0.9.4` | **Fix CI `security-audit`**: `template/.github/workflows/ci.yml` auditaba con `pip-audit --strict --skip-editable`, que inspecciona todo el entorno del runner (incluido `pip`). `PYSEC-2026-196` (pip 26.1.1) tumbaba el job de toda app generada. Pasa al patrón `pip install -e .` + `pip freeze --exclude-editable` + `pip-audit --requirement` (excluye editables y herramientas del entorno). Solo template; sin cambios en la lib. |
| `v0.9.3` | **Fix shell reescrito a flex-column**: el fix de 0.9.2 no bastaba (el `margin-top` del wrapper colapsaba con el `<body>` → sidebar sobre el header + hueco abajo). `sidebar_shell.html` reescrito al patrón app-shell canónico: `<body>` columna flex de alto viewport (nunca scrollea), header/footer `shrink-0`, fila sidebar+contenido `flex-1`, solo `main` scrollea. |
| `v0.9.2` | **Fix footer fijo y doble scrollbar**: `sidebar_shell.html` — el footer estaba fuera del presupuesto de alto del layout: el body desbordaba (doble scrollbar) y la sidebar estática subía por encima del header al scrollear. El wrapper pasa a `flex-col` con fila interna `flex-1` (sidebar + main) y el footer `shrink-0`; solo `main` scrollea. |
| `v0.9.1` | **Fix responsive (varios)**: CSS recompilado — faltaban `md:static`/`md:translate-x-0`/`md:w-56`/`sm:justify-center`/`sm:pt-0` en el wheel (sidebar oculta en desktop, login sin centrar en `sm+`). `users_panel.html` `overflow-hidden` → `overflow-x-auto` (scroll horizontal en mobile). `paginated_table.html` con `id` dinámico desde `target` (HTMX apuntaba a id inexistente). Shell: safe-area iOS, z-index overlay drawer, touch target hamburguesa 10×10. `roles_panel.html` grid `sm:grid-cols-2`. `legal/page.html` dark mode + scroll en mobile. Test `test_css_coverage.py` para prevenir la rotura. **Removed**: `modal.html` y `command_palette.html` eliminados (muertos). |
| `v0.9.0` | **BREAKING**: namespace de UI `dg/` → `core/` (directorio físico `ui/templates/dg/` → `ui/templates/core/`) y mount estático `/static/dg-common` → `/static/core` (`name="core-static"`). Sin alias de compatibilidad — toda app que use `{% extends "dg/..." %}` o `/static/dg-common/...` debe actualizar antes de instalar. **Added**: responsive shell mobile-first completo: drawer de navegación con `mobile-nav.js`, trap de foco, lock de scroll, logout en mobile, `viewport-fit=cover` + `env(safe-area-inset-*)` en todos los shells. |
| `v0.8.7` | **Fix `register_error_handlers` rompía el redirect a /login en sesión expirada**: cuando `require_auth` levantaba `HTTPException(303, headers={"Location": "/login"})`, el handler global de v0.8.6 lo interceptaba, perdía el `Location` y renderizaba "Error 303 / See Other" como página. Ahora los códigos `[300, 400)` se devuelven como `RedirectResponse(url=Location, status_code=...)` sin tocar; solo `[400, 600)` activa el flow banner/JSON. 2 tests de regresión cubriendo Location mayúscula y minúscula. |
| `v0.8.6` | **Banner global de errores** (`register_error_handlers(app, jinja_env)`): reemplaza el JSON crudo sobre pantalla blanca por un toast en `#dg-toasts` para navegaciones HTML/HTMX; preserva JSON para clientes API (`/api/*`/`/auth/*` o `Accept: application/json`). Mensajes por código (401/403/404/422/429/5xx). >=500 oculta detalles internos al usuario y vuelca el stack al logger. Soporte en layouts: `empty.html` monta `#dg-toasts` + carga `toasts.js`; `sidebar_shell.html` expone bloque `toast_initial`. `htmx-events.js` añade `htmx:beforeSwap` para que HTMX swappee respuestas 4xx/5xx con OOB toast. — **Fix CSRF** en `/auth/password` y `/logout` server-side (sin JS): `CSRFMiddleware` resuelve/genera el token al inicio del dispatch y lo expone como `request.state.dg_csrf_token`. Macro nueva `dg/components/csrf.html::csrf_input(request)` renderiza el hidden input `csrf_token` directamente en Jinja, eliminando la dependencia de `csrf.js` para forms server-rendered. `template/src/templates/auth/login.html` (standalone, no cargaba `csrf.js`) y `dg/layouts/sidebar_shell.html` (form de logout) usan el macro. `csrf.js` sigue cargado para HTMX y como red de seguridad para forms dinámicos. 2 tests nuevos de regresión CSRF + 5 nuevos de errors. |
| `v0.8.5` | Fix `DG_DATABASE_URL` con esquema HTTPS reventaba el lifespan: `resolve_postgres_url` valida ahora el esquema del valor explícito y levanta `ValueError` con mensaje claro si no es libpq (`postgresql://`, `postgres://`, `postgresql+psycopg://`, `postgresql+psycopg2://`). Antes pasaba `https://...` directo a SQLAlchemy y rebotaba con `NoSuchModuleError: sqlalchemy.dialects:https` críptico en crash loop. |
| `v0.8.4` | Fix `pip-audit` (era `Dependency not found on PyPI: <pkg>`): `template/.github/workflows/ci.yml::security-audit` lee el nombre del paquete del propio `pyproject.toml` (via `tomllib`) y lo desinstala tras `pip install .` para que pip-audit `--strict` no rechace el paquete privado del cliente. Fix `I001` en `src/main.py` patcheado: `_patch_main_for_production_readiness_helpers` fusiona `health` dentro del `from src.api import ...` existente (sorted) en lugar de añadir línea separada. **Backend**: `update_common_pr.py` aplica `ruff --fix` (+ wrapper de docstrings largos: ruff `E501` no rompe `"""..."""` solo, lo hacemos a mano) automáticamente sobre `src/main.py` (tras los patches encadenados) y sobre todos los `.py` de `src/api/sections/` + `tests/` (archivos del agente `section_boilerplater`) dentro del PR del bot — el dev no necesita ejecutar `ruff --fix` a mano tras cada bump. |
| `v0.8.3` | Fix lint del template (`ruff`) y CI (`pip-audit`): `template/src/**` pasa ahora `ruff check` limpio tras auto-fix de `I001`/`E501`/`RUF100` en `db.py`, `main.py`, `api/auth.py`, `api/admin_users.py`, `api/health.py`. `template/.github/workflows/ci.yml::security-audit` cambia `pip install -e .` por `pip install .` para que `pip-audit --strict --skip-editable` no choque con el editable. Apps existentes reciben ambos fixes en el siguiente "Actualizar common" del backend. |
| `v0.8.2` | Fix CSRF en `empty.html` (login/signup/reset): `dg/layouts/empty.html` ahora carga `csrf.js` con `defer`, cubriendo el form `POST /auth/password` que v0.8.1 dejó fuera. Ambos layouts del paquete (`sidebar_shell` + `empty`) tienen wiring CSRF completo. |
| `v0.8.1` | Fix CSRF en logout (topbar): nuevo `static/js/csrf.js` bundleado en el wheel, cargado desde `sidebar_shell.html`. Lee la cookie `dg_csrf` e inyecta `X-CSRF-Token` en cada `htmx:configRequest`; para forms HTML clásicos inserta campo hidden `csrf_token` en el submit. Idempotente. Fix `template/src/api/health.py`: helper público `get_engine()` lazy en `template/src/db.py` para que el smoke test del template pase sin `POSTGRES_PASSWORD`. |
| `v0.8.0` | **BREAKING**: `auth.local` eliminado (alias deprecated desde v0.3.0 — reemplazar por `auth.session_hmac`). **Security (11 fixes)**: XSS en parser markdown, OAuth callback sin anti-CSRF, `/auth/password` exento de CSRF, Sentry scrubbing incompleto, `_client_ip` confiaba en `X-Forwarded-For`, SQL identifier injection en `retention/purger.py`, `audit.emit` sin scrub de emails en UA. 40 tests de regresión. **Added**: `qa.run_audit` (19 checks, 0 deps externas); helpers production-readiness (`observability`, `middleware`, `me`, `legal`, `retention`, `audit`, `env`); template con compose perfiles redis/worker/backup, `ci.yml` con `prod-ready-audit`, plantillas `docs/PRIVACY.md` + `docs/RUNBOOK.md` + `docs/legal/`; módulo `env` con `dg_env()`/`is_dev_env()`/`is_prod_env()` — canónico `prod`/`dev`. |
| `v0.7.15` | `.sidebar-link-active` sólido (lima + texto navy) en lugar de translúcido al 22%: alineado con el look del backend orquestador. Resuelve via `var(--color-brand-primary-rgb)`/`var(--color-brand-secondary-rgb)`. |
| `v0.7.14` | Fix crítico de assets locales en apps generadas: `template/src/main.py` ahora monta `/static/` apuntando a `src/static/` del repo (además del `/static/dg-common/` que monta `register_ui`). Sin este mount las apps respondían 404 a `/static/brand/tokens-override.css` aunque el deployer Datagrowth lo hubiera escrito en su filesystem — bug invisible mientras `tailwind.compiled.css` bakeaba hex literales (pre-0.7.13), crítico desde 0.7.13 con utilities `var(--color-*-rgb)`. Apps existentes heredan el mount via patcher quirúrgico `_patch_main_for_local_static_mount` del backend Datagrowth al pulsar "Actualizar common" (idempotente, deja `structure-mismatch:no-register-ui` si el operador customizó el bootstrap). Encadenado con el patcher `_patch_main_for_template_state` (0.7.11) en un único commit `src/main.py (state dep, local static mount)`. |
| `v0.7.13` | Paquete deja de imponer marca Datagrowth: `tokens.css` declara brand/semánticos en doble forma (triplet `--color-X-rgb` para Tailwind con `<alpha-value>` + alias funcional `--color-X` para CSS directo) con defaults gray-based neutrales. `tailwind-preset.cjs` pasa de hex literales a `rgb(var(--color-X-rgb) / <alpha-value>)` — `tailwind.compiled.css` recompilado: cero hex Datagrowth bakeados, todas las utility classes resuelven la marca del cliente vía CSS vars. `sidebar_shell.html` añade dos fallbacks `request.state.*`: `dg_user` (topbar email/Admin/Salir consistente en páginas servidas por routers del paquete como `make_users_router`) y `dg_tokens_override_href` (`<link>` al CSS override del cliente sin tener que pasarlo en cada `TemplateResponse`). Nuevo set canónico de 46 vars en el override del cliente (5 brand-rgb + 5 brand funcionales + 2 on-*, 4 surfaces light/dark, 4 text, 2 link, 2 sidebar-active, 8 semánticos-rgb + 8 funcionales, 3 fonts, 1 ring-focus). Nuevo `template/setup.json` + `template/SETUP.md` para manifest declarativo de campos extra al instalar la app (consumido por `app-backend/src/packaging/setup_manifest.py`). Apps existentes: regenerar `tokens-override.css` desde la galería tras pullear para que las utility classes con alpha (`bg-brand-lime/15`) recuperen la marca del cliente — sin regenerar caen a gray. |
| `v0.7.12` | Apps generadas listas para desarrolladores externos: nueva skill `template/.agents/skills/dg-customize` con el contrato customizable vs gestionado (qué archivos regenera el backend y cuáles puede tocar el dev), regla `template/.claude/rules/managed-files.md` que Claude Code carga automáticamente al editar archivos gestionados (`src/static/brand/**`, `infra/Dockerfile`, `src/api/{deps,auth,admin_users}.py`, etc.), `template/setup.sh` de bootstrap (symlinks `.claude/skills` + `.cursor/skills` → `.agents/skills`), `template/README.md` rewrite con sección "Empezar a desarrollar", `template/CLAUDE.md` con bloque inicial "Esta es una app generada". Fix de rendering del header "Administración" del sidebar: la clase `.sidebar-section-label` ya viaja en el compiled CSS (era purgada porque `tailwind.config.js` no escaneaba `template/src/templates/**`). Header renombrado a "Administrador" con `mb-2`/`my-3` para más jerarquía visual. Apps existentes: `setup.sh` + regla en `_SYNC_FILES`, skill via `_SYNC_DIRS_RECURSIVE`, rename + spacing via patcher quirúrgico `_patch_sidebar_admin_section` (idempotente). |
| `v0.7.11` | Sidebar admin consistente cross-pantalla en apps generadas. Nueva dep `template/src/api/deps.py:populate_template_state` que inyecta `request.state.dg_user` antes de cada handler HTML. `main.py` del template aplica `dependencies=[Depends(populate_template_state)]` a `dashboard.router`, `users_router` y `admin_users.router` (no a `auth.router`, que sirve `/login` público). `partials/sidebar_nav.html` añade fallback `{% set user = user or request.state.dg_user %}` para que el bloque "Administración" funcione cuando el handler (caso `users_router` del paquete) no pasa `user` al context Jinja. Apps existentes: el backend Datagrowth ofrece 2 patchers quirúrgicos en `update_common_pr.py` (`_patch_main_for_template_state` + `_patch_sidebar_for_state_fallback`) que viajan en el siguiente "Actualizar common". |
| `v0.7.10` | Skills heredadas en `template/.agents/skills/` viajan en cada repo nuevo: `fastapi`, `supabase`, `boot`, `evolution`, `evolve`, `find-skills`, `fix-issue`, `modular-design`, `precommit`. El backend Datagrowth las sincroniza vía `_SYNC_DIRS_RECURSIVE` (auto-discovery — añadir una skill nueva al template basta para que se propague sin tocar la lista de archivos en `update_common_pr.py`). |
| `v0.7.9` | Sidebar activo deriva del color de marca: `tokens.css` añade `--color-sidebar-active-bg` (`color-mix(in srgb, var(--color-brand-primary) 18%, transparent)` en light, 22% en dark) y `--color-sidebar-active-text` (en dark usa `var(--color-brand-primary)` para legibilidad sobre superficie navy). `.sidebar-link-active` deja de hardcodear `bg-brand-lime/20 text-brand-dark`. Apps con primary distinto al lime ven el sidebar adaptado automáticamente. **Fallback admin badge tras primer arranque**: `require_auth` de la app fuerza `is_admin=True` si el email del JWT coincide con `APP_ADMIN_EMAIL` (cubre el caso de carrera entre el bootstrap admin del lifespan y el JWT viejo en cookies). `tmp/` retirado del template (era residuo del `app-backend`). |
| `v0.7.8` | Fixes visibles en la app desplegada: alias `POST /logout` añadido (antes 404 — el botón del topbar shared apunta a `/logout`), `dashboard.html` arregla `{{ user.email }}` que se renderizaba literal (era string param a macro), `bootstrap_admin_from_env` ahora sincroniza `app_metadata.role` en Supabase Auth tras `set_user_roles` (sin esto el sidebar admin no aparece aunque la tabla `app_role` lo marque). |
| `v0.7.7` | Bundle de marca local en `src/static/brand/` (CSS + meta.json + logo) copiado por el deployer al repo de la instance — cero fetch runtime al backend, cero env vars `BRAND_*`. Marker `INSTANCE_TOKENS_OVERRIDE_LINK` eliminado del template. `auth.py:login_page` lee `meta.json` desde filesystem. Fix crítico: `_get_audience()` cae al default `"authenticated"` cuando `SUPABASE_JWT_AUDIENCE` está empty/whitespace (antes rompía con "Audience doesn't match" aunque el JWT viniera correcto). `SUPABASE_JWT_AUDIENCE`, `BRAND_CLIENT_NAME`, `BRAND_LOGO_URL`, `BRAND_PRIMARY_COLOR` eliminadas del `.env.example`. `update_common_pr` (backend) filtra estas keys obsoletas del `.env.example` de apps existentes. |
| `v0.7.6` | Tokens semánticos de contraste `--color-on-primary` / `--color-on-secondary` en `tokens.css`. Login standalone usa el token en el botón primario (antes `color: #fff` hardcoded, ilegible sobre lime). El agente `design_to_tokens` (backend) genera ambos tokens automáticamente con heurística de luminance. Recomendados, no obligatorios — brand designs viejos siguen funcionando. |
| `v0.7.5` | Nueva skill `template/.claude/skills/modular-design`: contrato de modularidad para todos los elementos de diseño en apps generadas. Cualquier asset visual (colores, logo, tipos, copy de marca) debe ser sustituible sin tocar código vía `tokens-override.css` del cliente o env vars `BRAND_*`. Defaults Datagrowth en `template/infra/.env.example` (`BRAND_CLIENT_NAME=Datagrowth`, logo apuntando a `datagrowth.es`) — el control plane sobrescribe con valores del cliente al deployar. Color primario migrado de env var a CSS token (`var(--color-brand-primary)`). |
| `v0.7.4` | `apply_migrations` blindado contra race conditions cross-process: adquiere `pg_advisory_lock` antes de leer la tabla de control. Sin esto, dos workers uvicorn entrando al lifespan en paralelo se cargaban en `CREATE TYPE` con `duplicate key ... pg_type_typname_nsp_index`. Template `infra/Dockerfile` baja a `--workers 1` como defensa en profundidad. `template/src/api/deps.py:require_auth` redirige a `/login` (303) cuando el navegador entra sin sesión, en vez de devolver JSON 401. |
| `v0.7.2` | Nuevo `datagrowth_common.migrations.apply_migrations()`. El template invoca la función en el lifespan: las apps generadas aplican sus `migrations/*.sql` solas (sin que el control plane tenga que pegar Postgres del cliente). Nueva optional dep `[postgres]`. Template ajustado: `Dockerfile` copia `migrations/`, `docker-compose.yml` declara `env_file: ../.env` para cargar el `.env` que Easypanel materializa en la raíz del repo clonado. |
| `v0.6.x` | Template adopta el panel RBAC nuevo (`make_users_router`). Endpoint `POST /admin/users/{id}/password/set`. Compose del template: build local + red `n8n_supabase_default` external + sin labels Traefik. Botones admin con bypass para `is_admin` + fall-through en `DG_ENV != production`. |
| `v0.5.0` | Nuevo módulo `datagrowth_common.users`: `make_users_router`, `PermissionCatalog`, `make_require_permission`, `bootstrap_roles`, audit log, migración `001_users_rbac.sql`. UI panel reescrita (3 tabs, multi-rol, vanilla JS sin HTMX). Dep nueva `sqlalchemy>=2.0`. |
| `v0.4.x` | Tailwind preset distribuido (`tailwind-preset.cjs`) empaquetado dentro del wheel. Tokens semánticos `text-fg`/`bg-surface-subtle` + dark mode con FOUC guard + sync entre pestañas. Token `link`/`link-hover` para anchors legibles en dark. |
| `v0.3.x` | **BREAKING (v0.3.0)**: elimina `authentik`, `auth_fastapi`, integración Authentik OIDC. Renombra `auth.local` → `auth.session_hmac`. Modelo único: Supabase Auth. **v0.3.1**: subpaquete `datagrowth_common.ui` (`register_ui` + 20 templates + tokens). **v0.3.2+**: repo dual (PyPI + GitHub template). |
| `v0.2.x` | Añade `auth.supabase` (verify JWT), `auth.supabase_admin` (Admin API), `auth.session` (cookie httpOnly), `auth.router` (`make_auth_router` con OAuth Microsoft/Google + refresh). |
| `v0.1.0` | Primera release pública: `auth.local`, `authentik`, `auth_fastapi`, `result`, `logger`, `supabase`, `shared_models`, `updater`. |
