fix(admin): passe les variables FORGEJO_* au conteneur app
Some checks failed
Some checks failed
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 <noreply@anthropic.com>
This commit is contained in:
parent
26bd48bf4b
commit
429aa06ca6
4 changed files with 105 additions and 8 deletions
11
.env.example
11
.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_EMAIL=ops@example.com
|
||||||
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
||||||
# ADMIN_INITIAL_NAME=Ops
|
# 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
|
# 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
|
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,26 @@ import { z } from "zod";
|
||||||
// separate test database this now requires.
|
// separate test database this now requires.
|
||||||
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
|
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)
|
* Schema for every environment variable the API reads. Parsing (below)
|
||||||
* fails fast at startup if something required is missing/invalid, instead
|
* 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). */
|
/** 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"),
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
/** Port the HTTP server listens on. */
|
/** 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
|
* `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the
|
||||||
* rest of the app never talks to Forgejo.
|
* 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 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. */
|
/** Target repo as `owner/repo` (e.g. `kyuno/batchCooking`) — issues are always created here, never a repo chosen at call time. */
|
||||||
FORGEJO_REPO: z
|
FORGEJO_REPO: z.preprocess(
|
||||||
|
blankAsUnset,
|
||||||
|
z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
|
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
|
||||||
.optional(),
|
.optional(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||||
|
|
|
||||||
59
apps/api/test/env.test.ts
Normal file
59
apps/api/test/env.test.ts
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -54,6 +54,16 @@ services:
|
||||||
# has every /admin/* route 401. Must be a *different* secret than
|
# has every /admin/* route 401. Must be a *different* secret than
|
||||||
# JWT_SECRET.
|
# JWT_SECRET.
|
||||||
ADMIN_JWT_SECRET: ${ADMIN_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
|
# Compose network service name, not localhost — same reasoning as
|
||||||
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
|
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
|
||||||
# tech-step-intent-service is a core dependency (see its own entry
|
# tech-step-intent-service is a core dependency (see its own entry
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue