Compare commits

..

2 commits

Author SHA1 Message Date
4d9fb518b1 feat(deploy): wire prod stack for stark and add bridge healthcheck
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
E2E Playwright / Playwright e2e (chromium) (push) Waiting to run
- compose.yml: expose BASEROW_USER_EMAIL/PASSWORD, DOCMOST_APP_SECRET,
  DOCMOST_JWT_ISSUER on bridge service (required for user JWT + admin)
- compose.prod.yml: switch to acadedoc:${ACADEDOC_VERSION} image with
  pull_policy=never, point Traefik routers at *.stark.a3n.fr, attach to
  admin_proxy external network, add bridge service block with dual router
  (public bridge.stark.a3n.fr + same-origin /bridge prefix on doc), SMTP
  env vars, CPU caps and reservations on all services, healthchecks on
  baserow / redis / bridge
2026-05-12 06:21:28 +00:00
1644a72ccc fix(bridge): wire admin client and correct Baserow JWT URLs
- Mount /api/v1/admin routes in app builder
- Instantiate BaserowAdminClient and inject it into the container
- Add BASEROW_USER_AUTH_NOT_CONFIGURED error code
- Fix duplicate /api prefix in token-auth and token-refresh URLs
2026-05-12 06:20:49 +00:00
6 changed files with 103 additions and 6 deletions

View file

@ -18,6 +18,7 @@ import { type AuthVariables, authMiddleware } from './middleware/auth.js';
import { errorHandler } from './middleware/error-handler.js'; import { errorHandler } from './middleware/error-handler.js';
import { defaultRateLimitKey, rateLimit } from './middleware/rate-limit.js'; import { defaultRateLimitKey, rateLimit } from './middleware/rate-limit.js';
import { eventsRoutes } from './routes/events.js'; import { eventsRoutes } from './routes/events.js';
import { adminRoutes } from './routes/admin.js';
import { tablesRoutes } from './routes/tables.js'; import { tablesRoutes } from './routes/tables.js';
import { viewsRoutes } from './routes/views.js'; import { viewsRoutes } from './routes/views.js';
import { webhooksRoutes } from './routes/webhooks.js'; import { webhooksRoutes } from './routes/webhooks.js';
@ -83,6 +84,8 @@ export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
v1.route('/tables', tablesRoutes); v1.route('/tables', tablesRoutes);
// R3.1.a : routes views — liste vues par table + donnees paginées d'une vue. // R3.1.a : routes views — liste vues par table + donnees paginées d'une vue.
v1.route('/views', viewsRoutes); v1.route('/views', viewsRoutes);
// Phase B (acadenice) : CRUD tables/fields/views via Baserow user JWT.
v1.route('/admin', adminRoutes);
app.route('/api/v1', v1); app.route('/api/v1', v1);
// R3.1.b : SSE realtime stream — monté sur /api/events hors /api/v1 pour // R3.1.b : SSE realtime stream — monté sur /api/events hors /api/v1 pour

View file

