ops: generar accesos por ambiente y verificar Postgres, Redis y Contabo

Scripts create-accesses / verify-connectivity para un Postgres+Redis+bucket
por ambiente. /v1/health y el arranque de la API incluyen sonda de storage.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-02 21:22:39 +00:00
parent d36c287a82
commit 1ac04996b6
No known key found for this signature in database
8 changed files with 351 additions and 12 deletions

View file

@ -83,6 +83,18 @@ jobs:
REDIS_URL_CORE: redis://panels_core_redis:ci-core-redis@localhost:6379 REDIS_URL_CORE: redis://panels_core_redis:ci-core-redis@localhost:6379
run: ./db/provision/verify-isolation.sh run: ./db/provision/verify-isolation.sh
- name: Verificar conectividad Postgres/Redis (S3 opcional en CI)
env:
DATABASE_URL_PLATFORM: postgresql://panels_platform_app:ci-platform-app@localhost:5432/panels_platform
DATABASE_URL_PLATFORM_OWNER: postgresql://panels_platform_owner:ci-platform-owner@localhost:5432/panels_platform
DATABASE_URL_IAM: postgresql://panels_iam_app:ci-iam-app@localhost:5432/panels_product
DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:ci-iam-owner@localhost:5432/panels_product
DATABASE_URL_CORE: postgresql://panels_core_app:ci-core-app@localhost:5432/panels_product
DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:ci-core-owner@localhost:5432/panels_product
REDIS_URL_IAM: redis://panels_iam_redis:ci-iam-redis@localhost:6379
REDIS_URL_CORE: redis://panels_core_redis:ci-core-redis@localhost:6379
run: ./db/provision/verify-connectivity.sh
- name: deno check - name: deno check
working-directory: api working-directory: api
run: deno check main.ts run: deno check main.ts

View file

@ -61,7 +61,7 @@ import {
lineAmount, lineAmount,
listBudget, listBudget,
} from "./budget.ts"; } from "./budget.ts";
import { companyDocKey, getObject, projectDocKey, workerDocKey } from "./storage.ts"; import { companyDocKey, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts";
import { cacheCore, cacheKeyCore } from "./cache.ts"; import { cacheCore, cacheKeyCore } from "./cache.ts";
import { pingRedis } from "./redis.ts"; import { pingRedis } from "./redis.ts";
@ -105,13 +105,15 @@ app.use(
// Postgres/Redis, /v1/health de verdad toca las tres conexiones Postgres y // Postgres/Redis, /v1/health de verdad toca las tres conexiones Postgres y
// las dos de Redis, no solo responde estático. // las dos de Redis, no solo responde estático.
app.get("/v1/health", async (c) => { app.get("/v1/health", async (c) => {
const [core, platform, redis] = await Promise.all([ const [core, platform, redis, storage] = await Promise.all([
pingCoreDb().then(() => true).catch(() => false), pingCoreDb().then(() => true).catch(() => false),
pingPlatformDb().then(() => true).catch(() => false), pingPlatformDb().then(() => true).catch(() => false),
pingRedis(), pingRedis(),
pingStorage(),
]); ]);
const ok = core && platform && redis.iam && redis.core; const storageOk = storage.ok && (storage.backend === "local" || storage.configured);
return c.json({ ok, core, platform, redis }, ok ? 200 : 503); const ok = core && platform && redis.iam && redis.core && storageOk;
return c.json({ ok, core, platform, redis, storage }, ok ? 200 : 503);
}); });
app.post("/v1/auth/login", async (c) => { app.post("/v1/auth/login", async (c) => {
@ -1260,6 +1262,12 @@ await Promise.all([
pingRedis().then((r) => { pingRedis().then((r) => {
if (!r.iam || !r.core) throw new Error("Redis (iam/core) no responde"); if (!r.iam || !r.core) throw new Error("Redis (iam/core) no responde");
}), }),
pingStorage().then((s) => {
if (!s.ok) throw new Error("Storage (Contabo o disco local) no responde");
if (!config.isDev && !s.configured) {
throw new Error("S3_ENDPOINT/S3_BUCKET/S3_* son obligatorios fuera de desarrollo");
}
}),
]); ]);
Deno.serve({ port, hostname: "0.0.0.0" }, app.fetch); Deno.serve({ port, hostname: "0.0.0.0" }, app.fetch);

View file

@ -0,0 +1,35 @@
/**
* Sonda de Contabo (S3): HeadBucket + put/get/delete.
*
* Desde api/:
* deno run --allow-net --allow-env --allow-read --allow-write scripts/verify-storage.ts
*
* Requiere S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY.
* Si REQUIRE_S3=1 y no hay credenciales, sale con error (staging/prod).
* Si no están, sale 0 y avisa (dev local con disco).
*/
import { config } from "../config.ts";
import { pingStorage, probeStorageReadWrite, s3Configured } from "../storage.ts";
const requireS3 = ["1", "true", "yes"].includes((Deno.env.get("REQUIRE_S3") ?? "").toLowerCase());
if (!s3Configured()) {
const msg =
"S3 no configurado (S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY).";
if (requireS3) {
console.error(`FAIL - ${msg} Obligatorio en staging/producción.`);
Deno.exit(1);
}
console.log(`SKIP - ${msg} En este ambiente se usará disco local.`);
Deno.exit(0);
}
console.log(`S3 endpoint=${config.s3Endpoint} bucket=${config.s3Bucket} region=${config.s3Region}`);
const ping = await pingStorage();
if (!ping.ok) {
console.error("FAIL - HeadBucket: no se pudo alcanzar el bucket (credenciales, red o nombre).");
Deno.exit(1);
}
console.log("OK - HeadBucket");
await probeStorageReadWrite();
console.log("OK - put/get/delete de objeto sonda");

View file

@ -1,4 +1,10 @@
import { S3Client, PutObjectCommand, GetObjectCommand } from "npm:@aws-sdk/client-s3@3"; import {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadBucketCommand,
DeleteObjectCommand,
} from "npm:@aws-sdk/client-s3@3";
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { config, DATA_DIR } from "./config.ts"; import { config, DATA_DIR } from "./config.ts";
@ -20,10 +26,48 @@ import { config, DATA_DIR } from "./config.ts";
let client: S3Client | null = null; let client: S3Client | null = null;
function s3Configured(): boolean { export function s3Configured(): boolean {
return !!(config.s3Endpoint && config.s3Bucket && config.s3AccessKeyId && config.s3SecretAccessKey); return !!(config.s3Endpoint && config.s3Bucket && config.s3AccessKeyId && config.s3SecretAccessKey);
} }
export type StoragePing = {
configured: boolean;
ok: boolean;
backend: "s3" | "local";
};
/** HeadBucket (S3) o escritura de prueba en disco local. */
export async function pingStorage(): Promise<StoragePing> {
if (!s3Configured()) {
try {
await mkdir(join(DATA_DIR, "local-objects"), { recursive: true });
return { configured: false, ok: true, backend: "local" };
} catch {
return { configured: false, ok: false, backend: "local" };
}
}
try {
await getClient().send(new HeadBucketCommand({ Bucket: config.s3Bucket }));
return { configured: true, ok: true, backend: "s3" };
} catch {
return { configured: true, ok: false, backend: "s3" };
}
}
/** Put + get + delete de un objeto sonda. No dejar basura en el bucket. */
export async function probeStorageReadWrite(): Promise<void> {
const key = `_panels/connectivity-probe-${crypto.randomUUID()}`;
const payload = new TextEncoder().encode("panels-connectivity-probe");
await putObject(key, payload);
const got = await getObject(key);
if (new TextDecoder().decode(got) !== "panels-connectivity-probe") {
throw new Error("El objeto sonda no coincide con lo escrito");
}
if (s3Configured()) {
await getClient().send(new DeleteObjectCommand({ Bucket: config.s3Bucket, Key: key }));
}
}
function getClient(): S3Client { function getClient(): S3Client {
if (!client) { if (!client) {
client = new S3Client({ client = new S3Client({

View file

@ -60,6 +60,35 @@ El script verifica automáticamente que cruzar de módulo devuelva `NOPERM`
(ver "Riesgos y mitigaciones" del plan: un prefijo de llave sin ACL detrás (ver "Riesgos y mitigaciones" del plan: un prefijo de llave sin ACL detrás
no aísla nada). no aísla nada).
## Accesos de un ambiente (Coolify + Contabo)
**1 Postgres + 1 Redis + 1 bucket por ambiente.** El servidor lo crea
Coolify/Contabo; este repo solo genera roles y valida que contesten.
1. En Coolify: recurso Postgres y recurso Redis de **ese** ambiente.
En Contabo: bucket (ej. `panels-prod` / `panels-staging`) + access key.
2. Generar secretos y crear roles/ACLs contra esos hosts:
```bash
export PGHOST=... PGUSER=postgres PGPASSWORD=... # admin que da Coolify
export REDIS_ADMIN_URL="redis://:...@host:6379"
export S3_ENDPOINT=https://usc1.contabostorage.com
export S3_BUCKET=panels-prod
export S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=...
./db/provision/create-accesses.sh --apply --verify --out .env.prod.local
```
3. Pegar el contenido de `.env.prod.local` (gitignored) en las env del `api`.
4. Sin `--apply`, el script solo imprime el bloque; no toca servidores.
Comprobar conectividad después, sin reprovisionar:
```bash
set -a && source .env.prod.local && set +a
REQUIRE_S3=1 ./db/provision/verify-connectivity.sh # staging/prod
./db/provision/verify-isolation.sh
```
## Qué falta hacer manualmente en Coolify (staging/producción) ## Qué falta hacer manualmente en Coolify (staging/producción)
Estos scripts asumen que ya existe un servidor Postgres y un servidor Redis Estos scripts asumen que ya existe un servidor Postgres y un servidor Redis
@ -68,9 +97,10 @@ tiene acceso a la cuenta de Coolify del usuario, así que:
1. Crear el recurso Postgres gestionado en Coolify para el ambiente. 1. Crear el recurso Postgres gestionado en Coolify para el ambiente.
2. Crear el recurso Redis gestionado en Coolify para el ambiente. 2. Crear el recurso Redis gestionado en Coolify para el ambiente.
3. Correr estos scripts contra ambos usando las credenciales admin que da Coolify. 3. Crear el bucket Contabo de ese ambiente.
4. Cargar los secrets resultantes (`DATABASE_URL_*`, `REDIS_URL_*`) en la 4. Correr `create-accesses.sh --apply --verify` (o los SQL 01-04 + `05-redis-acl.sh`).
configuración del servicio `api` de ese ambiente. 5. Cargar los secrets resultantes (`DATABASE_URL_*`, `REDIS_URL_*`, `S3_*`)
en la configuración del servicio `api` de ese ambiente.
## Desarrollo local ## Desarrollo local

131
db/provision/create-accesses.sh Executable file
View file

@ -0,0 +1,131 @@
#!/usr/bin/env bash
# PANELS · genera secretos de UN ambiente y, si hay superusuario, crea
# roles/bases/ACLs. No crea el servidor Postgres, Redis ni el bucket:
# esos se levantan en Coolify / Contabo; este script solo deja los accesos.
#
# Uso (solo imprimir bloque .env, sin tocar servidores):
# PGHOST=pg.interno REDIS_HOST=redis.interno S3_BUCKET=panels-prod \
# ./db/provision/create-accesses.sh
#
# Uso (crear roles en un Postgres/Redis ya levantados):
# PGHOST=... PGUSER=postgres PGPASSWORD=... REDIS_ADMIN_URL=redis://:...@host:6379 \
# ./db/provision/create-accesses.sh --apply --verify
#
# Flags:
# --apply corre 01-04 SQL + 05 Redis ACL con las claves generadas
# --verify corre verify-connectivity.sh al final (implica tener URLs)
# --out FILE escribe el bloque .env (FILE debe estar gitignored, ej. .env.prod.local)
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
APPLY=0
VERIFY=0
OUT=""
while [[ $# -gt 0 ]]; do
case "$1" in
--apply) APPLY=1; shift ;;
--verify) VERIFY=1; shift ;;
--out) OUT="$2"; shift 2 ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) echo "Flag desconocido: $1" >&2; exit 1 ;;
esac
done
rand24() { openssl rand -hex 24; }
rand32() { openssl rand -hex 32; }
PGHOST="${PGHOST:-127.0.0.1}"
PGPORT="${PGPORT:-5432}"
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
REDIS_PORT="${REDIS_PORT:-6379}"
PGUSER="${PGUSER:-postgres}"
PLATFORM_OWNER_PASSWORD="${PLATFORM_OWNER_PASSWORD:-$(rand24)}"
PLATFORM_APP_PASSWORD="${PLATFORM_APP_PASSWORD:-$(rand24)}"
IAM_OWNER_PASSWORD="${IAM_OWNER_PASSWORD:-$(rand24)}"
IAM_APP_PASSWORD="${IAM_APP_PASSWORD:-$(rand24)}"
CORE_OWNER_PASSWORD="${CORE_OWNER_PASSWORD:-$(rand24)}"
CORE_APP_PASSWORD="${CORE_APP_PASSWORD:-$(rand24)}"
IAM_REDIS_PASSWORD="${IAM_REDIS_PASSWORD:-$(rand24)}"
CORE_REDIS_PASSWORD="${CORE_REDIS_PASSWORD:-$(rand24)}"
SESSION_SECRET="${SESSION_SECRET:-$(rand32)}"
DOCS_KEY="${DOCS_KEY:-$(rand32)}"
block() {
cat <<EOF
# PANELS · secretos de un solo ambiente (no mezclar prod/staging/dev)
SESSION_SECRET=${SESSION_SECRET}
DOCS_KEY=${DOCS_KEY}
DATABASE_URL_PLATFORM=postgresql://panels_platform_app:${PLATFORM_APP_PASSWORD}@${PGHOST}:${PGPORT}/panels_platform
DATABASE_URL_PLATFORM_OWNER=postgresql://panels_platform_owner:${PLATFORM_OWNER_PASSWORD}@${PGHOST}:${PGPORT}/panels_platform
DATABASE_URL_IAM=postgresql://panels_iam_app:${IAM_APP_PASSWORD}@${PGHOST}:${PGPORT}/panels_product
DATABASE_URL_IAM_OWNER=postgresql://panels_iam_owner:${IAM_OWNER_PASSWORD}@${PGHOST}:${PGPORT}/panels_product
DATABASE_URL_CORE=postgresql://panels_core_app:${CORE_APP_PASSWORD}@${PGHOST}:${PGPORT}/panels_product
DATABASE_URL_CORE_OWNER=postgresql://panels_core_owner:${CORE_OWNER_PASSWORD}@${PGHOST}:${PGPORT}/panels_product
REDIS_URL_IAM=redis://panels_iam_redis:${IAM_REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}
REDIS_URL_CORE=redis://panels_core_redis:${CORE_REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}
# Contabo -- rellenar endpoint/keys del Object Storage de ESTE ambiente
S3_ENDPOINT=${S3_ENDPOINT:-}
S3_BUCKET=${S3_BUCKET:-}
S3_REGION=${S3_REGION:-usc1}
S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY:-}
# Passwords sueltos (provision / compose local)
POSTGRES_SUPERUSER_PASSWORD=${PGPASSWORD:-}
PLATFORM_OWNER_PASSWORD=${PLATFORM_OWNER_PASSWORD}
PLATFORM_APP_PASSWORD=${PLATFORM_APP_PASSWORD}
IAM_OWNER_PASSWORD=${IAM_OWNER_PASSWORD}
IAM_APP_PASSWORD=${IAM_APP_PASSWORD}
CORE_OWNER_PASSWORD=${CORE_OWNER_PASSWORD}
CORE_APP_PASSWORD=${CORE_APP_PASSWORD}
IAM_REDIS_PASSWORD=${IAM_REDIS_PASSWORD}
CORE_REDIS_PASSWORD=${CORE_REDIS_PASSWORD}
EOF
}
if [[ "$APPLY" == "1" ]]; then
: "${PGPASSWORD:?PGPASSWORD del superusuario es obligatorio con --apply}"
SUPERUSER_URL="postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}:${PGPORT}/postgres"
echo "== Aplicando roles/bases/esquemas en ${PGHOST}:${PGPORT} =="
psql "$SUPERUSER_URL" \
-v platform_owner_pw="$PLATFORM_OWNER_PASSWORD" -v platform_app_pw="$PLATFORM_APP_PASSWORD" \
-v iam_owner_pw="$IAM_OWNER_PASSWORD" -v iam_app_pw="$IAM_APP_PASSWORD" \
-v core_owner_pw="$CORE_OWNER_PASSWORD" -v core_app_pw="$CORE_APP_PASSWORD" \
-f "$HERE/01-roles.sql"
psql "$SUPERUSER_URL" -f "$HERE/02-databases.sql"
psql "postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}:${PGPORT}/panels_platform" -f "$HERE/03-platform-database.sql"
psql "postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}:${PGPORT}/panels_product" -f "$HERE/04-product-database.sql"
if [[ -n "${REDIS_ADMIN_URL:-}" ]]; then
echo "== Aplicando ACLs Redis =="
REDIS_ADMIN_URL="$REDIS_ADMIN_URL" \
IAM_REDIS_PASSWORD="$IAM_REDIS_PASSWORD" \
CORE_REDIS_PASSWORD="$CORE_REDIS_PASSWORD" \
"$HERE/05-redis-acl.sh"
else
echo "SKIP - REDIS_ADMIN_URL no definido; no se crearon usuarios ACL"
fi
fi
ENV_TEXT="$(block)"
echo "$ENV_TEXT"
if [[ -n "$OUT" ]]; then
umask 077
printf '%s\n' "$ENV_TEXT" > "$OUT"
echo "Escrito $OUT (permisos 600). No lo subas a git." >&2
fi
if [[ "$VERIFY" == "1" ]]; then
echo "== Verificando conectividad ==" >&2
tmp="$(mktemp)"
umask 077
printf '%s\n' "$ENV_TEXT" > "$tmp"
set -a
# shellcheck disable=SC1090
source "$tmp"
set +a
rm -f "$tmp"
"$HERE/verify-connectivity.sh"
fi

View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
# PANELS · comprueba que ESTE ambiente responde: Postgres (6 roles),
# Redis (2 ACL) y Contabo (opcional; obligatorio si REQUIRE_S3=1).
#
# No crea recursos. Carga URLs desde el entorno (source .env.dev-local o
# las vars de Coolify).
#
# Uso:
# set -a && source .env.dev-local && set +a
# ./db/provision/verify-connectivity.sh
# REQUIRE_S3=1 ./db/provision/verify-connectivity.sh # staging/prod
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
fail=0
check() {
local desc="$1"; shift
if "$@" >/tmp/panels-conn-out.$$ 2>&1; then
echo "OK - $desc"
else
echo "FAIL - $desc"
cat /tmp/panels-conn-out.$$
fail=1
fi
rm -f /tmp/panels-conn-out.$$
}
need() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
echo "FAIL - falta $name"
fail=1
return 1
fi
return 0
}
echo "== Postgres =="
need DATABASE_URL_PLATFORM && check "platform_app SELECT 1" psql "$DATABASE_URL_PLATFORM" -c "SELECT 1;"
need DATABASE_URL_IAM && check "iam_app SELECT 1" psql "$DATABASE_URL_IAM" -c "SELECT 1;"
need DATABASE_URL_CORE && check "core_app SELECT 1" psql "$DATABASE_URL_CORE" -c "SELECT 1;"
if [[ -n "${DATABASE_URL_PLATFORM_OWNER:-}" ]]; then
check "platform_owner SELECT 1" psql "$DATABASE_URL_PLATFORM_OWNER" -c "SELECT 1;"
else
echo "SKIP - DATABASE_URL_PLATFORM_OWNER (solo hace falta para Liquibase)"
fi
if [[ -n "${DATABASE_URL_IAM_OWNER:-}" ]]; then
check "iam_owner SELECT 1" psql "$DATABASE_URL_IAM_OWNER" -c "SELECT 1;"
else
echo "SKIP - DATABASE_URL_IAM_OWNER"
fi
if [[ -n "${DATABASE_URL_CORE_OWNER:-}" ]]; then
check "core_owner SELECT 1" psql "$DATABASE_URL_CORE_OWNER" -c "SELECT 1;"
else
echo "SKIP - DATABASE_URL_CORE_OWNER"
fi
echo "== Redis =="
need REDIS_URL_IAM && check "iam redis PING" redis-cli -u "$REDIS_URL_IAM" PING
need REDIS_URL_CORE && check "core redis PING" redis-cli -u "$REDIS_URL_CORE" PING
echo "== Contabo (S3) =="
if command -v deno >/dev/null 2>&1; then
if (cd "$ROOT/api" && REQUIRE_S3="${REQUIRE_S3:-}" deno run --allow-net --allow-env --allow-read --allow-write scripts/verify-storage.ts); then
:
else
fail=1
fi
else
echo "FAIL - deno no está en PATH; no se pudo probar el bucket"
fail=1
fi
echo ""
if [[ "$fail" == "0" ]]; then
echo "Conectividad de este ambiente: OK"
else
echo "Hay fallos de conectividad -- no desplegar la API contra este ambiente todavía."
exit 1
fi

View file

@ -81,8 +81,8 @@ En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrde
## Paso a paso en Coolify ## Paso a paso en Coolify
1. **Provisionar Postgres y Redis** como recursos gestionados de Coolify para el ambiente (uno de cada, no por módulo). 1. **Provisionar Postgres, Redis y bucket** — 1 de cada **por ambiente** (no por módulo; no compartir prod con staging).
2. **Aprovisionar roles/esquemas/ACLs**: correr los scripts de [`db/provision/`](../db/provision/README.md) contra ese Postgres/Redis (una vez, desde tu máquina o un job manual -- Coolify no lo hace por ti). 2. **Crear accesos y validar**: `./db/provision/create-accesses.sh --apply --verify --out .env.<env>.local` contra esos hosts (ver [`db/provision/README.md`](../db/provision/README.md)). Coolify no crea los roles `panels_*` ni los ACL de Redis solo.
3. **Aplicar Liquibase** (paso explícito, NO ocurre al arrancar la app): `./db/update.sh all --context-filter='!dev'` con las credenciales `_owner`. En staging/producción, **nunca** olvidar el `--context-filter` -- sin él, Liquibase corre TAMBIÉN los changesets de demo (`context=dev`). 3. **Aplicar Liquibase** (paso explícito, NO ocurre al arrancar la app): `./db/update.sh all --context-filter='!dev'` con las credenciales `_owner`. En staging/producción, **nunca** olvidar el `--context-filter` -- sin él, Liquibase corre TAMBIÉN los changesets de demo (`context=dev`).
4. **Bootstrap del primer admin**: `deno run ... api/scripts/bootstrap-admin.ts platform` y `... tenant --tenant-id=... --company-code=...` (ver `db/README.md`). 4. **Bootstrap del primer admin**: `deno run ... api/scripts/bootstrap-admin.ts platform` y `... tenant --tenant-id=... --company-code=...` (ver `db/README.md`).
5. **Push** este repo (sin `.env` ni `data/`). 5. **Push** este repo (sin `.env` ni `data/`).
@ -91,7 +91,7 @@ En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrde
8. **Dominios:** `web-panel` → panels; `web-saas` → saas; `api` sin FQDN (proxy `/v1`). 8. **Dominios:** `web-panel` → panels; `web-saas` → saas; `api` sin FQDN (proxy `/v1`).
9. Cargar en Coolify todas las variables **obligatorias** de la tabla de arriba + `COOKIE_SECURE=true`. 9. Cargar en Coolify todas las variables **obligatorias** de la tabla de arriba + `COOKIE_SECURE=true`.
10. Deploy. 10. Deploy.
11. Verificar `https://app…/v1/health` → debe responder `{"ok":true,"core":true,"platform":true,"redis":{"iam":true,"core":true}}`. Si algo es `false`, la app ni siquiera debería haber arrancado (fail-fast, Fase 7). 11. Verificar `https://app…/v1/health` → `ok`, `core`, `platform`, `redis.iam`, `redis.core` y `storage` (en prod `backend:"s3"`). Si algo falla, la API no arranca.
12. Login SaaS `admin` / tu `SEED_PASSWORD`. 12. Login SaaS `admin` / tu `SEED_PASSWORD`.
13. SMTP en `/smtp` o por `SMTP_*`. 13. SMTP en `/smtp` o por `SMTP_*`.