diff --git a/apps/api/.env.example b/apps/api/.env.example index c4eecc4..97bb9f6 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -13,6 +13,11 @@ AUTH_SECRET= AUTH_GITHUB_ID= AUTH_GITHUB_SECRET= +# Public origin of the site, without a trailing slash. Required in production +# (including the Docker image): the GitHub callback URL is built from it, and +# forwarded host headers are ignored when it is set. +AUTH_URL=http://localhost:3000 + # Comma-separated GitHub numeric user IDs that may use /admin/ # Find yours: https://api.github.com/users/ -> "id" ADMIN_GITHUB_IDS= diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 8ecd88f..b77474a 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -1,30 +1,44 @@ import { z } from "zod"; -const envSchema = z.object({ - DATABASE_URL: z.url(), - AUTH_SECRET: z.string().min(32, "AUTH_SECRET must be at least 32 characters"), - AUTH_GITHUB_ID: z.string().min(1, "AUTH_GITHUB_ID is required"), - AUTH_GITHUB_SECRET: z.string().min(1, "AUTH_GITHUB_SECRET is required"), - ADMIN_GITHUB_IDS: z.string().default(""), - WEB_ORIGIN: z.url().default("http://localhost:5173"), - STORAGE_DIR: z.string().min(1).default("./storage"), - GITHUB_WEBHOOK_SECRET: z.string().default(""), -}); +const envSchema = z + .object({ + NODE_ENV: z.string().optional(), + DATABASE_URL: z.url(), + AUTH_SECRET: z.string().min(32, "AUTH_SECRET must be at least 32 characters"), + AUTH_GITHUB_ID: z.string().min(1, "AUTH_GITHUB_ID is required"), + AUTH_GITHUB_SECRET: z.string().min(1, "AUTH_GITHUB_SECRET is required"), + // The public origin. Auth.js builds the GitHub callback URL from it; without + // it the production server uses its own bind address (0.0.0.0:3000). + AUTH_URL: z.url().optional(), + ADMIN_GITHUB_IDS: z.string().default(""), + WEB_ORIGIN: z.url().default("http://localhost:5173"), + STORAGE_DIR: z.string().min(1).default("./storage"), + GITHUB_WEBHOOK_SECRET: z.string().default(""), + }) + .refine((env) => env.NODE_ENV !== "production" || env.AUTH_URL !== undefined, { + path: ["AUTH_URL"], + message: "AUTH_URL is required in production, e.g. https://devnepal.gov.np", + }); export type AppEnv = z.infer; +/** Validates an environment; throws with every problem listed. */ +export function parseEnv(env: Record): AppEnv { + const parsed = envSchema.safeParse(env); + if (!parsed.success) { + const details = parsed.error.issues + .map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`) + .join("\n"); + throw new Error(`Invalid environment configuration:\n${details}`); + } + return parsed.data; +} + let cachedEnv: AppEnv | null = null; export function getEnv(): AppEnv { if (cachedEnv === null) { - const parsed = envSchema.safeParse(process.env); - if (!parsed.success) { - const details = parsed.error.issues - .map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`) - .join("\n"); - throw new Error(`Invalid environment configuration:\n${details}`); - } - cachedEnv = parsed.data; + cachedEnv = parseEnv(process.env); } return cachedEnv; } diff --git a/apps/api/tests/unit/config.test.ts b/apps/api/tests/unit/config.test.ts new file mode 100644 index 0000000..f6f724c --- /dev/null +++ b/apps/api/tests/unit/config.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { parseEnv } from "@/config"; + +const base = { + DATABASE_URL: "postgres://user:pass@127.0.0.1:5432/app", + AUTH_SECRET: "a".repeat(32), + AUTH_GITHUB_ID: "client-id", + AUTH_GITHUB_SECRET: "client-secret", +}; + +describe("parseEnv", () => { + it("requires AUTH_URL in production", () => { + expect(() => parseEnv({ ...base, NODE_ENV: "production" })).toThrow(/AUTH_URL/); + }); + + it("accepts production with AUTH_URL set", () => { + const env = parseEnv({ ...base, NODE_ENV: "production", AUTH_URL: "https://example.gov.np" }); + expect(env.AUTH_URL).toBe("https://example.gov.np"); + }); + + it("does not require AUTH_URL in development", () => { + expect(() => parseEnv({ ...base, NODE_ENV: "development" })).not.toThrow(); + }); +}); diff --git a/docs/deployment.md b/docs/deployment.md index 72d24b1..5c21fa6 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -25,7 +25,9 @@ read at runtime from the process environment. (`openssl rand -base64 48`). Changing it signs everyone out. 4. **GitHub OAuth**: add the production callback URL to the OAuth App / GitHub App (`https:///api/auth/callback/github`) — the localhost - one does not carry over. Set `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET`. + one does not carry over. Set `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET`, and set + `AUTH_URL` to the public origin (`https://`). The app refuses to start + in production without it. 5. **`ADMIN_GITHUB_IDS`** — comma-separated numeric IDs; empty means nobody can moderate. 6. **`STORAGE_DIR`** must point at a **persistent volume** (avatars). In the @@ -53,6 +55,7 @@ read at runtime from the process environment. | Browser PATCH rejected with 403 after deployment | Origin guard must trust the proxy-resolved own origin | Request-origin + `x-forwarded-*` support in `assertSameOrigin` | | Everyone shares one rate-limit bucket behind a tunnel | Proxy IPs hide the client | `cf-connecting-ip` → `x-forwarded-for` → `x-real-ip` order in `clientIp` | | Timestamps show UTC on the server | Server renders dates | All display formatting pinned to `Asia/Kathmandu` (`src/lib/format.ts`) | +| GitHub sign-in fails with a callback URL mismatch | Without `AUTH_URL` the standalone server builds the callback from its own bind address (`0.0.0.0:3000`), and forwarded host headers do not change it | Set `AUTH_URL`; the app refuses to start in production without it | | Sessions break on HTTP | Auth.js marks cookies secure on HTTPS; `trustHost` is enabled | Terminate TLS at the proxy; do not serve the app over plain HTTP | | `/health` reports 503 in a healthy container | It checks the database | Correct: the check is a real dependency probe; investigate the DB | | Rate limits reset on restart | In-memory counters, single instance | Accept for one replica; move to a shared store before scaling out |