@ -173,7 +173,7 @@ export class BaserowJwtManagerImpl implements BaserowJwtManager {
private async doLogin(): Promise<string> { private async doLogin(): Promise<string> {
this.logger.debug({ email: this.email }, 'baserow jwt login'); this.logger.debug({ email: this.email }, 'baserow jwt login');
const url = `${this.baseUrl}/api/user/token-auth/`; const url = `${this.baseUrl}/user/token-auth/`;
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -202,7 +202,7 @@ export class BaserowJwtManagerImpl implements BaserowJwtManager {
private async doRefresh(currentToken: string): Promise<string> { private async doRefresh(currentToken: string): Promise<string> {
this.logger.debug('baserow jwt refresh'); this.logger.debug('baserow jwt refresh');
const url = `${this.baseUrl}/api/user/token-refresh/`; const url = `${this.baseUrl}/user/token-refresh/`;
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View file

@ -9,6 +9,7 @@
*/ */
import type { Logger } from 'pino'; import type { Logger } from 'pino';
import { BaserowAdminClient } from '../adapters/baserow-admin-client.js';
import { BaserowClient } from '../adapters/baserow-client.js'; import { BaserowClient } from '../adapters/baserow-client.js';
import { RedisCache } from '../adapters/redis-cache.js'; import { RedisCache } from '../adapters/redis-cache.js';
import type { ApiTokenRecord } from '../middleware/auth.js'; import type { ApiTokenRecord } from '../middleware/auth.js';
@ -47,6 +48,9 @@ export interface Container {
logger: Logger; logger: Logger;
/** Gestionnaire JWT user Baserow pour endpoints metadata. Toujours present. */ /** Gestionnaire JWT user Baserow pour endpoints metadata. Toujours present. */
baserowJwt: BaserowJwtManager; baserowJwt: BaserowJwtManager;
/** Client admin Baserow (CRUD tables/fields/views). Lève
* BASEROW_USER_AUTH_NOT_CONFIGURED si appelé sans creds user. */
baserowAdmin: BaserowAdminClient;
} }
let _container: Container | null = null; let _container: Container | null = null;
@ -146,6 +150,12 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
); );
} }
const baserowAdmin = new BaserowAdminClient({
baseUrl: config.baserowApiUrl,
jwtManager: baserowJwt,
logger: rootLogger,
});
const container: Container = { const container: Container = {
config, config,
baserow, baserow,
@ -157,6 +167,7 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
groupsScopesMap, groupsScopesMap,
logger: rootLogger, logger: rootLogger,
baserowJwt, baserowJwt,
baserowAdmin,
}; };
setContainer(container); setContainer(container);
return container; return container;

View file

@ -14,6 +14,7 @@ export type ErrorCode =
| 'CONFLICT' | 'CONFLICT'
| 'RATE_LIMITED' | 'RATE_LIMITED'
| 'BASEROW_UNAVAILABLE' | 'BASEROW_UNAVAILABLE'
| 'BASEROW_USER_AUTH_NOT_CONFIGURED'
| 'DOCMOST_UNAVAILABLE' | 'DOCMOST_UNAVAILABLE'
| 'INTERNAL'; | 'INTERNAL';
@ -57,6 +58,12 @@ export const errors = {
rateLimited: (retryAfter: number) => rateLimited: (retryAfter: number) =>
new BridgeError('RATE_LIMITED', 429, 'Too many requests', { retry_after: retryAfter }), new BridgeError('RATE_LIMITED', 429, 'Too many requests', { retry_after: retryAfter }),
baserowDown: () => new BridgeError('BASEROW_UNAVAILABLE', 502, 'Baserow API unreachable'), baserowDown: () => new BridgeError('BASEROW_UNAVAILABLE', 502, 'Baserow API unreachable'),
baserowUserAuthNotConfigured: () =>
new BridgeError(
'BASEROW_USER_AUTH_NOT_CONFIGURED',
503,
'Baserow user JWT not configured (BASEROW_USER_EMAIL/PASSWORD missing)',
),
docmostDown: () => new BridgeError('DOCMOST_UNAVAILABLE', 502, 'Docmost API unreachable'), docmostDown: () => new BridgeError('DOCMOST_UNAVAILABLE', 502, 'Docmost API unreachable'),
internal: (message: string) => new BridgeError('INTERNAL', 500, message), internal: (message: string) => new BridgeError('INTERNAL', 500, message),
}; };

View file

