Metadata-Version: 2.4
Name: datagrowth-common
Version: 0.10.0
Summary: Datagrowth common: Supabase Auth (cookie JWT + Admin 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,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: 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'
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.9,<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`, `get_session_tokens`, `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 (`DG_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). |

### 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. |

### 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. Flag `DG_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. Flag `DG_CSRF_ENABLED`. |
| `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 `DG_RATE_LIMIT_ENABLED`. |
| `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. `trust_proxy=False` por default (ignora `X-Forwarded-For` para evitar IPs falseadas en audit log); pasa `True` solo detrás de proxy de confianza. |
| `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_prod_env` | Helper canónico para `DG_ENV`. Normaliza a `prod`/`dev` (default `prod`, valores desconocidos caen a `prod` por fail-safe). Acepta legacy `production`/`development` por compat con apps ya desplegadas. |
| `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 DG_RETENTION_PURGE_INTERVAL_S segundos
# (APScheduler, arq, cron del SO).
```

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

| Módulo | Contenido |
|---|---|
| `qa.run_audit(repo_root)` | Auditor determinista. Ejecuta 19 checks sobre el repo (no-print, structlog, .env.example, migrations, /live, /ready, security headers, CSRF, rate-limit, GDPR endpoints, CI workflow, secrets hardcoded, 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.9,<1.0"
```

Extras disponibles:

- `[supabase]` — `supabase>=2.0` + `pyjwt>=2.9` (necesario para `verify_supabase_jwt`, `users_router` y el módulo `users`).
- `[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`, `psycopg`).

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

```toml
[project]
dependencies = [
    "datagrowth-common[supabase,postgres]>=0.9,<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.

## 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 template oficial (`template/`) ya viene con esto cableado — incluido el bootstrap de admin desde `APP_ADMIN_EMAIL`. Ver `template/src/main.py` y `template/src/api/admin_users.py` 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` |
| `DG_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` |
| `DG_ENV` | Valor canónico: `prod` (default) o `dev`. Acepta legacy `production`/`development` por compat. `dev` permite cookies sin `Secure` para tests locales sin TLS. | `prod` |

### Genéricas

| Variable | Descripción | Por defecto |
|---|---|---|
| `DG_ENV` | Valor canónico: `prod` (default) o `dev`. Acepta legacy `production`/`development` por compat. `dev` permite cookies sin `Secure` para tests locales sin TLS. | `prod` |
| `LOG_LEVEL` | `DEBUG`, `INFO`, `WARNING`, `ERROR` | `INFO` |
| `DG_APP_SLUG` | Slug de la app para el checker de actualizaciones | — |
| `DG_TENANT_SLUG` | Slug del tenant para el checker de actualizaciones | — |
| `DG_APP_VERSION` | Versión actual de la app | `0.0.0` |
| `DG_RELEASES_URL` | URL base del backend de releases | `https://backend.dev.datagrowth.es` |

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

| Variable | Descripción | Por defecto |
|---|---|---|
| `DG_SESSION_SECRET` | Secreto HMAC para firmar la cookie (≥32 bytes) | — |
| `DG_SESSION_COOKIE` | Nombre de la cookie | `dg_session` |
| `DG_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 |
|---|---|---|
| `DG_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 `DG_DATABASE_URL`) | — |
| `DG_SUPABASE_DB_HOST` | Host de Postgres | `db` |
| `DG_SUPABASE_DB_PORT` | Puerto de Postgres | `5432` |
| `DG_SUPABASE_DB_USER` | Usuario Postgres | `postgres` |
| `DG_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.8.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 19 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.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`. |
