fix(docker): read the Nexus token from NEXUS_TOKEN instead of ~/.npmrc

Dokploy builds failed with "failed to stat /root/.npmrc": the compose build
secret pointed at ${HOME}/.npmrc, which only exists on a developer machine.
The token now comes from the NEXUS_TOKEN environment variable (Dokploy's
Environment tab or .env), is written to a temporary npmrc and removed in the
same RUN, so it never lands in an image layer.

Also removes a temporary UI dev-server spec that was committed by mistake,
and documents the variable (.env.example, docs 06/07, Word/PDF export).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 14:11:01 +02:00
parent 566f415fa7
commit bf0294a188
8 changed files with 35 additions and 133 deletions

View File

@@ -27,3 +27,7 @@ PIREON_PASSWORD_GRANT=true
AUTH_SESSION_TTL_SECONDS=60 AUTH_SESSION_TTL_SECONDS=60
GRAPHQL_PLAYGROUND=true 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=

View File

@@ -3,14 +3,18 @@
# docker compose up -d --build → api + web + infrastructure # docker compose up -d --build → api + web + infrastructure
# #
# @pi/ui comes from Pireon's private Nexus. Its token is passed as a build # @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) -------------------- # ---- Workspace dependencies (shared by both builds) --------------------
FROM node:22-alpine AS deps FROM node:22-alpine AS deps
WORKDIR /workspace WORKDIR /workspace
ENV NX_DAEMON=false NX_NO_CLOUD=true NX_SKIP_NX_CACHE=true ENV NX_DAEMON=false NX_NO_CLOUD=true NX_SKIP_NX_CACHE=true
COPY package.json package-lock.json .npmrc ./ 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 ----------------- # ---- API: Nest bundle + pruned package.json / lockfile -----------------
FROM deps AS build-api FROM deps AS build-api

View File

@@ -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);
});

View File

@@ -2,13 +2,14 @@
# docker compose up -d --build → everything: web on http://localhost:8080 # 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`) # 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: services:
web: web:
build: build:
context: . context: .
target: web target: web
secrets: [npmrc] secrets: [nexus_token]
image: task-stream-web image: task-stream-web
ports: ['8080:80'] ports: ['8080:80']
depends_on: depends_on:
@@ -19,7 +20,7 @@ services:
build: build:
context: . context: .
target: api target: api
secrets: [npmrc] secrets: [nexus_token]
image: task-stream-api image: task-stream-api
ports: ['3000:3000'] ports: ['3000:3000']
environment: environment:
@@ -81,5 +82,5 @@ volumes:
redis-data: redis-data:
secrets: secrets:
npmrc: nexus_token:
file: ${HOME}/.npmrc environment: NEXUS_TOKEN

View File

@@ -86,7 +86,8 @@ exporta facades, una página tampoco puede tocar un store ni despachar eventos p
| Síntoma | Causa y solución | | 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 | | `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 | | `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 | | 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` | | Un componente de `@pi/ui` lanza `Unable to find icon` | Falta registrar ese icono en `libs/web/shared/ui/src/lib/icons.ts` |

View File

@@ -23,14 +23,29 @@ Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tre
El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias: 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** - **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token llega como **build secret**
(`secrets.npmrc` → `~/.npmrc` en `docker-compose.yml`) y nunca queda en una capa. 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 - **`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). `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`. 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 - **`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`. `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 ## 7.2 Variables de entorno de la API
| Variable | Por defecto | Descripción | | Variable | Por defecto | Descripción |

Binary file not shown.

Binary file not shown.