Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/<username> -> "id"
ADMIN_GITHUB_IDS=
Expand Down
50 changes: 32 additions & 18 deletions apps/api/src/config.ts
Original file line number Diff line number Diff line change
@@ -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<typeof envSchema>;

/** Validates an environment; throws with every problem listed. */
export function parseEnv(env: Record<string, string | undefined>): 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;
}
Expand Down
25 changes: 25 additions & 0 deletions apps/api/tests/unit/config.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
5 changes: 4 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>/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://<host>`). 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
Expand Down Expand Up @@ -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 |
Expand Down
Loading