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_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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
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
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue