Compare commits
2 Commits
master
...
b2c9be72c3
| Author | SHA1 | Date | |
|---|---|---|---|
| b2c9be72c3 | |||
| bf0294a188 |
@@ -27,3 +27,7 @@ PIREON_PASSWORD_GRANT=true
|
||||
AUTH_SESSION_TTL_SECONDS=60
|
||||
|
||||
GRAPHQL_PLAYGROUND=true
|
||||
|
||||
# Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image).
|
||||
# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
|
||||
NEXUS_TOKEN=
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
# docker compose up -d --build → api + web + infrastructure
|
||||
#
|
||||
# @pi/ui comes from Pireon's private Nexus. Its token is passed as a build
|
||||
# secret (compose: `secrets.npmrc` → ~/.npmrc) and never lands in a layer.
|
||||
# secret (compose: `secrets.nexus_token` ← $NEXUS_TOKEN) and never lands in a
|
||||
# layer: it is written to a temporary npmrc that the same RUN deletes.
|
||||
|
||||
# ---- Workspace dependencies (shared by both builds) --------------------
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /workspace
|
||||
ENV NX_DAEMON=false NX_NO_CLOUD=true NX_SKIP_NX_CACHE=true
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --no-audit --no-fund
|
||||
RUN --mount=type=secret,id=nexus_token,required=true \
|
||||
printf '//nexus.pireon.dev/repository/npm-releases/:_authToken=%s\n' "$(cat /run/secrets/nexus_token)" > /tmp/auth.npmrc \
|
||||
&& npm ci --no-audit --no-fund --userconfig /tmp/auth.npmrc \
|
||||
&& rm -f /tmp/auth.npmrc
|
||||
|
||||
# ---- API: Nest bundle + pruned package.json / lockfile -----------------
|
||||
FROM deps AS build-api
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
// TEMPORARY (UI work): API on :3001 with fake Pireon users and seeded data. Not part of the repo.
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
IdentityProvider,
|
||||
InvalidAccessTokenError,
|
||||
InvalidCredentialsError,
|
||||
} from '@task-stream/api/identity/application';
|
||||
import { User, UserFactory } from '@task-stream/api/identity/domain';
|
||||
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
|
||||
|
||||
process.env['POSTGRES_URL'] ??= 'postgres://taskstream:taskstream@localhost:5432/taskstream';
|
||||
process.env['POSTGRES_SYNCHRONIZE'] = 'true';
|
||||
process.env['MONGO_URL'] = `mongodb://localhost:27017/taskstream_ui_${Date.now()}`;
|
||||
process.env['REDIS_URL'] ??= 'redis://localhost:6379';
|
||||
process.env['EVENT_STREAM_KEY'] = `task-stream:ui:${Date.now()}`;
|
||||
process.env['GRAPHQL_SCHEMA_FILE'] = '';
|
||||
process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
|
||||
|
||||
const { AppModule } = require('../src/app/app.module');
|
||||
|
||||
const users = new UserFactory();
|
||||
const person = (id: string, name: string, email: string) => users.create({ id, name, email, isActive: true });
|
||||
const people: Record<string, User> = {
|
||||
'token-ana': person('101', 'Ana García', 'ana@pireon.dev'),
|
||||
'token-bob': person('102', 'Bob Martín', 'bob@pireon.dev'),
|
||||
'token-qa': person('103', 'Quinn Ruiz', 'quinn@pireon.dev'),
|
||||
'token-dan': person('104', 'Dan Ortega', 'dan@pireon.dev'),
|
||||
};
|
||||
const byId = (id: string) => Object.values(people).find((p) => p.id === id) ?? null;
|
||||
|
||||
const PATH: Record<string, string[]> = {
|
||||
BACKLOG: [], TODO: ['TODO'], IN_PROGRESS: ['TODO', 'IN_PROGRESS'],
|
||||
IN_REVIEW: ['TODO', 'IN_PROGRESS', 'IN_REVIEW'],
|
||||
READY_FOR_QA: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA'],
|
||||
IN_QA: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA', 'IN_QA'],
|
||||
QA_FAILED: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA', 'IN_QA', 'QA_FAILED'],
|
||||
DONE: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'], CANCELLED: ['CANCELLED'],
|
||||
};
|
||||
|
||||
describe('ui dev server', () => {
|
||||
it('serves until stopped', async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(IdentityProvider)
|
||||
.useValue({
|
||||
whoAmI: async (token: string) => { const u = people[token]; if (!u) throw new InvalidAccessTokenError(); return u; },
|
||||
findUser: async (id: string) => byId(id),
|
||||
listUsers: async () => Object.values(people),
|
||||
// Email + any password "pireon" → that user's token.
|
||||
issueToken: async (grant: { type: string; username?: string; password?: string }) => {
|
||||
const entry = Object.entries(people).find(([, u]) => u.email === grant.username);
|
||||
if (grant.type !== 'password' || !entry || grant.password !== 'pireon') throw new InvalidCredentialsError('Email o contraseña incorrectos');
|
||||
return { accessToken: entry[0], refreshToken: null, expiresIn: 86400, tokenType: 'Bearer' };
|
||||
},
|
||||
authorizationConfig: () => ({ authorizeUrl: 'https://oauth.pireon.dev/oauth/authorize', clientId: 'ui-dev', scopes: ['users:read'], passwordGrant: true }),
|
||||
})
|
||||
.compile();
|
||||
const app = moduleRef.createNestApplication({ rawBody: true });
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new DomainErrorFilter());
|
||||
await app.listen(3001);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const gql = async (token: string, query: string, variables?: object): Promise<any> => {
|
||||
const r = await fetch('http://localhost:3001/graphql', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ query, variables }) });
|
||||
const body = await r.json();
|
||||
if (body.errors) throw new Error(JSON.stringify(body.errors));
|
||||
return body.data;
|
||||
};
|
||||
const ana = 'token-ana';
|
||||
const project = async (key: string, name: string, description: string) => (await gql(ana, `mutation($i: CreateProjectInput!) { createProject(input: $i) { id } }`, { i: { key, name, description } })).createProject.id as string;
|
||||
const member = (projectId: string, userId: string, role: string) => gql(ana, `mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`, { i: { projectId, userId, role } });
|
||||
const release = async (projectId: string, name: string, targetDate: string, description: string) => (await gql(ana, `mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`, { i: { projectId, name, targetDate, description } })).createRelease.id as string;
|
||||
const move = async (issueId: string, status: string, reason?: string) => {
|
||||
for (const step of PATH[status]) await gql(ana, `mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`, { i: { issueId, status: step, reason: step === 'QA_FAILED' || step === 'CANCELLED' ? reason : undefined } });
|
||||
};
|
||||
const issue = async (projectId: string, i: Record<string, unknown>, status = 'BACKLOG', reason?: string) => {
|
||||
const id = (await gql(ana, `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id } }`, { i: { projectId, ...i } })).createIssue.id as string;
|
||||
await move(id, status, reason);
|
||||
return id;
|
||||
};
|
||||
const comment = (token: string, issueId: string, body: string) => gql(token, `mutation($i: ID!, $b: String!) { addComment(issueId: $i, body: $b) { id } }`, { i: issueId, b: body });
|
||||
const link = (issueId: string, targetIssueId: string, type: string) => gql(ana, `mutation($i: LinkIssuesInput!) { linkIssues(input: $i) { version } }`, { i: { issueId, targetIssueId, type } });
|
||||
|
||||
const web = await project('WEB', 'Checkout revamp', 'Rediseño del checkout: pago en un paso, cupones y QA en cada release.');
|
||||
for (const [id, role] of [['102', 'MEMBER'], ['103', 'MEMBER'], ['104', 'ADMIN']]) await member(web, id, role);
|
||||
const v132 = await release(web, 'v1.3.2', '2026-09-26', 'Tarjetas guardadas y webhooks v2.');
|
||||
const v140 = await release(web, 'v1.4.0', '2026-10-17', 'Checkout en un paso.');
|
||||
await release(web, 'v1.5.0', '2026-11-14', 'Nuevos métodos de pago.');
|
||||
const pay = await issue(web, { type: 'BUG', priority: 'CRITICAL', area: 'FRONTEND', assigneeId: '102', releaseId: v140, labels: ['safari', 'checkout'], title: 'El botón de pagar no responde en Safari 17', description: '1. Abrir el checkout en Safari 17\n2. Rellenar la tarjeta\n3. Pulsar «Pagar»: no pasa nada y no hay error en consola.' }, 'IN_PROGRESS');
|
||||
const cart = await issue(web, { type: 'FEATURE', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', releaseId: v140, labels: ['carrito'], title: 'Guardar el carrito entre dispositivos', description: 'El carrito se asocia al usuario y se fusiona al iniciar sesión.' }, 'IN_REVIEW');
|
||||
const summary = await issue(web, { type: 'UI_CHANGE', priority: 'MEDIUM', area: 'DESIGN', assigneeId: '102', releaseId: v140, figmaUrl: 'https://www.figma.com/design/abc123/Checkout?node-id=12-345', title: 'Nuevo resumen del pedido según Figma' }, 'READY_FOR_QA');
|
||||
const regression = await issue(web, { type: 'QA', priority: 'HIGH', area: 'QA', assigneeId: '103', releaseId: v140, title: 'Regresión del checkout en iOS y Android' }, 'IN_QA');
|
||||
const coupon = await issue(web, { type: 'BUG', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', releaseId: v140, labels: ['cupones'], title: 'El cupón se aplica dos veces si se recarga la página' }, 'QA_FAILED', 'Sigue duplicándose si se recarga durante el pago.');
|
||||
await issue(web, { type: 'TASK', priority: 'LOW', area: 'DEVOPS', assigneeId: '104', title: 'Alertas de latencia del servicio de pagos' }, 'TODO');
|
||||
await issue(web, { type: 'FEATURE', priority: 'MEDIUM', area: 'FRONTEND', title: 'Pago con Bizum' });
|
||||
await issue(web, { type: 'BUG', priority: 'LOW', area: 'FRONTEND', assigneeId: '102', title: 'El foco se pierde al cerrar el modal de dirección' }, 'TODO');
|
||||
await issue(web, { type: 'UI_CHANGE', priority: 'LOW', area: 'DESIGN', title: 'Estados vacíos del historial de pedidos' });
|
||||
await issue(web, { type: 'TASK', priority: 'MEDIUM', area: 'BACKEND', assigneeId: '104', releaseId: v132, title: 'Migrar webhooks de Stripe a la v2' }, 'DONE');
|
||||
await issue(web, { type: 'FEATURE', priority: 'HIGH', area: 'FRONTEND', assigneeId: '102', releaseId: v132, title: 'Guardar tarjeta para próximas compras' }, 'DONE');
|
||||
await issue(web, { type: 'QA', priority: 'MEDIUM', area: 'QA', assigneeId: '103', title: 'Pruebas exploratorias de devoluciones' }, 'TODO');
|
||||
await issue(web, { type: 'BUG', priority: 'MEDIUM', area: 'MOBILE', title: 'La app se cierra al girar en el paso de envío' });
|
||||
await issue(web, { type: 'TASK', priority: 'LOWEST', area: 'DEVOPS', title: 'Limpiar feature flags del checkout antiguo' }, 'CANCELLED', 'Se hará con la limpieza general de flags.');
|
||||
await gql(ana, `mutation($r: ID!) { publishRelease(releaseId: $r) { version } }`, { r: v132 });
|
||||
await link(regression, summary, 'TESTS');
|
||||
await link(summary, cart, 'DEPENDS_ON');
|
||||
await link(coupon, regression, 'BLOCKS');
|
||||
await comment('token-bob', pay, 'Reproducido: el `onClick` llega, pero el iframe de pago no termina de cargar en Safari.');
|
||||
await comment('token-qa', pay, 'En Safari 16 funciona. Adjunto vídeo en el ticket de regresión.');
|
||||
await comment(ana, pay, 'Decisión: usar redirect en lugar de popup para el 3-D Secure.');
|
||||
const api = await project('API', 'Plataforma API', 'Servicios compartidos: identidad, pagos y notificaciones.');
|
||||
await member(api, '104', 'ADMIN');
|
||||
await issue(api, { type: 'FEATURE', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', title: 'Rate limiting por cliente' }, 'IN_PROGRESS');
|
||||
await issue(api, { type: 'BUG', priority: 'MEDIUM', area: 'BACKEND', title: 'Timeout en /payments/refund con importes altos' }, 'TODO');
|
||||
const mob = await project('MOB', 'App móvil', 'iOS y Android: compra rápida y notificaciones.');
|
||||
await member(mob, '102', 'MEMBER');
|
||||
await issue(mob, { type: 'UI_CHANGE', priority: 'MEDIUM', area: 'MOBILE', title: 'Nueva navegación inferior' }, 'IN_REVIEW');
|
||||
|
||||
console.log('UI DEV API READY on :3001');
|
||||
await new Promise(() => undefined);
|
||||
}, 24 * 60 * 60 * 1000);
|
||||
});
|
||||
14
docker-compose.override.yml
Normal file
14
docker-compose.override.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
# Development only: published ports, so `npx nx serve api` / `npx nx serve web` and your tools
|
||||
# can reach the containers. Compose loads this file automatically with docker-compose.yml;
|
||||
# production (Dokploy) runs `-f docker-compose.yml` alone and publishes nothing.
|
||||
services:
|
||||
web:
|
||||
ports: ['8080:80']
|
||||
api:
|
||||
ports: ['3000:3000']
|
||||
postgres:
|
||||
ports: ['5432:5432']
|
||||
mongo:
|
||||
ports: ['27017:27017']
|
||||
redis:
|
||||
ports: ['6379:6379']
|
||||
@@ -1,16 +1,21 @@
|
||||
# TaskStream: web + API + infrastructure.
|
||||
# TaskStream: web + API + infrastructure — production definition (Dokploy deploys this file).
|
||||
# No ports are published here: services only talk over the compose network, and the web is
|
||||
# exposed through the platform's proxy (Dokploy domain → service `web`, port 80).
|
||||
#
|
||||
# Locally, `docker compose` also loads docker-compose.override.yml, which publishes the ports:
|
||||
# docker compose up -d --build → everything: web on http://localhost:8080
|
||||
# docker compose up -d postgres mongo redis → only infrastructure (then `npx nx serve api` / `npx nx serve web`)
|
||||
#
|
||||
# Building needs access to Pireon's Nexus (@pi/ui): the token is read from ~/.npmrc as a build secret.
|
||||
# Building needs access to Pireon's Nexus (@pi/ui): set NEXUS_TOKEN in the environment or in .env
|
||||
# (Dokploy: Environment tab). It reaches the build as a secret and never lands in an image layer.
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
target: web
|
||||
secrets: [npmrc]
|
||||
secrets: [nexus_token]
|
||||
image: task-stream-web
|
||||
ports: ['8080:80']
|
||||
expose: ['80']
|
||||
depends_on:
|
||||
api: { condition: service_healthy }
|
||||
restart: unless-stopped
|
||||
@@ -19,9 +24,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
target: api
|
||||
secrets: [npmrc]
|
||||
secrets: [nexus_token]
|
||||
image: task-stream-api
|
||||
ports: ['3000:3000']
|
||||
expose: ['3000']
|
||||
environment:
|
||||
POSTGRES_URL: postgres://taskstream:taskstream@postgres:5432/taskstream
|
||||
# No migrations yet: TypeORM creates the event store and lookup tables.
|
||||
@@ -49,7 +54,6 @@ services:
|
||||
POSTGRES_USER: taskstream
|
||||
POSTGRES_PASSWORD: taskstream
|
||||
POSTGRES_DB: taskstream
|
||||
ports: ['5432:5432']
|
||||
volumes: [postgres-data:/var/lib/postgresql/data]
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U taskstream']
|
||||
@@ -58,7 +62,6 @@ services:
|
||||
|
||||
mongo:
|
||||
image: mongo:8
|
||||
ports: ['27017:27017']
|
||||
volumes: [mongo-data:/data/db]
|
||||
healthcheck:
|
||||
test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping")']
|
||||
@@ -68,7 +71,6 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: ['redis-server', '--appendonly', 'yes']
|
||||
ports: ['6379:6379']
|
||||
volumes: [redis-data:/data]
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
@@ -81,5 +83,5 @@ volumes:
|
||||
redis-data:
|
||||
|
||||
secrets:
|
||||
npmrc:
|
||||
file: ${HOME}/.npmrc
|
||||
nexus_token:
|
||||
environment: NEXUS_TOKEN
|
||||
|
||||
@@ -86,7 +86,8 @@ exporta facades, una página tampoco puede tocar un store ni despachar eventos p
|
||||
| Síntoma | Causa y solución |
|
||||
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `npm install` falla con `Cannot read properties of null (reading 'edgesOut')` | Bug de npm con un peer opcional de Vite que arrastra vitest 5. El `overrides` de `package.json` fija vitest; no lo quites |
|
||||
| `401` al instalar `@pi/ui` | Falta el token del Nexus en `~/.npmrc` |
|
||||
| `401` al instalar `@pi/ui` | Falta el token del Nexus en `~/.npmrc` (o `NEXUS_TOKEN` al construir con Docker) |
|
||||
| `failed to stat /root/.npmrc` al desplegar | Versión antigua del `docker-compose.yml`: ahora el token llega por `NEXUS_TOKEN` (ver [Despliegue](07-despliegue.md)) |
|
||||
| `nx serve web` arranca en el puerto 3000 | Nx carga el `.env` raíz (`PORT=3000`) en todas las tareas; `apps/web/.env` lo fija en 4200 |
|
||||
| La API no ve un evento nuevo al deserializar | La clase del evento no tiene `@AutowiredEvent` o no se importa |
|
||||
| Un componente de `@pi/ui` lanza `Unable to find icon` | Falta registrar ese icono en `libs/web/shared/ui/src/lib/icons.ts` |
|
||||
|
||||
@@ -9,28 +9,53 @@ docker compose logs -f api
|
||||
docker compose down # parar (-v borra los datos)
|
||||
```
|
||||
|
||||
| Servicio | Imagen | Puerto | Función |
|
||||
| ---------- | --------------------------- | ------ | ----------------------------------------------------------------------- |
|
||||
| Servicio | Imagen | Puerto en local | Función |
|
||||
| ---------- | --------------------------- | --------------- | ----------------------------------------------------------------------- |
|
||||
| `web` | `task-stream-web` (nginx) | 8080 | SPA y proxy de `/api` y `/graphql` (incluido el WebSocket) hacia la API |
|
||||
| `api` | `task-stream-api` (Node 22) | 3000 | API NestJS |
|
||||
| `postgres` | postgres:17-alpine | 5432 | Event store y lookups |
|
||||
| `mongo` | mongo:8 | 27017 | Read model |
|
||||
| `redis` | redis:7-alpine (AOF) | 6379 | Stream de eventos, pub/sub y caché de sesiones |
|
||||
|
||||
### Producción y desarrollo
|
||||
|
||||
- **`docker-compose.yml`** es la definición de producción (la que despliega Dokploy) y **no publica puertos**: los
|
||||
servicios solo se ven entre sí por la red de Compose. Hacia fuera solo sale la web, por el proxy de la plataforma
|
||||
(en Dokploy: dominio → servicio `web`, puerto `80`). `web` y `api` declaran `expose` para documentarlo.
|
||||
- **`docker-compose.override.yml`** publica los puertos de la tabla. Compose lo carga solo cuando se ejecuta sin `-f`,
|
||||
es decir, en local; Dokploy usa `-f docker-compose.yml` y no lo lee.
|
||||
|
||||
Para probar en local exactamente lo de producción: `docker compose -f docker-compose.yml up -d --build`.
|
||||
|
||||
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos.
|
||||
|
||||
### Imágenes
|
||||
|
||||
El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias:
|
||||
|
||||
- **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token se pasa como **build secret**
|
||||
(`secrets.npmrc` → `~/.npmrc` en `docker-compose.yml`) y nunca queda en una capa.
|
||||
- **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token llega como **build secret**
|
||||
desde la variable `NEXUS_TOKEN` (`secrets.nexus_token` en `docker-compose.yml`), se escribe en un `npmrc` temporal y
|
||||
se borra en el mismo paso, así que nunca queda en una capa. Sin `NEXUS_TOKEN` la build falla en ese paso.
|
||||
- **`api`**: `nx run api:prune` genera el bundle con un `package.json` y un lockfile podados a partir de
|
||||
`apps/api/package.json`. La imagen final solo instala esas dependencias (sin Angular ni paquetes privados).
|
||||
Si la API empieza a usar un paquete nuevo **en tiempo de ejecución**, hay que añadirlo a `apps/api/package.json`.
|
||||
- **`web`**: build de producción de Angular servido por nginx (`apps/web/nginx.conf`): fallback de rutas a
|
||||
`index.html`, caché larga para los bundles con hash y sin caché para `index.html`.
|
||||
|
||||
### Construir
|
||||
|
||||
`NEXUS_TOKEN` tiene que estar en el entorno de quien construye, o en el `.env` junto al `docker-compose.yml`:
|
||||
|
||||
```sh
|
||||
# Local: reutiliza el token de tu ~/.npmrc
|
||||
export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
En **Dokploy** (despliegue con Docker Compose): pestaña _Environment_ del servicio → `NEXUS_TOKEN=<token>`, junto a
|
||||
`PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET` y `PIREON_WEBHOOK_SECRET`. Dokploy los escribe en el `.env` del proyecto, de
|
||||
donde los lee Compose.
|
||||
|
||||
## 7.2 Variables de entorno de la API
|
||||
|
||||
| Variable | Por defecto | Descripción |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user