All checks were successful
Depuis /admin/messages, un admin peut créer une issue Forgejo à partir
d'un message utilisateur : bouton « Créer un ticket » → modale pré-remplie
éditable → l'issue est créée via l'API Forgejo et le lien (#N) est
mémorisé sur le message. Première intégration Forgejo d'apps/api.
- Modèle : `forgejoIssueNumber` / `forgejoIssueUrl` sur `ContactMessage`
(migration `20260908130000_contact_message_forgejo_link`, 2 colonnes
nullables, aucun backfill).
- Config : `FORGEJO_BASE_URL` / `FORGEJO_TOKEN` / `FORGEJO_REPO`, toutes
`.optional()` — **fail-closed** (même posture que `ADMIN_JWT_SECRET`) :
bouton masqué + endpoint `503` tant que les trois ne sont pas là.
Documentées dans `.env.example` ; valeurs de test dans `ci.yml`.
- `ForgejoService` (`lib/forgejo/forgejo.service.ts`, singleton `forgejo`) :
`isConfigured()` + `createIssue({ title, body })` → `POST .../issues`,
en-tête `Authorization: token <pat>`, lit `{ number, html_url }` ;
erreur réseau / non-2xx / JSON invalide ⇒ `HttpError 502
FORGEJO_REQUEST_FAILED`.
- `admin-contact` : `POST /admin/api/contact/:id/forgejo-issue`
(`createForgejoIssueFromMessage` — ordre 503 → 404 → 409 → Forgejo →
écriture BDD ; un échec Forgejo ne touche pas la BDD) et
`GET /admin/api/contact/forgejo/status`.
- Contrat partagé : `createForgejoIssueSchema` (title 1–255, body 1–60000),
`ContactMessageAdminView` étendu, `ForgejoIntegrationStatus`, nouveaux
`ErrorCode` (`FORGEJO_NOT_CONFIGURED`=5001, `FORGEJO_REQUEST_FAILED`=5002,
`CONTACT_MESSAGE_ALREADY_LINKED`=4026, `CONTACT_MESSAGE_NOT_FOUND`=4055)
+ labels i18n.
- Front : `MessagesPage` — sonde `forgejo/status` au montage, colonne
« Ticket » (lien `#N` si lié, bouton sinon, « non configurée » sinon),
`CreateForgejoIssueModal` + helpers purs `messages.ts`
(`buildIssueTitle` / `buildIssueBody`).
- Tests : `forgejo.service.test.ts` (stub `globalThis.fetch`),
`admin-contact.test.ts` étendu (nominal / 503 / 404 / 409 / 502 +
ligne intacte / 400), `admin-messages.cy.ts` étendu (modale pré-remplie,
lien après création, masquage si non configuré).
- Docs : `backend-architecture.md`, `batch-cooking-modele.md`.
Closes #81.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
166 lines
9.1 KiB
TypeScript
166 lines
9.1 KiB
TypeScript
import dotenv from "dotenv";
|
|
import { z } from "zod";
|
|
|
|
// Loads `.env.test` instead of `.env` when running the test suite
|
|
// (NODE_ENV=test, set by `cross-env` in package.json's `test` script —
|
|
// already present in `process.env` by the time this module runs, since
|
|
// `cross-env` sets it before invoking node/tsx at all). Keeps
|
|
// `resetDatabase()` (test-support/reset-db.ts, which TRUNCATEs almost
|
|
// every table before each test) pointed at a dedicated test database,
|
|
// never whatever `pnpm dev` actually uses — running the test suite once
|
|
// already wiped a real local dev database this way (`.env`/`.env.test`
|
|
// sharing one `DATABASE_URL`), see `.env.test.example` for how to set the
|
|
// separate test database this now requires.
|
|
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
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. */
|
|
PORT: z.coerce.number().int().positive().default(3000),
|
|
/** Postgres connection string, consumed by Prisma. */
|
|
DATABASE_URL: z.string().url().optional(),
|
|
// Auth — no default on purpose, same reasoning as docker-compose.yml's
|
|
// POSTGRES_USER/PASSWORD: a secret must never have a working fallback
|
|
// baked into committed code.
|
|
/** Secret used to sign/verify session JWTs. Required, no default — see comment above. */
|
|
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
|
|
/** JWT expiry, in `jsonwebtoken`'s duration string format (e.g. "7d"). */
|
|
JWT_EXPIRES_IN: z.string().default("7d"),
|
|
/** Name of the httpOnly cookie carrying the session JWT. */
|
|
AUTH_COOKIE_NAME: z.string().default("session"),
|
|
/**
|
|
* Origin(s) allowed by CORS, comma-separated — parsed below into a
|
|
* `string[]` for `ExpressServerCoreOptions.corsOrigin`
|
|
* (`app.ts`/`express-server.ts`, which passes it straight to the `cors`
|
|
* package) and reused as-is for the Socket.IO handshake's own CORS config
|
|
* (`server.ts`, which needs its own since the WS upgrade never runs
|
|
* through `setupCore`'s middleware).
|
|
*
|
|
* Must always include `https://localhost`: the packaged Android app's
|
|
* WebView is served from exactly that fixed origin (Capacitor's default
|
|
* for `androidScheme`/hostname — see `apps/web/capacitor.config.ts`'s own
|
|
* doc comment), and every request it makes to this API — `/auth/login`
|
|
* and `/auth/signup` included — is genuinely cross-origin, unlike
|
|
* apps/web's *browser* build, which is served from the very same origin
|
|
* as this API in production (see docker-compose.yml's `app` service) and
|
|
* so never needs an entry here at all. Missing this origin doesn't fail
|
|
* loudly: the `cors` package still answers a preflight `OPTIONS` with 204
|
|
* for an origin it's about to reject (it only *omits*
|
|
* `Access-Control-Allow-Origin`), so the request the app's WebView logs
|
|
* looks identical to a normal one right up until the browser silently
|
|
* drops the real `POST` — surfacing client-side as a generic "unexpected
|
|
* error" with no 401/403 anywhere to point at the actual cause. Default
|
|
* covers both the Vite dev server and the packaged app out of the box; a
|
|
* real deployment's own web origin (e.g. a production domain) never needs
|
|
* adding here for the reason above, but the env var still allows
|
|
* extending/overriding this list (comma-separated) if that ever changes.
|
|
*/
|
|
CORS_ORIGIN: z
|
|
.string()
|
|
.default("http://localhost:5173,https://localhost")
|
|
.transform((value) =>
|
|
value
|
|
.split(",")
|
|
.map((origin) => origin.trim())
|
|
.filter((origin) => origin.length > 0),
|
|
),
|
|
/**
|
|
* Absolute path to the built frontend (`apps/web/dist`), to serve
|
|
* alongside the API. Optional, no default — only set inside the
|
|
* production Docker image (see Dockerfile); left unset in native dev
|
|
* (`pnpm dev:api`), where `pnpm dev:web`'s own Vite dev server serves
|
|
* the frontend instead.
|
|
*/
|
|
FRONTEND_DIST_DIR: z.string().optional(),
|
|
/**
|
|
* Overrides whether the session cookie gets the `Secure` attribute
|
|
* (HTTPS-only — see auth.routes.ts). Independent from NODE_ENV on
|
|
* purpose: NODE_ENV=production doesn't imply the deployment actually
|
|
* has TLS in front of it (e.g. an HTTP-only dev/staging instance), and
|
|
* a `Secure` cookie is silently never sent back by the browser over
|
|
* plain HTTP — every authenticated request 401s despite login
|
|
* succeeding, with no error to point at the cause. Unset (the default)
|
|
* falls back to NODE_ENV === "production", same as before this existed.
|
|
* Empty string counts as unset too, so `${COOKIE_SECURE:-}` in
|
|
* docker-compose.yml doesn't force it to `false` when not provided.
|
|
*/
|
|
COOKIE_SECURE: z
|
|
.string()
|
|
.optional()
|
|
.transform((value) => (value === undefined || value === "" ? undefined : value === "true")),
|
|
/**
|
|
* Shared secret `services/tech-step-llm-worker` sends as an
|
|
* `X-Internal-Worker-Secret` header on every call to `/internal/tech-steps/*`
|
|
* (`requireInternalWorker`, `middlewares/require-internal-worker.ts`).
|
|
* Optional with no default in the schema itself (unlike `JWT_SECRET`) so
|
|
* an environment that doesn't run the worker at all (e.g. this repo's
|
|
* existing test suite) never needs to set it — but `requireInternalWorker`
|
|
* itself rejects every request outright when it's unset, so the surface
|
|
* fails closed rather than open if a real deployment forgets to set it.
|
|
*/
|
|
INTERNAL_WORKER_SECRET: z.string().min(32).optional(),
|
|
/**
|
|
* Base URL of `services/tech-step-intent-service` (the spaCy-based
|
|
* microservice `TechStepClassifierService` delegates NER + intent
|
|
* classification to, see `lib/recipe-matching/intent-service-client.ts`).
|
|
* Has a default (unlike `DATABASE_URL`/secrets below) since it isn't
|
|
* secret and dev natively runs it on a fixed local port — Docker Compose
|
|
* overrides it to the compose network's service name.
|
|
*/
|
|
INTENT_SERVICE_BASE_URL: z.string().url().default("http://localhost:8000"),
|
|
/**
|
|
* Shared secret sent as an `X-Intent-Service-Secret` header on every call
|
|
* to `services/tech-step-intent-service`. Unlike `INTERNAL_WORKER_SECRET`
|
|
* above, **required, no `.optional()`** — that service is a core
|
|
* dependency (recipe save/preview can no longer detect any technique
|
|
* without it), not an optional background job; an environment that
|
|
* forgets to set this must fail loudly at startup, not silently run with
|
|
* every technique detection request failing one at a time.
|
|
*/
|
|
INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"),
|
|
/**
|
|
* Secret used to sign/verify the **admin** session JWT (`lib/admin-jwt.ts`)
|
|
* — entirely separate from `JWT_SECRET`, so an end-user session token can
|
|
* never be replayed against `/admin/*` and vice versa. `.optional()`
|
|
* (unlike `JWT_SECRET`): an instance that doesn't run the admin app at
|
|
* all never needs it — but `requireAdmin` (`middlewares/require-admin.ts`)
|
|
* rejects every request outright when it's unset, so the surface fails
|
|
* closed, same posture as `INTERNAL_WORKER_SECRET`.
|
|
*/
|
|
ADMIN_JWT_SECRET: z
|
|
.string()
|
|
.min(32, "ADMIN_JWT_SECRET must be at least 32 characters")
|
|
.optional(),
|
|
/** Name of the httpOnly cookie carrying the admin session JWT — must differ from `AUTH_COOKIE_NAME` so the two sessions coexist in one browser. */
|
|
ADMIN_COOKIE_NAME: z.string().default("admin_session"),
|
|
/** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */
|
|
ADMIN_INITIAL_EMAIL: z.string().optional(),
|
|
ADMIN_INITIAL_PASSWORD: z.string().optional(),
|
|
ADMIN_INITIAL_NAME: z.string().optional(),
|
|
/**
|
|
* Forgejo instance + credentials for "create an issue from a contact
|
|
* message" (`/admin/messages`, issue #81). All three `.optional()` and
|
|
* checked together by `ForgejoService.isConfigured()` — the feature
|
|
* **fails closed** when any is missing: the endpoint answers `503` and
|
|
* the admin UI hides the button (same posture as `ADMIN_JWT_SECRET` /
|
|
* `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 personal access token with the `write:issue` (or `issue`) scope. Secret — `.env.example` shows only a `changeme`. */
|
|
FORGEJO_TOKEN: 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(),
|
|
});
|
|
|
|
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
|
export const env = envSchema.parse(process.env);
|