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 f4159a9..9a1e903 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, parseEnv } from "./lib/env"; 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 { + 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); diff --git a/packages/server/src/dispatch.ts b/packages/server/src/dispatch.ts index 30c968a..d819b64 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, parseEnv } from "./lib/env"; 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 { + 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 @@ -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..9da443b 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, checkEnv } from "./lib/env"; 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,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); diff --git a/packages/server/src/lib/crypto.ts b/packages/server/src/lib/crypto.ts index 258de86..819d282 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 { validateEncryptionKey } from "./env"; + 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 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, 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/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"] } 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