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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ GITHUB_WEB_BASE_URL=https://github.com
TRUSTSIGNAL_API_BASE_URL=https://trustsignal.example.com
TRUSTSIGNAL_API_KEY=change-me-api-key
INTERNAL_API_KEY=change-me-internal-api-key
INTERNAL_API_KEYS=change-me-api-key-v2,change-me-api-key-v3
LOG_LEVEL=info
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ Required values:
- `TRUSTSIGNAL_API_BASE_URL`
- `TRUSTSIGNAL_API_KEY`
- `INTERNAL_API_KEY`
- `INTERNAL_API_KEYS` (optional, comma-separated; useful for key rotation)
- `LOG_LEVEL`

Important distinction:
Expand Down Expand Up @@ -227,6 +228,8 @@ https://<your-tunnel-host>/webhooks/github

`/github/installations` and `/github/check-run` are internal endpoints and require the dedicated internal API key via `Authorization: Bearer <INTERNAL_API_KEY>` or `x-api-key`.

To rotate keys safely in production, you can keep `INTERNAL_API_KEY` and add `INTERNAL_API_KEYS` with a comma-separated list of accepted keys. Any listed key is accepted.

`GET /` returns a minimal service descriptor for load balancers, demos, and quick smoke checks. `GET /health` returns environment, uptime, timestamp, and deployment metadata (`gitSha`, `buildTime`, `version`) suitable for readiness checks.

- `GET /version` is a compact deployment verification endpoint with the same metadata.
Expand Down
32 changes: 30 additions & 2 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,46 @@ const envSchema = z.object({
GITHUB_WEB_BASE_URL: z.string().url("GITHUB_WEB_BASE_URL must be a valid URL").optional(),
TRUSTSIGNAL_API_BASE_URL: z.string().url("TRUSTSIGNAL_API_BASE_URL must be a valid URL"),
TRUSTSIGNAL_API_KEY: z.string().min(1, "TRUSTSIGNAL_API_KEY is required"),
INTERNAL_API_KEY: z.string().min(1, "INTERNAL_API_KEY is required"),
INTERNAL_API_KEY: z.string().min(1, "INTERNAL_API_KEY is required").optional(),
INTERNAL_API_KEYS: z.string().optional(),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
});

type ParsedEnv = z.infer<typeof envSchema>;

