From 429aa06ca62f4a1e4c637c24e4403767a6764bbb Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 9 Sep 2026 22:58:13 +0200 Subject: [PATCH] fix(admin): passe les variables FORGEJO_* au conteneur `app` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'intégration Forgejo (« Créer un ticket » depuis un message de contact, issue #81) restait invisible en prod Docker : `docker-compose.yml` ne listait pas `FORGEJO_BASE_URL` / `FORGEJO_TOKEN` / `FORGEJO_REPO` dans le bloc `environment:` du service `app`, et `**/.env` est `.dockerignore`d — le process API ne voyait donc jamais ces variables, `ForgejoService.isConfigured()` renvoyait `false` et le bouton restait masqué sur `/admin/messages`. - `docker-compose.yml` : les 3 `FORGEJO_*` passées au conteneur via `${VAR:-}`, même forme que `ADMIN_JWT_SECRET` / `INTERNAL_WORKER_SECRET`. - `config/env.ts` : `blankAsUnset` sur les 3 champs — `${VAR:-}` arrive comme `""` quand le `.env` du stack laisse la variable vide ; sans ça une valeur vide ferait échouer `.url()` / la regex `owner/repo` et planter `envSchema.parse()` au boot. Même parade que `COOKIE_SECURE`. `envSchema` exporté pour le test. - `.env.example` (racine) : documente les 3 variables au niveau compose. - `apps/api/test/env.test.ts` : blanc = absent, + non-régression sur une valeur non vide mais mal formée. Co-Authored-By: Claude Sonnet 5 --- .env.example | 11 +++++++ apps/api/src/config/env.ts | 33 +++++++++++++++------ apps/api/test/env.test.ts | 59 ++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 10 +++++++ 4 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 apps/api/test/env.test.ts diff --git a/.env.example b/.env.example index 2e6090a..2445596 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,17 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # ADMIN_INITIAL_EMAIL=ops@example.com # ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars # ADMIN_INITIAL_NAME=Ops +# +# Optional — lets an admin open a Forgejo ticket from a contact message +# (apps/web's /admin/messages, issue #81). All three needed together: the +# button stays hidden and the endpoint answers 503 unless all are set to a +# non-empty value. docker-compose.yml forwards these into the "app" +# container, so setting them here is enough (a blank value counts as +# unset). FORGEJO_TOKEN is a personal access token with the write:issue +# scope — generate it in Forgejo, never commit the real value. +# FORGEJO_BASE_URL=https://git.example.com +# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token +# FORGEJO_REPO=owner/repo # Optional — only set this to false if THIS deployment is served over # plain HTTP (no TLS in front of it). Left unset, the session cookie diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 0c42d8e..0e1b116 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -13,12 +13,26 @@ import { z } from "zod"; // separate test database this now requires. dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" }); +/** + * Treats a blank env var (`FOO=`) as unset. `docker-compose.yml` passes + * every optional var through as `${FOO:-}`, which resolves to `""` (not an + * absent key) when the stack's `.env` leaves it empty — and a `.env` file + * is `.dockerignore`d, so that passthrough is the container's only source + * of these. Fields that must *fail closed* on a blank value rather than run + * their own format check against `""` (and crash `parse()` at boot) wrap + * their schema in this. Used for the `FORGEJO_*` trio below; `COOKIE_SECURE` + * folds the same `"" -> undefined` step into its own `.transform`. + */ +const blankAsUnset = (value: unknown): unknown => (value === "" ? undefined : value); + /** * Schema for every environment variable the API reads. Parsing (below) * fails fast at startup if something required is missing/invalid, instead - * of surfacing as a confusing runtime error later. + * of surfacing as a confusing runtime error later. Exported so the parsing + * rules (e.g. `blankAsUnset` on the `FORGEJO_*` trio) can be unit-tested + * without the process-wide `process.env` the singleton below parses. */ -const envSchema = z.object({ +export const envSchema = z.object({ /** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), /** Port the HTTP server listens on. */ @@ -152,14 +166,17 @@ const envSchema = z.object({ * `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the * rest of the app never talks to Forgejo. */ - FORGEJO_BASE_URL: z.string().url().optional(), + FORGEJO_BASE_URL: z.preprocess(blankAsUnset, z.string().url().optional()), /** Forgejo personal access token with the `write:issue` (or `issue`) scope. Secret — `.env.example` shows only a `changeme`. */ - FORGEJO_TOKEN: z.string().optional(), + FORGEJO_TOKEN: z.preprocess(blankAsUnset, z.string().optional()), /** Target repo as `owner/repo` (e.g. `kyuno/batchCooking`) — issues are always created here, never a repo chosen at call time. */ - FORGEJO_REPO: z - .string() - .regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'") - .optional(), + FORGEJO_REPO: z.preprocess( + blankAsUnset, + z + .string() + .regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'") + .optional(), + ), }); /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */ diff --git a/apps/api/test/env.test.ts b/apps/api/test/env.test.ts new file mode 100644 index 0000000..9a8b4f3 --- /dev/null +++ b/apps/api/test/env.test.ts @@ -0,0 +1,59 @@ +import { expect } from "chai"; +import { envSchema } from "../src/config/env.js"; + +/** + * Pure schema test — no DB, no `process.env`. Covers `blankAsUnset` on the + * `FORGEJO_*` trio: `docker-compose.yml` forwards every optional var as + * `${FOO:-}`, which the container receives as `""` (not an absent key) when + * the stack's `.env` leaves it blank. Those three must read a blank value + * as "not configured" and still boot, rather than trip `FORGEJO_BASE_URL`'s + * `.url()` / `FORGEJO_REPO`'s regex and abort `parse()` at startup. + */ +describe("envSchema — FORGEJO_* blank handling", () => { + // The only two vars with no default and no `.optional()`. + const base = { + JWT_SECRET: "x".repeat(32), + INTENT_SERVICE_SECRET: "y".repeat(32), + }; + + it("treats blank FORGEJO_* values as unset instead of failing validation", () => { + const parsed = envSchema.parse({ + ...base, + FORGEJO_BASE_URL: "", + FORGEJO_TOKEN: "", + FORGEJO_REPO: "", + }); + + expect(parsed.FORGEJO_BASE_URL).to.equal(undefined); + expect(parsed.FORGEJO_TOKEN).to.equal(undefined); + expect(parsed.FORGEJO_REPO).to.equal(undefined); + }); + + it("treats a fully absent FORGEJO_* trio the same way", () => { + const parsed = envSchema.parse({ ...base }); + expect(parsed.FORGEJO_BASE_URL).to.equal(undefined); + expect(parsed.FORGEJO_TOKEN).to.equal(undefined); + expect(parsed.FORGEJO_REPO).to.equal(undefined); + }); + + it("still passes through and validates non-blank FORGEJO_* values", () => { + const parsed = envSchema.parse({ + ...base, + FORGEJO_BASE_URL: "https://git.example.com", + FORGEJO_TOKEN: "a-token", + FORGEJO_REPO: "owner/repo", + }); + + expect(parsed.FORGEJO_BASE_URL).to.equal("https://git.example.com"); + expect(parsed.FORGEJO_TOKEN).to.equal("a-token"); + expect(parsed.FORGEJO_REPO).to.equal("owner/repo"); + }); + + it("still rejects a non-blank but malformed FORGEJO_BASE_URL", () => { + expect(() => envSchema.parse({ ...base, FORGEJO_BASE_URL: "git.example.com" })).to.throw(); + }); + + it("still rejects a non-blank but malformed FORGEJO_REPO", () => { + expect(() => envSchema.parse({ ...base, FORGEJO_REPO: "not-a-repo" })).to.throw(); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml index a25e8bb..05042bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,6 +54,16 @@ services: # has every /admin/* route 401. Must be a *different* secret than # JWT_SECRET. ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-} + # Forgejo integration for "create a ticket from a contact message" + # (apps/web's /admin/messages, issue #81). All three read from this + # stack's .env; the feature fails closed — the button is hidden and + # the endpoint answers 503 — unless all three are set to a non-empty + # value, same posture as ADMIN_JWT_SECRET above. A `.dockerignore`d + # `.env` never reaches this container, so they must be passed here + # explicitly; a blank value counts as unset (see config/env.ts). + FORGEJO_BASE_URL: ${FORGEJO_BASE_URL:-} + FORGEJO_TOKEN: ${FORGEJO_TOKEN:-} + FORGEJO_REPO: ${FORGEJO_REPO:-} # Compose network service name, not localhost — same reasoning as # DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback: # tech-step-intent-service is a core dependency (see its own entry