@ -1,15 +1,27 @@
# compose.prod.yml — overrides pour env production # compose.prod.yml — overrides pour env production
# Usage : docker compose -f compose.yml -f compose.prod.yml up -d # Usage : docker compose -f compose.yml -f compose.prod.yml up -d
# Reseau externe : admin_proxy (Traefik)
services: services:
docmost: docmost:
image: acadedoc:${ACADEDOC_VERSION:-local}
pull_policy: never
restart: always restart: always
environment: environment:
APP_URL: ${DOCMOST_URL:?DOCMOST_URL requis sur prod} APP_URL: ${DOCMOST_URL:?DOCMOST_URL requis sur prod}
LOG_LEVEL: warn LOG_LEVEL: warn
MAIL_DRIVER: smtp
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_SECURE: "false"
MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS}
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.docmost-prod.rule=Host(`wiki.acadenice.fr`)" - "traefik.docker.network=admin_proxy"
- "traefik.http.routers.docmost-prod.rule=Host(`doc.stark.a3n.fr`)"
- "traefik.http.routers.docmost-prod.entrypoints=websecure" - "traefik.http.routers.docmost-prod.entrypoints=websecure"
- "traefik.http.routers.docmost-prod.tls.certresolver=letsencrypt" - "traefik.http.routers.docmost-prod.tls.certresolver=letsencrypt"
- "traefik.http.services.docmost-prod.loadbalancer.server.port=3000" - "traefik.http.services.docmost-prod.loadbalancer.server.port=3000"
@ -18,10 +30,11 @@ services:
resources: resources:
limits: limits:
memory: 2G memory: 2G
cpus: "1.5"
reservations: reservations:
memory: 512M memory: 512M
healthcheck: healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"] test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
@ -31,9 +44,11 @@ services:
restart: always restart: always
environment: environment:
BASEROW_PUBLIC_URL: ${BASEROW_URL:?BASEROW_URL requis sur prod} BASEROW_PUBLIC_URL: ${BASEROW_URL:?BASEROW_URL requis sur prod}
BASEROW_EXTRA_ALLOWED_HOSTS: baserow
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.baserow-prod.rule=Host(`baserow.acadenice.fr`)" - "traefik.docker.network=admin_proxy"
- "traefik.http.routers.baserow-prod.rule=Host(`baserow.stark.a3n.fr`)"
- "traefik.http.routers.baserow-prod.entrypoints=websecure" - "traefik.http.routers.baserow-prod.entrypoints=websecure"
- "traefik.http.routers.baserow-prod.tls.certresolver=letsencrypt" - "traefik.http.routers.baserow-prod.tls.certresolver=letsencrypt"
- "traefik.http.services.baserow-prod.loadbalancer.server.port=80" - "traefik.http.services.baserow-prod.loadbalancer.server.port=80"
@ -42,8 +57,54 @@ services:
resources: resources:
limits: limits:
memory: 3G memory: 3G
cpus: "2.0"
reservations: reservations:
memory: 1G memory: 1G
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost/_health/ || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
bridge:
restart: always
environment:
BASEROW_WEBHOOK_SECRET: ${BASEROW_WEBHOOK_SECRET:?BASEROW_WEBHOOK_SECRET requis (>= 16 chars)}
LOG_LEVEL: warn
labels:
- "traefik.enable=true"
- "traefik.docker.network=admin_proxy"
# Router public : webhooks Baserow + appels machine-to-machine (token brg_*).
- "traefik.http.routers.bridge-prod.rule=Host(`bridge.stark.a3n.fr`)"
- "traefik.http.routers.bridge-prod.entrypoints=websecure"
- "traefik.http.routers.bridge-prod.tls.certresolver=letsencrypt"
- "traefik.http.routers.bridge-prod.service=bridge-prod"
# Router same-origin sur le doc : appels SPA front. Le cookie authToken
# de Docmost est host-only sur doc.stark.a3n.fr donc on ne peut PAS
# router cross-subdomain. Le strip /bridge laisse passer /api/v1/* tel
# que le bridge l'attend.
- "traefik.http.routers.bridge-on-doc.rule=Host(`doc.stark.a3n.fr`) && PathPrefix(`/bridge`)"
- "traefik.http.routers.bridge-on-doc.entrypoints=websecure"
- "traefik.http.routers.bridge-on-doc.tls.certresolver=letsencrypt"
- "traefik.http.routers.bridge-on-doc.middlewares=bridge-strip"
- "traefik.http.routers.bridge-on-doc.service=bridge-prod"
- "traefik.http.middlewares.bridge-strip.stripprefix.prefixes=/bridge"
- "traefik.http.services.bridge-prod.loadbalancer.server.port=4000"
ports: !reset []
deploy:
resources:
limits:
memory: 512M
cpus: "0.5"
reservations:
memory: 128M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:4000/api/health | grep -q '\"status\":\"ok\"' || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
docmost-db: docmost-db:
restart: always restart: always
@ -51,6 +112,9 @@ services:
resources: resources:
limits: limits:
memory: 1G memory: 1G
cpus: "1.0"
reservations:
memory: 256M
docmost-redis: docmost-redis:
restart: always restart: always
@ -58,8 +122,16 @@ services:
resources: resources:
limits: limits:
memory: 256M memory: 256M
cpus: "0.5"
reservations:
memory: 64M
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks: networks:
default: default:
external: true external: true
name: traefik name: admin_proxy

View file

@ -66,8 +66,12 @@ services:
environment: environment:
BASEROW_API_URL: http://baserow:80/api BASEROW_API_URL: http://baserow:80/api
BASEROW_API_TOKEN: ${BASEROW_API_TOKEN} BASEROW_API_TOKEN: ${BASEROW_API_TOKEN}
BASEROW_USER_EMAIL: ${BASEROW_USER_EMAIL}
BASEROW_USER_PASSWORD: ${BASEROW_USER_PASSWORD}
DOCMOST_API_URL: http://docmost:3000/api DOCMOST_API_URL: http://docmost:3000/api
DOCMOST_API_TOKEN: ${DOCMOST_API_TOKEN} DOCMOST_API_TOKEN: ${DOCMOST_API_TOKEN}
DOCMOST_APP_SECRET: ${DOCMOST_APP_SECRET}
DOCMOST_JWT_ISSUER: Docmost
REDIS_URL: redis://docmost-redis:6379 REDIS_URL: redis://docmost-redis:6379
ports: ports:
- "4000:4000" - "4000:4000"