export interface AppEnv extends Omit<ParsedEnv, "GITHUB_PRIVATE_KEY" | "GITHUB_PRIVATE_KEY_PEM"> {
export interface AppEnv extends Omit<ParsedEnv, "GITHUB_PRIVATE_KEY" | "GITHUB_PRIVATE_KEY_PEM" | "INTERNAL_API_KEYS" | "INTERNAL_API_KEY"> {
GITHUB_PRIVATE_KEY: string;
GITHUB_PRIVATE_KEY_PEM: string;
INTERNAL_API_KEY: string;
INTERNAL_API_KEYS: string[];
}

export function normalizePrivateKey(value: string) {
return value.replace(/\\n/g, "\n").trim();
}

function parseInternalApiKeys(parsed: ParsedEnv) {
const rawValues = [parsed.INTERNAL_API_KEY, parsed.INTERNAL_API_KEYS]
.filter((value): value is string => Boolean(value))
.flatMap((value) => value.split(","))
.map((value) => value.trim())
.filter(Boolean);

const keys = [...new Set(rawValues)];

if (!keys.length) {
throw new z.ZodError([
{
code: z.ZodIssueCode.custom,
path: ["INTERNAL_API_KEY"],
message: "INTERNAL_API_KEY or INTERNAL_API_KEYS is required",
},
]);
}

return keys;
}

export function parseEnv(input: NodeJS.ProcessEnv): AppEnv {
const parsed = envSchema.parse(input);
const privateKey = parsed.GITHUB_PRIVATE_KEY_PEM || parsed.GITHUB_PRIVATE_KEY;
Expand All @@ -43,11 +68,14 @@ export function parseEnv(input: NodeJS.ProcessEnv): AppEnv {
}

const normalizedKey = normalizePrivateKey(privateKey);
const internalApiKeys = parseInternalApiKeys(parsed);

return {
...parsed,
GITHUB_PRIVATE_KEY: normalizedKey,
GITHUB_PRIVATE_KEY_PEM: normalizedKey,
INTERNAL_API_KEY: internalApiKeys.join(","),
INTERNAL_API_KEYS: internalApiKeys,
};
}

Expand Down
9 changes: 8 additions & 1 deletion src/routes/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,18 @@ function safeEqual(a: string, b: string) {
}

export function createInternalApiKeyMiddleware(expected: string) {
const expectedTokens = expected
.split(",")
.map((value) => value.trim())
.filter(Boolean);

return (req: Request, _res: Response, next: NextFunction) => {
const header = (req.header("authorization") || req.header("x-api-key") || "").trim();
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : header;

if (!token || !safeEqual(token, expected)) {
const isAuthorized = token && expectedTokens.some((expectedToken) => safeEqual(token, expectedToken));

if (!isAuthorized) {
next(new AuthenticationError());
return;
}
Expand Down
21 changes: 21 additions & 0 deletions tests/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ describe("parseEnv", () => {
TRUSTSIGNAL_API_BASE_URL: "https://trustsignal.example.com",
TRUSTSIGNAL_API_KEY: "api-key",
INTERNAL_API_KEY: "internal-key",
INTERNAL_API_KEYS: "internal-key-2, internal-key-3",
LOG_LEVEL: "info",
});

expect(env.GITHUB_PRIVATE_KEY_PEM).toContain("BEGIN RSA PRIVATE KEY");
expect(env.GITHUB_API_BASE_URL).toBe("https://api.github.com");
expect(env.INTERNAL_API_KEY).toBe("internal-key,internal-key-2,internal-key-3");
expect(env.INTERNAL_API_KEYS).toEqual(["internal-key", "internal-key-2", "internal-key-3"]);
});

it("fails closed when required values are missing", () => {
Expand All @@ -47,4 +50,22 @@ describe("parseEnv", () => {

expect(env.GITHUB_PRIVATE_KEY_PEM).toContain("BEGIN RSA PRIVATE KEY");
});

it("accepts INTERNAL_API_KEYS when INTERNAL_API_KEY is not provided", () => {
const env = parseEnv({
NODE_ENV: "test",
PORT: "3000",
GITHUB_APP_ID: "123",
GITHUB_APP_NAME: "TrustSignal",
GITHUB_WEBHOOK_SECRET: "secret",
GITHUB_PRIVATE_KEY_PEM: "-----BEGIN RSA PRIVATE KEY-----\\nkey\\n-----END RSA PRIVATE KEY-----",
TRUSTSIGNAL_API_BASE_URL: "https://trustsignal.example.com",
TRUSTSIGNAL_API_KEY: "api-key",
INTERNAL_API_KEYS: "internal-key-a, internal-key-b",
LOG_LEVEL: "info",
});

expect(env.INTERNAL_API_KEY).toBe("internal-key-a,internal-key-b");
expect(env.INTERNAL_API_KEYS).toEqual(["internal-key-a", "internal-key-b"]);
});
});
17 changes: 17 additions & 0 deletions tests/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,23 @@ describe("route handlers", () => {
expect(next.mock.calls[0]?.[0]).toMatchObject({ statusCode: 401, code: "unauthorized" });
});

it("accepts any configured internal API key from a comma-separated list", () => {
const middleware = createInternalApiKeyMiddleware("internal-key-1, internal-key-2");
const req = {
header: vi.fn((name: string) => {
if (name === "authorization") {
return "Bearer internal-key-2";
}
return undefined;
}),
} as any;
const next = vi.fn();

middleware(req, {} as any, next);

expect(next).toHaveBeenCalledWith();
});

it("rejects replayed deliveries", async () => {
const services = createServices();
services.replayStore.begin = vi.fn().mockReturnValue("completed");
Expand Down
Loading