Metadata-Version: 2.4
Name: datagrowth-common
Version: 0.7.6
Summary: Datagrowth common: Supabase Auth (cookie JWT + Admin API), API helpers, structured logging, result pattern, Supabase client, shared models and Jinja2 + Tailwind UI subpackage.
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: 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'
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

Repo de doble propósito:

1. **Paquete Python** (`src/datagrowth_common/`): auth (Supabase + cookie HMAC), UI (Jinja2 + CSS + JS), logging, helpers de API. Se instala vía pip.
2. **GitHub template** (`template/`): scaffold FastAPI listo para Datagrowth. Se instancia con `gh repo create --template datagrowth/datagrowth-common`.

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

## Quickstart como template (nuevo repo de app)

```bash
gh repo create my-new-app --template datagrowth/datagrowth-common --private
gh repo clone datagrowth/my-new-app
cd my-new-app
cp infra/.env.example .env  # rellena las claves Supabase
docker compose -f infra/docker-compose.yml up -d --wait
curl http://localhost:8000/healthz
```

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]>=0.7,<0.8"
```

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`, 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. Antes de v0.3.0 se llamaba `auth.local`. |
| `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/dg-common")` 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/dg/` | 20 partials Jinja2 con prefijo obligatorio `dg/...`: layouts (`sidebar_shell`, `empty`), componentes (`badge`, `button`, `card`, `command_palette`, `data_table`, `empty_state`, `form_field`, `icon`, `modal`, `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). |

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

`auth.local` se conserva como alias deprecated (DeprecationWarning) hasta v0.4.0. Ver `CHANGELOG.md` para la guía de migración completa.

## Instalación

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

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

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.7,<0.8",
]
```

### 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/dg-common/* y dg/... 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` | `development` permite cookies sin `Secure` para tests locales sin TLS | `production` |

### Genéricas

| Variable | Descripción | Por defecto |
|---|---|---|
| `DG_ENV` | `development` permite cookies sin `Secure` para tests locales sin TLS | `production` |
| `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.7.x): cookie HMAC (`auth.session_hmac`), validación JWT Supabase, router CRUD de usuarios (legacy), cookie httpOnly de sesión Supabase, `auth_router` (login + OAuth + refresh + logout), deprecation warning de `auth.local`, 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`).

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