From 21409be66d7f1c911b931aa50a27ef393d62d146 Mon Sep 17 00:00:00 2001 From: akshitkrnagpal Date: Wed, 29 Apr 2026 22:03:07 +0400 Subject: [PATCH 1/2] Add env validation with structured errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier cryptic "atob() called with invalid base64-encoded data" deep in the dispatch consumer was an undefined ENCRYPTION_KEY on edgepush-api. The error itself never named the variable; the operator had to trace it back through decryptCredential to figure out which secret was wrong. This adds runtime env validation that surfaces the variable name + a non-secret reason at three entry points: - HTTP routes: a Hono onError handler catches EnvValidationError and returns a structured 503 with the variable + reason instead of a generic 500. - Queue consumer (dispatch): validates required env at batch start. Misconfiguration retries the whole batch (so jobs drain once the operator fixes things) and logs a single clear line per batch instead of one cryptic atob error per job. - Scheduled handler (cron): validates env at scheduled-event entry. The probe cycle would otherwise fail mid-loop with a confusing log line per app. Also exposes the validation through /health/deep — the new "env" component reports any misconfigured required secrets by name. Validation messages never include the secret value. Reasons are limited to: "missing or empty", "not valid base64 (length N, contains characters outside [A-Za-z0-9+/=])", "decoded to N bytes, expected M". Length is fine to expose; the value is not. 19 unit tests in env-validation.test.ts cover the validators and the variable-name leakage prevention. Excluded test files from the server tsconfig so they don't pollute the build types — the package will get a proper vitest setup as a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/server/src/cron.ts | 16 ++ packages/server/src/dispatch.ts | 31 +++- packages/server/src/index.ts | 43 +++++ packages/server/src/lib/crypto.ts | 12 +- .../server/src/lib/env-validation.test.ts | 161 ++++++++++++++++++ packages/server/src/lib/env-validation.ts | 136 +++++++++++++++ packages/server/tsconfig.json | 3 +- 7 files changed, 396 insertions(+), 6 deletions(-) create mode 100644 packages/server/src/lib/env-validation.test.ts create mode 100644 packages/server/src/lib/env-validation.ts diff --git a/packages/server/src/cron.ts b/packages/server/src/cron.ts index f4159a9..110211e 100644 --- a/packages/server/src/cron.ts +++ b/packages/server/src/cron.ts @@ -33,6 +33,7 @@ import { workerErrors, } from "./db/schema"; import { decryptCredential } from "./lib/crypto"; +import { EnvValidationError, validateRequiredEnv } from "./lib/env-validation"; import { sendEmail } from "./lib/email"; import { isHosted } from "./lib/mode"; import { probeApnsCredentials } from "./probes/apns"; @@ -64,6 +65,21 @@ export async function handleScheduled( event: ScheduledEvent, env: Env, ): Promise { + // 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 { + validateRequiredEnv(env as unknown as Record); + } 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); diff --git a/packages/server/src/dispatch.ts b/packages/server/src/dispatch.ts index 30c968a..c64c777 100644 --- a/packages/server/src/dispatch.ts +++ b/packages/server/src/dispatch.ts @@ -24,6 +24,7 @@ import { webhooks, } from "./db/schema"; import { decryptCredential } from "./lib/crypto"; +import { EnvValidationError, validateRequiredEnv } from "./lib/env-validation"; import { logWorkerError } from "./lib/observability"; import { dispatchWebhook, type WebhookPayload } from "./lib/webhook"; import { dispatchApns } from "./push/apns"; @@ -34,6 +35,25 @@ export async function handleQueue( batch: MessageBatch, env: Env, ): Promise { + // 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 { + validateRequiredEnv(env as unknown as Record); + } 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 @@ -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(); } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 5c23f77..8c63b59 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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, checkRequiredEnv } from "./lib/env-validation"; import { dashboardRouter } from "./routes/dashboard"; import { receiptsRouter } from "./routes/receipts"; import { sendRouter } from "./routes/send"; @@ -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", @@ -167,6 +190,26 @@ 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 = checkRequiredEnv( + c.env as unknown as Record, + ); + 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); diff --git a/packages/server/src/lib/crypto.ts b/packages/server/src/lib/crypto.ts index 258de86..e1421e6 100644 --- a/packages/server/src/lib/crypto.ts +++ b/packages/server/src/lib/crypto.ts @@ -6,6 +6,8 @@ * account JSONs are encrypted with this key before being written to D1. */ +import { validateBase64 } from "./env-validation"; + function base64ToBytes(b64: string): Uint8Array { const binary = atob(b64); const bytes = new Uint8Array(binary.length); @@ -24,10 +26,12 @@ function bytesToBase64(bytes: Uint8Array): string { } async function importKey(rawBase64: string): Promise { - const keyBytes = base64ToBytes(rawBase64); - if (keyBytes.length !== 32) { - throw new Error("ENCRYPTION_KEY must be 32 bytes (base64)"); - } + // Throws an EnvValidationError naming ENCRYPTION_KEY if the value is + // missing, not base64, or doesn't decode to exactly 32 bytes — much + // clearer at the call site than a generic atob() InvalidCharacterError + // surfacing from inside the queue consumer. + const validated = validateBase64(rawBase64, "ENCRYPTION_KEY", 32); + const keyBytes = base64ToBytes(validated); return crypto.subtle.importKey( "raw", keyBytes, diff --git a/packages/server/src/lib/env-validation.test.ts b/packages/server/src/lib/env-validation.test.ts new file mode 100644 index 0000000..0573edf --- /dev/null +++ b/packages/server/src/lib/env-validation.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { + EnvValidationError, + checkRequiredEnv, + validateBase64, + validateRequired, + validateRequiredEnv, +} from "./env-validation"; + +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"; + +describe("validateBase64", () => { + it("accepts a valid 32-byte base64 string when expectedBytes=32", () => { + expect(validateBase64(VALID_KEY, "X", 32)).toBe(VALID_KEY); + }); + + it("trims surrounding whitespace", () => { + expect(validateBase64(` ${VALID_KEY} `, "X", 32)).toBe(VALID_KEY); + }); + + it("rejects undefined", () => { + expect(() => validateBase64(undefined, "X", 32)).toThrow( + EnvValidationError, + ); + }); + + it("rejects empty string", () => { + expect(() => validateBase64("", "X", 32)).toThrow(EnvValidationError); + }); + + it("rejects strings with characters outside the base64 alphabet", () => { + expect(() => validateBase64("not-base64-at-all!!!", "X", 32)).toThrow( + /not valid base64/, + ); + }); + + it("rejects hex strings of the wrong decoded length", () => { + // 64 hex chars decode as base64 to 48 bytes, not 32. Common mistake + // when someone uses `openssl rand -hex 32` instead of `-base64 32`. + const hex = "a".repeat(64); + expect(() => validateBase64(hex, "X", 32)).toThrow( + /decoded to 48 bytes, expected 32/, + ); + }); + + it("rejects base64 of the wrong byte length", () => { + // 24 bytes encoded as base64 = "QUJDRUZHSElKS0xNTk9QUVJTVFVWV1g=" (32 chars) + const tooShort = btoa("ABCDEFGHIJKLMNOPQRSTUVWX"); + expect(() => validateBase64(tooShort, "X", 32)).toThrow( + /decoded to 24 bytes, expected 32/, + ); + }); + + it("error includes the variable name", () => { + try { + validateBase64("not-base64-at-all!!!", "MY_VAR", 32); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(EnvValidationError); + const e = err as EnvValidationError; + expect(e.variable).toBe("MY_VAR"); + expect(e.message).toContain("MY_VAR"); + } + }); + + it("error reason never includes the value itself", () => { + const secret = "AAAAAAAAAAAAAAAAAAAAAAAA"; // valid base64 but wrong length (18 bytes) + try { + validateBase64(secret, "MY_KEY", 32); + expect.fail("should have thrown"); + } catch (err) { + const e = err as EnvValidationError; + expect(e.reason).not.toContain(secret); + } + }); +}); + +describe("validateRequired", () => { + it("accepts a non-empty string", () => { + expect(validateRequired("hello", "X")).toBe("hello"); + }); + + it("rejects undefined", () => { + expect(() => validateRequired(undefined, "X")).toThrow(EnvValidationError); + }); + + it("rejects empty string", () => { + expect(() => validateRequired("", "X")).toThrow(EnvValidationError); + }); + + it("rejects non-strings", () => { + expect(() => validateRequired(42 as unknown as string, "X")).toThrow( + EnvValidationError, + ); + }); +}); + +describe("validateRequiredEnv", () => { + it("passes with all required values", () => { + expect(() => + validateRequiredEnv({ + ENCRYPTION_KEY: VALID_KEY, + BETTER_AUTH_SECRET: VALID_AUTH_SECRET, + BETTER_AUTH_URL: VALID_AUTH_URL, + }), + ).not.toThrow(); + }); + + it("throws on missing ENCRYPTION_KEY", () => { + expect(() => + validateRequiredEnv({ + BETTER_AUTH_SECRET: VALID_AUTH_SECRET, + BETTER_AUTH_URL: VALID_AUTH_URL, + }), + ).toThrow(/ENCRYPTION_KEY/); + }); + + it("throws on malformed ENCRYPTION_KEY (hex)", () => { + expect(() => + validateRequiredEnv({ + ENCRYPTION_KEY: "f".repeat(64), + BETTER_AUTH_SECRET: VALID_AUTH_SECRET, + BETTER_AUTH_URL: VALID_AUTH_URL, + }), + ).toThrow(/ENCRYPTION_KEY/); + }); + + it("throws on missing BETTER_AUTH_SECRET", () => { + expect(() => + validateRequiredEnv({ + ENCRYPTION_KEY: VALID_KEY, + BETTER_AUTH_URL: VALID_AUTH_URL, + }), + ).toThrow(/BETTER_AUTH_SECRET/); + }); +}); + +describe("checkRequiredEnv", () => { + it("returns empty array when all valid", () => { + expect( + checkRequiredEnv({ + ENCRYPTION_KEY: VALID_KEY, + BETTER_AUTH_SECRET: VALID_AUTH_SECRET, + BETTER_AUTH_URL: VALID_AUTH_URL, + }), + ).toEqual([]); + }); + + it("collects all errors instead of stopping at the first", () => { + const errors = checkRequiredEnv({}); + expect(errors).toHaveLength(3); + const variables = errors.map((e) => e.variable).sort(); + expect(variables).toEqual([ + "BETTER_AUTH_SECRET", + "BETTER_AUTH_URL", + "ENCRYPTION_KEY", + ]); + }); +}); diff --git a/packages/server/src/lib/env-validation.ts b/packages/server/src/lib/env-validation.ts new file mode 100644 index 0000000..ac38726 --- /dev/null +++ b/packages/server/src/lib/env-validation.ts @@ -0,0 +1,136 @@ +/** + * Runtime validation for the Worker's environment secrets. + * + * Failures here surface BEFORE the cryptic atob/decrypt errors that + * would otherwise show up deep in a queue consumer or request handler. + * + * Pattern: + * - Call `validateRequiredEnv(env)` at the entry point of every long- + * running surface (HTTP middleware, queue consumer, scheduled handler). + * - On failure it throws an `EnvValidationError` whose `.variable` and + * `.reason` are safe to log — they never include the secret value + * itself, only descriptors like length / decoded byte count. + * + * The validation is idempotent and cheap (a regex + atob), so calling + * it on every request is fine. + */ + +const BASE64_RE = /^[A-Za-z0-9+/]+=*$/; + +export class EnvValidationError extends Error { + readonly variable: string; + readonly reason: string; + + constructor(variable: string, reason: string) { + super(`Invalid env var ${variable}: ${reason}`); + this.name = "EnvValidationError"; + this.variable = variable; + this.reason = reason; + } +} + +/** + * Returns a non-secret descriptor of the value's shape, for logs. + * Never includes the value itself. + */ +function describeShape(value: unknown): string { + if (value === undefined) return "undefined"; + if (value === null) return "null"; + if (typeof value !== "string") return `non-string (${typeof value})`; + if (value.length === 0) return "empty string"; + return `string of length ${value.length}`; +} + +/** + * Validates that a value is a non-empty string. Returns the trimmed + * string or throws. + */ +export function validateRequired(value: unknown, name: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new EnvValidationError(name, `expected non-empty string, got ${describeShape(value)}`); + } + return value; +} + +/** + * Validates that a value is a base64-encoded string that decodes to + * the expected number of bytes (when expectedBytes is provided). + * + * Returns the trimmed input. + */ +export function validateBase64( + value: unknown, + name: string, + expectedBytes?: number, +): string { + if (typeof value !== "string" || value.length === 0) { + throw new EnvValidationError( + name, + `expected base64 string, got ${describeShape(value)}`, + ); + } + const trimmed = value.trim(); + if (!BASE64_RE.test(trimmed)) { + throw new EnvValidationError( + name, + `not valid base64 (length ${trimmed.length}, contains characters outside [A-Za-z0-9+/=])`, + ); + } + let bytes: Uint8Array; + try { + const binary = atob(trimmed); + bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + } catch (err) { + throw new EnvValidationError( + name, + `base64 decode failed: ${(err as Error).message}`, + ); + } + if (expectedBytes !== undefined && bytes.length !== expectedBytes) { + throw new EnvValidationError( + name, + `decoded to ${bytes.length} bytes, expected ${expectedBytes}`, + ); + } + return trimmed; +} + +/** + * Validates the secrets the server cannot run without. Throws on the + * first missing or malformed value with a descriptor that's safe to log. + * + * Optional secrets (Stripe, Resend, OAuth providers) are not validated + * here because the surface that needs them returns 501 / falls back + * gracefully when unset. + */ +export function validateRequiredEnv(env: Record): void { + validateBase64(env.ENCRYPTION_KEY, "ENCRYPTION_KEY", 32); + validateRequired(env.BETTER_AUTH_SECRET, "BETTER_AUTH_SECRET"); + validateRequired(env.BETTER_AUTH_URL, "BETTER_AUTH_URL"); +} + +/** + * Like validateRequiredEnv but returns the EnvValidationError instead + * of throwing. Useful in surfaces that want to report all failures + * together (e.g. /health/deep) rather than stopping at the first one. + */ +export function checkRequiredEnv( + env: Record, +): EnvValidationError[] { + const errors: EnvValidationError[] = []; + const checks: Array<() => void> = [ + () => validateBase64(env.ENCRYPTION_KEY, "ENCRYPTION_KEY", 32), + () => validateRequired(env.BETTER_AUTH_SECRET, "BETTER_AUTH_SECRET"), + () => validateRequired(env.BETTER_AUTH_URL, "BETTER_AUTH_URL"), + ]; + for (const check of checks) { + try { + check(); + } catch (err) { + if (err instanceof EnvValidationError) errors.push(err); + else throw err; + } + } + return errors; +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index e6723bc..9c09d85 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -5,5 +5,6 @@ "types": ["@cloudflare/workers-types"], "ignoreDeprecations": "6.0" }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] } From 2a64c5cadd8b70cae4c1723fed53a9941a462c1a Mon Sep 17 00:00:00 2001 From: akshitkrnagpal Date: Wed, 29 Apr 2026 22:13:27 +0400 Subject: [PATCH 2/2] Refactor env validation to t3-env + zod Replaces the hand-rolled validators with @t3-oss/env-core's createEnv + a zod schema. Same surface (parseEnv / checkEnv / EnvValidationError) but the schema is declarative and the error path goes through zod's issue list instead of throw chains. - Add @t3-oss/env-core dep - Move env.ts; delete env-validation.ts/.test.ts - ENCRYPTION_KEY validator: zod.string with a superRefine that base64- decodes and asserts byte length. Other required vars use the standard zod.string().min(1) and zod.url() validators - Expose validateEncryptionKey for crypto.ts so a bad key still surfaces with the variable name (instead of having to call full parseEnv with synthetic values for the others) - 13 tests covering parseEnv (throw-on-first), checkEnv (collect-all), validateEncryptionKey, and the "value never leaks into the error" property - Add vitest as a dev dep on the server package so tests run from there directly (not pulled from the workspace root) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/server/package.json | 4 +- packages/server/src/cron.ts | 4 +- packages/server/src/dispatch.ts | 4 +- packages/server/src/index.ts | 6 +- packages/server/src/lib/crypto.ts | 12 +- .../server/src/lib/env-validation.test.ts | 161 ------------------ packages/server/src/lib/env-validation.ts | 136 --------------- packages/server/src/lib/env.test.ts | 126 ++++++++++++++ packages/server/src/lib/env.ts | 139 +++++++++++++++ pnpm-lock.yaml | 74 +++++--- 10 files changed, 331 insertions(+), 335 deletions(-) delete mode 100644 packages/server/src/lib/env-validation.test.ts delete mode 100644 packages/server/src/lib/env-validation.ts create mode 100644 packages/server/src/lib/env.test.ts create mode 100644 packages/server/src/lib/env.ts diff --git a/packages/server/package.json b/packages/server/package.json index b94eb95..0d9552f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", @@ -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" } } diff --git a/packages/server/src/cron.ts b/packages/server/src/cron.ts index 110211e..9a1e903 100644 --- a/packages/server/src/cron.ts +++ b/packages/server/src/cron.ts @@ -33,7 +33,7 @@ import { workerErrors, } from "./db/schema"; import { decryptCredential } from "./lib/crypto"; -import { EnvValidationError, validateRequiredEnv } from "./lib/env-validation"; +import { EnvValidationError, parseEnv } from "./lib/env"; import { sendEmail } from "./lib/email"; import { isHosted } from "./lib/mode"; import { probeApnsCredentials } from "./probes/apns"; @@ -69,7 +69,7 @@ export async function handleScheduled( // 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 { - validateRequiredEnv(env as unknown as Record); + parseEnv(env); } catch (err) { if (err instanceof EnvValidationError) { console.error( diff --git a/packages/server/src/dispatch.ts b/packages/server/src/dispatch.ts index c64c777..d819b64 100644 --- a/packages/server/src/dispatch.ts +++ b/packages/server/src/dispatch.ts @@ -24,7 +24,7 @@ import { webhooks, } from "./db/schema"; import { decryptCredential } from "./lib/crypto"; -import { EnvValidationError, validateRequiredEnv } from "./lib/env-validation"; +import { EnvValidationError, parseEnv } from "./lib/env"; import { logWorkerError } from "./lib/observability"; import { dispatchWebhook, type WebhookPayload } from "./lib/webhook"; import { dispatchApns } from "./push/apns"; @@ -39,7 +39,7 @@ export async function handleQueue( // an opaque atob() error inside decryptCredential a few stack frames // down and the operator wouldn't know which env var was wrong. try { - validateRequiredEnv(env as unknown as Record); + parseEnv(env); } catch (err) { if (err instanceof EnvValidationError) { console.error( diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8c63b59..9da443b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -5,7 +5,7 @@ import { handleScheduled } from "./cron"; import { createDb } from "./db"; import { handleDlq, handleQueue } from "./dispatch"; import { createAuth } from "./lib/better-auth"; -import { EnvValidationError, checkRequiredEnv } from "./lib/env-validation"; +import { EnvValidationError, checkEnv } from "./lib/env"; import { dashboardRouter } from "./routes/dashboard"; import { receiptsRouter } from "./routes/receipts"; import { sendRouter } from "./routes/send"; @@ -194,9 +194,7 @@ app.get("/health/deep", async (c) => { // by name + reason — does not include the value itself. { const start = Date.now(); - const envErrors = checkRequiredEnv( - c.env as unknown as Record, - ); + const envErrors = checkEnv(c.env); if (envErrors.length === 0) { results.env = { status: "ok", latency_ms: Date.now() - start }; } else { diff --git a/packages/server/src/lib/crypto.ts b/packages/server/src/lib/crypto.ts index e1421e6..819d282 100644 --- a/packages/server/src/lib/crypto.ts +++ b/packages/server/src/lib/crypto.ts @@ -6,7 +6,7 @@ * account JSONs are encrypted with this key before being written to D1. */ -import { validateBase64 } from "./env-validation"; +import { validateEncryptionKey } from "./env"; function base64ToBytes(b64: string): Uint8Array { const binary = atob(b64); @@ -26,11 +26,11 @@ function bytesToBase64(bytes: Uint8Array): string { } async function importKey(rawBase64: string): Promise { - // Throws an EnvValidationError naming ENCRYPTION_KEY if the value is - // missing, not base64, or doesn't decode to exactly 32 bytes — much - // clearer at the call site than a generic atob() InvalidCharacterError - // surfacing from inside the queue consumer. - const validated = validateBase64(rawBase64, "ENCRYPTION_KEY", 32); + // 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", diff --git a/packages/server/src/lib/env-validation.test.ts b/packages/server/src/lib/env-validation.test.ts deleted file mode 100644 index 0573edf..0000000 --- a/packages/server/src/lib/env-validation.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - EnvValidationError, - checkRequiredEnv, - validateBase64, - validateRequired, - validateRequiredEnv, -} from "./env-validation"; - -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"; - -describe("validateBase64", () => { - it("accepts a valid 32-byte base64 string when expectedBytes=32", () => { - expect(validateBase64(VALID_KEY, "X", 32)).toBe(VALID_KEY); - }); - - it("trims surrounding whitespace", () => { - expect(validateBase64(` ${VALID_KEY} `, "X", 32)).toBe(VALID_KEY); - }); - - it("rejects undefined", () => { - expect(() => validateBase64(undefined, "X", 32)).toThrow( - EnvValidationError, - ); - }); - - it("rejects empty string", () => { - expect(() => validateBase64("", "X", 32)).toThrow(EnvValidationError); - }); - - it("rejects strings with characters outside the base64 alphabet", () => { - expect(() => validateBase64("not-base64-at-all!!!", "X", 32)).toThrow( - /not valid base64/, - ); - }); - - it("rejects hex strings of the wrong decoded length", () => { - // 64 hex chars decode as base64 to 48 bytes, not 32. Common mistake - // when someone uses `openssl rand -hex 32` instead of `-base64 32`. - const hex = "a".repeat(64); - expect(() => validateBase64(hex, "X", 32)).toThrow( - /decoded to 48 bytes, expected 32/, - ); - }); - - it("rejects base64 of the wrong byte length", () => { - // 24 bytes encoded as base64 = "QUJDRUZHSElKS0xNTk9QUVJTVFVWV1g=" (32 chars) - const tooShort = btoa("ABCDEFGHIJKLMNOPQRSTUVWX"); - expect(() => validateBase64(tooShort, "X", 32)).toThrow( - /decoded to 24 bytes, expected 32/, - ); - }); - - it("error includes the variable name", () => { - try { - validateBase64("not-base64-at-all!!!", "MY_VAR", 32); - expect.fail("should have thrown"); - } catch (err) { - expect(err).toBeInstanceOf(EnvValidationError); - const e = err as EnvValidationError; - expect(e.variable).toBe("MY_VAR"); - expect(e.message).toContain("MY_VAR"); - } - }); - - it("error reason never includes the value itself", () => { - const secret = "AAAAAAAAAAAAAAAAAAAAAAAA"; // valid base64 but wrong length (18 bytes) - try { - validateBase64(secret, "MY_KEY", 32); - expect.fail("should have thrown"); - } catch (err) { - const e = err as EnvValidationError; - expect(e.reason).not.toContain(secret); - } - }); -}); - -describe("validateRequired", () => { - it("accepts a non-empty string", () => { - expect(validateRequired("hello", "X")).toBe("hello"); - }); - - it("rejects undefined", () => { - expect(() => validateRequired(undefined, "X")).toThrow(EnvValidationError); - }); - - it("rejects empty string", () => { - expect(() => validateRequired("", "X")).toThrow(EnvValidationError); - }); - - it("rejects non-strings", () => { - expect(() => validateRequired(42 as unknown as string, "X")).toThrow( - EnvValidationError, - ); - }); -}); - -describe("validateRequiredEnv", () => { - it("passes with all required values", () => { - expect(() => - validateRequiredEnv({ - ENCRYPTION_KEY: VALID_KEY, - BETTER_AUTH_SECRET: VALID_AUTH_SECRET, - BETTER_AUTH_URL: VALID_AUTH_URL, - }), - ).not.toThrow(); - }); - - it("throws on missing ENCRYPTION_KEY", () => { - expect(() => - validateRequiredEnv({ - BETTER_AUTH_SECRET: VALID_AUTH_SECRET, - BETTER_AUTH_URL: VALID_AUTH_URL, - }), - ).toThrow(/ENCRYPTION_KEY/); - }); - - it("throws on malformed ENCRYPTION_KEY (hex)", () => { - expect(() => - validateRequiredEnv({ - ENCRYPTION_KEY: "f".repeat(64), - BETTER_AUTH_SECRET: VALID_AUTH_SECRET, - BETTER_AUTH_URL: VALID_AUTH_URL, - }), - ).toThrow(/ENCRYPTION_KEY/); - }); - - it("throws on missing BETTER_AUTH_SECRET", () => { - expect(() => - validateRequiredEnv({ - ENCRYPTION_KEY: VALID_KEY, - BETTER_AUTH_URL: VALID_AUTH_URL, - }), - ).toThrow(/BETTER_AUTH_SECRET/); - }); -}); - -describe("checkRequiredEnv", () => { - it("returns empty array when all valid", () => { - expect( - checkRequiredEnv({ - ENCRYPTION_KEY: VALID_KEY, - BETTER_AUTH_SECRET: VALID_AUTH_SECRET, - BETTER_AUTH_URL: VALID_AUTH_URL, - }), - ).toEqual([]); - }); - - it("collects all errors instead of stopping at the first", () => { - const errors = checkRequiredEnv({}); - expect(errors).toHaveLength(3); - const variables = errors.map((e) => e.variable).sort(); - expect(variables).toEqual([ - "BETTER_AUTH_SECRET", - "BETTER_AUTH_URL", - "ENCRYPTION_KEY", - ]); - }); -}); diff --git a/packages/server/src/lib/env-validation.ts b/packages/server/src/lib/env-validation.ts deleted file mode 100644 index ac38726..0000000 --- a/packages/server/src/lib/env-validation.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Runtime validation for the Worker's environment secrets. - * - * Failures here surface BEFORE the cryptic atob/decrypt errors that - * would otherwise show up deep in a queue consumer or request handler. - * - * Pattern: - * - Call `validateRequiredEnv(env)` at the entry point of every long- - * running surface (HTTP middleware, queue consumer, scheduled handler). - * - On failure it throws an `EnvValidationError` whose `.variable` and - * `.reason` are safe to log — they never include the secret value - * itself, only descriptors like length / decoded byte count. - * - * The validation is idempotent and cheap (a regex + atob), so calling - * it on every request is fine. - */ - -const BASE64_RE = /^[A-Za-z0-9+/]+=*$/; - -export class EnvValidationError extends Error { - readonly variable: string; - readonly reason: string; - - constructor(variable: string, reason: string) { - super(`Invalid env var ${variable}: ${reason}`); - this.name = "EnvValidationError"; - this.variable = variable; - this.reason = reason; - } -} - -/** - * Returns a non-secret descriptor of the value's shape, for logs. - * Never includes the value itself. - */ -function describeShape(value: unknown): string { - if (value === undefined) return "undefined"; - if (value === null) return "null"; - if (typeof value !== "string") return `non-string (${typeof value})`; - if (value.length === 0) return "empty string"; - return `string of length ${value.length}`; -} - -/** - * Validates that a value is a non-empty string. Returns the trimmed - * string or throws. - */ -export function validateRequired(value: unknown, name: string): string { - if (typeof value !== "string" || value.length === 0) { - throw new EnvValidationError(name, `expected non-empty string, got ${describeShape(value)}`); - } - return value; -} - -/** - * Validates that a value is a base64-encoded string that decodes to - * the expected number of bytes (when expectedBytes is provided). - * - * Returns the trimmed input. - */ -export function validateBase64( - value: unknown, - name: string, - expectedBytes?: number, -): string { - if (typeof value !== "string" || value.length === 0) { - throw new EnvValidationError( - name, - `expected base64 string, got ${describeShape(value)}`, - ); - } - const trimmed = value.trim(); - if (!BASE64_RE.test(trimmed)) { - throw new EnvValidationError( - name, - `not valid base64 (length ${trimmed.length}, contains characters outside [A-Za-z0-9+/=])`, - ); - } - let bytes: Uint8Array; - try { - const binary = atob(trimmed); - bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - } catch (err) { - throw new EnvValidationError( - name, - `base64 decode failed: ${(err as Error).message}`, - ); - } - if (expectedBytes !== undefined && bytes.length !== expectedBytes) { - throw new EnvValidationError( - name, - `decoded to ${bytes.length} bytes, expected ${expectedBytes}`, - ); - } - return trimmed; -} - -/** - * Validates the secrets the server cannot run without. Throws on the - * first missing or malformed value with a descriptor that's safe to log. - * - * Optional secrets (Stripe, Resend, OAuth providers) are not validated - * here because the surface that needs them returns 501 / falls back - * gracefully when unset. - */ -export function validateRequiredEnv(env: Record): void { - validateBase64(env.ENCRYPTION_KEY, "ENCRYPTION_KEY", 32); - validateRequired(env.BETTER_AUTH_SECRET, "BETTER_AUTH_SECRET"); - validateRequired(env.BETTER_AUTH_URL, "BETTER_AUTH_URL"); -} - -/** - * Like validateRequiredEnv but returns the EnvValidationError instead - * of throwing. Useful in surfaces that want to report all failures - * together (e.g. /health/deep) rather than stopping at the first one. - */ -export function checkRequiredEnv( - env: Record, -): EnvValidationError[] { - const errors: EnvValidationError[] = []; - const checks: Array<() => void> = [ - () => validateBase64(env.ENCRYPTION_KEY, "ENCRYPTION_KEY", 32), - () => validateRequired(env.BETTER_AUTH_SECRET, "BETTER_AUTH_SECRET"), - () => validateRequired(env.BETTER_AUTH_URL, "BETTER_AUTH_URL"), - ]; - for (const check of checks) { - try { - check(); - } catch (err) { - if (err instanceof EnvValidationError) errors.push(err); - else throw err; - } - } - return errors; -} diff --git a/packages/server/src/lib/env.test.ts b/packages/server/src/lib/env.test.ts new file mode 100644 index 0000000..4564c76 --- /dev/null +++ b/packages/server/src/lib/env.test.ts @@ -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/, + ); + }); +}); diff --git a/packages/server/src/lib/env.ts b/packages/server/src/lib/env.ts new file mode 100644 index 0000000..b9ece43 --- /dev/null +++ b/packages/server/src/lib/env.ts @@ -0,0 +1,139 @@ +/** + * Runtime env validation via @t3-oss/env-core + zod. + * + * Failures here surface BEFORE the cryptic atob/decrypt errors that + * would otherwise show up deep in a queue consumer or request handler. + * + * Pattern: + * - Call `parseEnv(env)` at the entry point of every long-running + * surface (HTTP middleware, queue consumer, scheduled handler). + * - On failure it throws an `EnvValidationError` whose `.variable` and + * `.reason` are safe to log — they never include the secret value + * itself, only descriptors like decoded byte count. + * + * Workers note: `createEnv` is per-invocation here (not a module-level + * singleton) because the Worker env binding is passed per-request, not + * read from `process.env`. Validation cost is a few zod refines — + * negligible. + */ + +import { createEnv } from "@t3-oss/env-core"; +import { z, type ZodError } from "zod"; + +/** + * Base64 string that decodes to exactly `expectedBytes` bytes. The error + * message is intentionally bounded — it reports the length of the input + * and the number of decoded bytes, but never the value itself. + */ +const base64WithBytes = (expectedBytes: number) => + z + .string() + .min(1, `expected base64 string, got empty string`) + .superRefine((raw, ctx) => { + const trimmed = raw.trim(); + if (!/^[A-Za-z0-9+/]+=*$/.test(trimmed)) { + ctx.addIssue({ + code: "custom", + message: `not valid base64 (length ${trimmed.length}, contains characters outside [A-Za-z0-9+/=])`, + }); + return; + } + let decoded: number; + try { + decoded = atob(trimmed).length; + } catch (err) { + ctx.addIssue({ + code: "custom", + message: `base64 decode failed: ${(err as Error).message}`, + }); + return; + } + if (decoded !== expectedBytes) { + ctx.addIssue({ + code: "custom", + message: `decoded to ${decoded} bytes, expected ${expectedBytes}`, + }); + } + }); + +/** + * Schema for the secrets the server cannot run without. Optional secrets + * (Stripe, Resend, OAuth providers) are not enforced here because the + * surface that needs them returns 501 / falls back gracefully when unset. + */ +const requiredEnvSchema = { + ENCRYPTION_KEY: base64WithBytes(32), + BETTER_AUTH_SECRET: z.string().min(1), + BETTER_AUTH_URL: z.url(), +} as const; + +/** + * Surfaced when validation fails. `.variable` and `.reason` are bounded + * to schema names + non-secret descriptors — safe to log. + */ +export class EnvValidationError extends Error { + readonly variable: string; + readonly reason: string; + + constructor(variable: string, reason: string) { + super(`Invalid env var ${variable}: ${reason}`); + this.name = "EnvValidationError"; + this.variable = variable; + this.reason = reason; + } +} + +function fromZodError(err: ZodError): EnvValidationError[] { + return err.issues.map((issue) => { + const variable = String(issue.path[0] ?? "(unknown)"); + return new EnvValidationError(variable, issue.message); + }); +} + +/** + * Validates required env. Throws the first EnvValidationError on failure. + */ +export function parseEnv(env: unknown): void { + try { + createEnv({ + server: requiredEnvSchema, + runtimeEnv: env as Record, + emptyStringAsUndefined: true, + onValidationError: (issues) => { + const errors = fromZodError({ issues } as ZodError); + throw errors[0] ?? new Error("env validation failed"); + }, + }); + } catch (err) { + if (err instanceof EnvValidationError) throw err; + throw err; + } +} + +/** + * Like parseEnv but returns all validation errors instead of throwing + * on the first. Used by /health/deep so the operator sees every + * misconfigured variable in one response. + */ +export function checkEnv(env: unknown): EnvValidationError[] { + const result = z.object(requiredEnvSchema).safeParse(env); + if (result.success) return []; + return fromZodError(result.error); +} + +/** + * Validates just `ENCRYPTION_KEY`. Used by `importKey` in crypto.ts so + * a bad key surfaces with the variable name instead of a cryptic atob + * error. Throws EnvValidationError on failure. + */ +export function validateEncryptionKey(value: unknown): string { + const result = base64WithBytes(32).safeParse(value); + if (!result.success) { + const issue = result.error.issues[0]; + throw new EnvValidationError( + "ENCRYPTION_KEY", + issue?.message ?? "invalid", + ); + } + return result.data.trim(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc128ea..ccba84f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: '@edgepush/orpc': specifier: workspace:* version: link:../orpc + '@t3-oss/env-core': + specifier: ^0.13.11 + version: 0.13.11(typescript@6.0.2)(zod@4.3.6) better-auth: specifier: ^1.6.9 version: 1.6.9(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(kysely@0.28.16))(next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.16.9)(tsx@4.21.0)(yaml@2.8.3))) @@ -211,6 +214,9 @@ importers: typescript: specifier: ^6.0.2 version: 6.0.2 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.16.9)(tsx@4.21.0)(yaml@2.8.3)) packages/shared: dependencies: @@ -2603,6 +2609,23 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@t3-oss/env-core@0.13.11': + resolution: {integrity: sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ==} + peerDependencies: + arktype: ^2.1.0 + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 || ^4.0.0 + peerDependenciesMeta: + arktype: + optional: true + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + '@tailwindcss/node@4.2.4': resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} @@ -6981,7 +7004,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0)': + '@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -6996,38 +7019,38 @@ snapshots: '@cloudflare/workers-types': 4.20260426.1 '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(kysely@0.28.16))': + '@better-auth/drizzle-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(kysely@0.28.16))': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(kysely@0.28.16) - '@better-auth/kysely-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.16)': + '@better-auth/kysely-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.16)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: kysely: 0.28.16 - '@better-auth/memory-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/memory-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/mongo-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/mongo-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/prisma-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/prisma-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/telemetry@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': + '@better-auth/telemetry@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -8560,6 +8583,11 @@ snapshots: dependencies: tslib: 2.8.1 + '@t3-oss/env-core@0.13.11(typescript@6.0.2)(zod@4.3.6)': + optionalDependencies: + typescript: 6.0.2 + zod: 4.3.6 + '@tailwindcss/node@4.2.4': dependencies: '@jridgewell/remapping': 2.3.5 @@ -9159,13 +9187,13 @@ snapshots: better-auth@1.6.9(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(kysely@0.28.16))(next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.16.9)(tsx@4.21.0)(yaml@2.8.3))): dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(kysely@0.28.16)) - '@better-auth/kysely-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.16) - '@better-auth/memory-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/mongo-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/prisma-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/telemetry': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(kysely@0.28.16)) + '@better-auth/kysely-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.16) + '@better-auth/memory-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/mongo-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/prisma-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/telemetry': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260426.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.2.0 @@ -9810,7 +9838,7 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1)) @@ -9843,7 +9871,7 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -9858,7 +9886,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9