Skip to content
Open
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
4 changes: 3 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"dependencies": {
"@edgepush/orpc": "workspace:*",
"@t3-oss/env-core": "^0.13.11",
"better-auth": "^1.6.9",
"drizzle-orm": "^0.45.2",
"hono": "^4.12.15",
Expand All @@ -36,6 +37,7 @@
"@cloudflare/workers-types": "^4.20260426.1",
"drizzle-kit": "^0.31.10",
"tsup": "^8.3.5",
"typescript": "^6.0.2"
"typescript": "^6.0.2",
"vitest": "^4.1.5"
}
}
16 changes: 16 additions & 0 deletions packages/server/src/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
workerErrors,
} from "./db/schema";
import { decryptCredential } from "./lib/crypto";
import { EnvValidationError, parseEnv } from "./lib/env";
import { sendEmail } from "./lib/email";
import { isHosted } from "./lib/mode";
import { probeApnsCredentials } from "./probes/apns";
Expand Down Expand Up @@ -64,6 +65,21 @@ export async function handleScheduled(
event: ScheduledEvent,
env: Env,
): Promise<void> {
// Validate env up front. The probe cycle decrypts every cred and would
// hit a generic atob() error mid-loop; better to fail loudly here so
// the operator digest the next morning records a single clear row.
try {
parseEnv(env);
} catch (err) {
if (err instanceof EnvValidationError) {
console.error(
`[cron] env validation failed for "${event.cron}": ${err.variable} ${err.reason}`,
);
return;
}
throw err;
}

switch (event.cron) {
case "0 * * * *":
await runProbeCycle(env);
Expand Down
31 changes: 30 additions & 1 deletion packages/server/src/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
webhooks,
} from "./db/schema";
import { decryptCredential } from "./lib/crypto";
import { EnvValidationError, parseEnv } from "./lib/env";
import { logWorkerError } from "./lib/observability";
import { dispatchWebhook, type WebhookPayload } from "./lib/webhook";
import { dispatchApns } from "./push/apns";
Expand All @@ -34,6 +35,25 @@ export async function handleQueue(
batch: MessageBatch<DispatchJob>,
env: Env,
): Promise<void> {
// Fail loudly if the worker is misconfigured. Otherwise we'd hit
// an opaque atob() error inside decryptCredential a few stack frames
// down and the operator wouldn't know which env var was wrong.
try {
parseEnv(env);
} catch (err) {
if (err instanceof EnvValidationError) {
console.error(
`[dispatch] env validation failed: ${err.variable} ${err.reason}`,
);
// Retry every job — the operator will fix the env var, and the
// queue's exponential backoff will let things drain once it's
// healthy again.
for (const msg of batch.messages) msg.retry();
return;
}
throw err;
}

const db = createDb(env.DB);

// Group by appId so we load credentials once per app per batch
Expand All @@ -51,7 +71,16 @@ export async function handleQueue(
try {
await processAppBatch(db, env, appId, jobs);
} catch (err) {
console.error(`[dispatch] app ${appId} batch failed:`, err);
const errorDetail =
err instanceof EnvValidationError
? `env ${err.variable}: ${err.reason}`
: err instanceof Error
? err.message
: String(err);
console.error(
`[dispatch] app ${appId} batch failed: ${errorDetail}`,
err,
);
// Retry the whole group - individual jobs weren't acked
for (const job of jobs) job.retry();
}
Expand Down
41 changes: 41 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { handleScheduled } from "./cron";
import { createDb } from "./db";
import { handleDlq, handleQueue } from "./dispatch";
import { createAuth } from "./lib/better-auth";
import { EnvValidationError, checkEnv } from "./lib/env";
import { dashboardRouter } from "./routes/dashboard";
import { receiptsRouter } from "./routes/receipts";
import { sendRouter } from "./routes/send";
Expand Down Expand Up @@ -49,6 +50,28 @@ app.use("*", async (c, next) => {
await next();
});

// Catch EnvValidationError anywhere in the handler chain and turn it
// into a structured 503 instead of a generic 500. Operators looking at
// logs see exactly which env var is wrong and why.
app.onError((err, c) => {
if (err instanceof EnvValidationError) {
console.error(
`[edgepush] env validation failed at ${c.req.method} ${c.req.path}: ${err.variable} ${err.reason}`,
);
return c.json(
{
error: "server_misconfigured",
message:
"An environment variable required by this endpoint is missing or invalid. Check the Worker logs.",
variable: err.variable,
reason: err.reason,
},
503,
);
}
throw err;
});

app.get("/", (c) =>
c.json({
name: "edgepush",
Expand Down Expand Up @@ -167,6 +190,24 @@ app.get("/health/deep", async (c) => {
: "binding missing",
};

// Required-secrets validation. Reports each misconfigured env var
// by name + reason — does not include the value itself.
{
const start = Date.now();
const envErrors = checkEnv(c.env);
if (envErrors.length === 0) {
results.env = { status: "ok", latency_ms: Date.now() - start };
} else {
results.env = {
status: "down",
latency_ms: Date.now() - start,
detail: envErrors
.map((e) => `${e.variable}: ${e.reason}`)
.join("; "),
};
}
}

// Roll-up: any 'down' component flips the overall to 'down'.
// 'degraded' (e.g. killswitch active) flips to 'degraded'.
const components = Object.values(results);
Expand Down
12 changes: 8 additions & 4 deletions packages/server/src/lib/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* account JSONs are encrypted with this key before being written to D1.
*/

import { validateEncryptionKey } from "./env";

function base64ToBytes(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
Expand All @@ -24,10 +26,12 @@ function bytesToBase64(bytes: Uint8Array): string {
}

async function importKey(rawBase64: string): Promise<CryptoKey> {
const keyBytes = base64ToBytes(rawBase64);
if (keyBytes.length !== 32) {
throw new Error("ENCRYPTION_KEY must be 32 bytes (base64)");
}
// Throws EnvValidationError naming ENCRYPTION_KEY if the value is
// missing, not base64, or doesn't decode to exactly 32 bytes —
// clearer at the call site than a generic atob() error from inside
// the queue consumer.
const validated = validateEncryptionKey(rawBase64);
const keyBytes = base64ToBytes(validated);
return crypto.subtle.importKey(
"raw",
keyBytes,
Expand Down
126 changes: 126 additions & 0 deletions packages/server/src/lib/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import {
EnvValidationError,
checkEnv,
parseEnv,
validateEncryptionKey,
} from "./env";

const VALID_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; // 32 zero bytes
const VALID_AUTH_SECRET = "long-enough-secret-string-for-tests";
const VALID_AUTH_URL = "https://api.example.com";

const VALID = {
ENCRYPTION_KEY: VALID_KEY,
BETTER_AUTH_SECRET: VALID_AUTH_SECRET,
BETTER_AUTH_URL: VALID_AUTH_URL,
};

describe("parseEnv", () => {
it("accepts a fully valid env", () => {
expect(() => parseEnv(VALID)).not.toThrow();
});

it("throws EnvValidationError when ENCRYPTION_KEY missing", () => {
expect(() =>
parseEnv({
BETTER_AUTH_SECRET: VALID_AUTH_SECRET,
BETTER_AUTH_URL: VALID_AUTH_URL,
}),
).toThrow(EnvValidationError);
});

it("error names the missing variable", () => {
try {
parseEnv({
BETTER_AUTH_SECRET: VALID_AUTH_SECRET,
BETTER_AUTH_URL: VALID_AUTH_URL,
});
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
expect((err as EnvValidationError).variable).toBe("ENCRYPTION_KEY");
}
});

it("rejects malformed ENCRYPTION_KEY (hex string of wrong byte length)", () => {
expect(() =>
parseEnv({
...VALID,
ENCRYPTION_KEY: "a".repeat(64), // 48 bytes when decoded as base64
}),
).toThrow(/decoded to 48 bytes/);
});

it("rejects ENCRYPTION_KEY with non-base64 characters", () => {
expect(() =>
parseEnv({
...VALID,
ENCRYPTION_KEY: "not-base64!!!",
}),
).toThrow(/not valid base64/);
});

it("rejects an invalid BETTER_AUTH_URL", () => {
expect(() =>
parseEnv({
...VALID,
BETTER_AUTH_URL: "not-a-url",
}),
).toThrow(EnvValidationError);
});

it("error reason never includes the value itself", () => {
const badKey = "AAAAAAAAAAAAAAAAAAAAAAAA"; // valid base64, 18 bytes
try {
parseEnv({ ...VALID, ENCRYPTION_KEY: badKey });
expect.fail("should have thrown");
} catch (err) {
const e = err as EnvValidationError;
expect(e.reason).not.toContain(badKey);
}
});
});

describe("checkEnv", () => {
it("returns empty array when all valid", () => {
expect(checkEnv(VALID)).toEqual([]);
});

it("collects all errors instead of stopping at the first", () => {
const errors = checkEnv({});
expect(errors).toHaveLength(3);
const variables = errors.map((e) => e.variable).sort();
expect(variables).toEqual([
"BETTER_AUTH_SECRET",
"BETTER_AUTH_URL",
"ENCRYPTION_KEY",
]);
});
});

describe("validateEncryptionKey", () => {
it("returns the trimmed key when valid", () => {
expect(validateEncryptionKey(VALID_KEY)).toBe(VALID_KEY);
});

it("trims surrounding whitespace", () => {
expect(validateEncryptionKey(` ${VALID_KEY} `)).toBe(VALID_KEY);
});

it("throws EnvValidationError naming ENCRYPTION_KEY for missing", () => {
try {
validateEncryptionKey(undefined);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
expect((err as EnvValidationError).variable).toBe("ENCRYPTION_KEY");
}
});

it("rejects wrong byte length", () => {
expect(() => validateEncryptionKey("a".repeat(64))).toThrow(
/decoded to 48 bytes/,
);
});
});
Loading