diff --git a/.env.example b/.env.example index cf0e25f4c..ef38fe789 100644 --- a/.env.example +++ b/.env.example @@ -81,8 +81,10 @@ SMTP_SECURE="false" # --- Storage (optional) --- # Backend defaults to S3 when all credentials are present, otherwise local. -# Vercel defaults to private Blob. Explicit selection: local, s3, blob. +# Vercel defaults to private Blob; Workers uses native R2. Explicit selection: local, s3, blob, r2. # STORAGE_BACKEND="local" +# Cloudflare Workers uses CLOUDFLARE=1 and native STORAGE_BACKEND="r2" bindings. +# Configure it through wrangler.jsonc and Wrangler secrets, not this Docker environment template. # BLOB_READ_WRITE_TOKEN="" # BLOB_STORE_ID="" # DEPLOYMENT_NAMESPACE="default" diff --git a/.github/workflows/cloudflare.yml b/.github/workflows/cloudflare.yml new file mode 100644 index 000000000..40f844e1e --- /dev/null +++ b/.github/workflows/cloudflare.yml @@ -0,0 +1,45 @@ +name: Cloudflare compatibility + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + worker: + runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 20 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_PASSWORD: postgres + ports: [5432:5432] + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s --health-timeout 5s --health-retries 10 + steps: + - if: ${{ vars.USE_BLACKSMITH != 'true' }} + uses: actions/checkout@v6 + with: + persist-credentials: false + - if: ${{ vars.USE_BLACKSMITH == 'true' }} + uses: useblacksmith/checkout@v1 + with: + persist-credentials: false + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + # No cloud account or deployment credentials: forks exercise the real local Workers runtime. + - run: pnpm check:cloudflare + - run: pnpm test:cloudflare diff --git a/.gitignore b/.gitignore index 72aff5667..ac9450829 100644 --- a/.gitignore +++ b/.gitignore @@ -5,12 +5,14 @@ node_modules # Build Outputs dist dist-prerender +dist-cloudflare .vercel .wrangler # Environment Variables .env* !.env.example +.dev.vars* # IDEs and Editors *~ @@ -66,4 +68,3 @@ temp # Git Hooks .vite-hooks - diff --git a/.oxfmtrc.json b/.oxfmtrc.json index cba062e08..02ca99d13 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -37,6 +37,7 @@ "**/.turbo/**", "**/.output/**", "**/dist/**", + "**/dist-cloudflare/**", "**/dist-prerender/**", "**/.vercel/**", "**/.wrangler/**", diff --git a/.oxlintrc.json b/.oxlintrc.json index 35358c942..4ee7a9382 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -9,6 +9,7 @@ "**/.turbo/**", "**/.output/**", "**/dist/**", + "**/dist-cloudflare/**", "**/dist-prerender/**", "**/.vercel/**", "**/.wrangler/**", diff --git a/README.md b/README.md index 8defd35d9..c292af36e 100644 --- a/README.md +++ b/README.md @@ -197,12 +197,14 @@ The full documentation lives at [docs.rxresu.me](https://docs.rxresu.me): ## Self-Hosting -Reactive Resume supports Docker and Vercel Hobby. +Reactive Resume supports Docker, Vercel Hobby, and Cloudflare Workers Paid. [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Freactive-resume%2Freactive-resume&project-name=reactive-resume&repository-name=reactive-resume&env=AUTH_SECRET%2CENCRYPTION_SECRET&envDescription=Generate+two+independent+secrets+with+openssl+rand+-hex+32.+Keep+these+values+across+deployments.&envLink=https%3A%2F%2Fdocs.rxresu.me%2Fself-hosting%2Fvercel&stores=%5B%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22neon%22%2C%22productSlug%22%3A%22neon%22%7D%2C%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22upstash%22%2C%22productSlug%22%3A%22upstash-kv%22%7D%2C%7B%22type%22%3A%22blob%22%2C%22access%22%3A%22private%22%7D%5D) Vercel provisions Neon PostgreSQL, private Blob storage, and Upstash Redis through its deployment wizard. Supply two persistent secrets, then deploy. See the [Vercel guide](docs/self-hosting/vercel.mdx) for setup, limits, and optional SMTP/OAuth configuration. +Cloudflare deployment uses Workers with Static Assets, private R2 storage, SQLite Durable Objects, and PostgreSQL through Hyperdrive. No Redis or container is required. See the [Cloudflare guide](docs/self-hosting/cloudflare.mdx) for setup, pricing, and runtime limits. The first version requires an existing PostgreSQL database; fully Cloudflare-native database provisioning and a deploy button are separate work. + For Docker, the stack includes: - **PostgreSQL** — Database for storing user data and resumes diff --git a/apps/server/package.json b/apps/server/package.json index d7994bdfa..ab76e68e8 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -50,6 +50,7 @@ "@reactive-resume/db": "workspace:*", "@reactive-resume/env": "workspace:*", "@reactive-resume/mcp": "workspace:*", + "@reactive-resume/pdf": "workspace:*", "@reactive-resume/schema": "workspace:*", "@reactive-resume/utils": "workspace:*", "@sindresorhus/slugify": "^3.0.1", @@ -91,6 +92,7 @@ "zod": "catalog:" }, "devDependencies": { + "@cloudflare/workers-types": "catalog:", "@reactive-resume/config": "workspace:*", "@types/node": "catalog:", "@types/pg": "catalog:", diff --git a/apps/server/src/cloudflare/coordination.ts b/apps/server/src/cloudflare/coordination.ts new file mode 100644 index 000000000..b59ff996e --- /dev/null +++ b/apps/server/src/cloudflare/coordination.ts @@ -0,0 +1,72 @@ +import type { DurableObjectState, WebSocket } from "@cloudflare/workers-types"; +import { z } from "zod"; + +const inputSchema = z.discriminatedUnion("operation", [ + z.object({ + operation: z.literal("consume"), + window: z.number().positive(), + max: z.number().int().positive(), + rolling: z.boolean().optional(), + }), + z.object({ operation: z.literal("set"), value: z.string(), ttl: z.number().positive() }), + z.object({ operation: z.literal("get") }), + z.object({ operation: z.literal("publish"), value: z.string().max(8192) }), +]); +type Entry = { expiresAt: number; count?: number; value?: string }; +declare const WebSocketPair: new () => { 0: WebSocket; 1: WebSocket }; + +/** One object per key: unrelated users never contend for the same counter or run state. */ +export class Coordination { + constructor(private readonly ctx: DurableObjectState) {} + + async fetch(request: Request): Promise { + if (request.headers.get("upgrade") === "websocket") { + const pair = new WebSocketPair(); + this.ctx.acceptWebSocket(pair[1]); + return new Response(null, { status: 101, webSocket: pair[0] } as ResponseInit); + } + const input = inputSchema.parse(await request.json()); + if (input.operation === "publish") { + for (const socket of this.ctx.getWebSockets()) { + try { + socket.send(input.value); + } catch { + socket.close(1011, "Delivery failed"); + } + } + return Response.json(null); + } + const now = Date.now(); + const result = this.ctx.storage.transactionSync(() => { + const stored = this.ctx.storage.kv.get("entry"); + const entry = stored && stored.expiresAt > now ? stored : undefined; + if (input.operation === "get") return entry?.value ?? null; + if (input.operation === "set") { + this.ctx.storage.kv.put("entry", { value: input.value, expiresAt: now + input.ttl }); + return null; + } + const count = entry?.count ?? 0; + const allowed = count < input.max; + const expiresAt = allowed && input.rolling ? now + input.window : (entry?.expiresAt ?? now + input.window); + if (allowed) this.ctx.storage.kv.put("entry", { count: count + 1, expiresAt }); + return { allowed, remaining: Math.max(0, input.max - count - (allowed ? 1 : 0)), reset: expiresAt }; + }); + // Schedule only once per active key. The alarm reschedules itself if accepted requests extend the expiry. + const entry = this.ctx.storage.kv.get("entry"); + if (entry && (await this.ctx.storage.getAlarm()) === null) await this.ctx.storage.setAlarm(entry.expiresAt); + return Response.json(result); + } + + async alarm(): Promise { + const entry = this.ctx.storage.kv.get("entry"); + if (entry && entry.expiresAt > Date.now()) await this.ctx.storage.setAlarm(entry.expiresAt); + else await this.ctx.storage.deleteAll(); + } + + webSocketClose(socket: WebSocket, code: number, reason: string): void { + socket.close(code, reason); + } + webSocketError(socket: WebSocket): void { + socket.close(1011, "Connection failed"); + } +} diff --git a/apps/server/src/cloudflare/index.ts b/apps/server/src/cloudflare/index.ts new file mode 100644 index 000000000..1e20d6e54 --- /dev/null +++ b/apps/server/src/cloudflare/index.ts @@ -0,0 +1,190 @@ +import type { + DurableObjectNamespace, + ExecutionContext, + Fetcher, + Hyperdrive, + R2Bucket, +} from "@cloudflare/workers-types"; +import type { CoordinationService } from "@reactive-resume/db/coordination"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { on } from "node:events"; +import { isIP } from "node:net"; +import wasm from "@formepdf/core/pkg-web/forme_bg.wasm"; +import { init } from "@formepdf/core/worker"; +import { Pool } from "pg"; +import { configureAgentStreamLifetime } from "@reactive-resume/api/features/agent/streams"; +import { configureStorageService, getStorageService } from "@reactive-resume/api/features/storage"; +import { initializeAuth } from "@reactive-resume/auth/config"; +import { withDatabasePool } from "@reactive-resume/db/client"; +import { configureCoordination } from "@reactive-resume/db/coordination"; +import { env } from "@reactive-resume/env/server"; +import { configureOwnPictureReader } from "@reactive-resume/pdf/server"; +import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit"; +import { createApp } from "../http/app"; +import { R2StorageService } from "./r2"; + +export { Coordination } from "./coordination"; + +export type CloudflareBindings = { + ASSETS: Fetcher; + HYPERDRIVE: Hyperdrive; + BUCKET: R2Bucket; + COORDINATION: DurableObjectNamespace; +}; + +const requests = new AsyncLocalStorage<{ bindings: CloudflareBindings; ctx: ExecutionContext }>(); +configureAgentStreamLifetime((promise) => requests.getStore()?.ctx.waitUntil(promise)); +configureOwnPictureReader(async (key) => { + const file = await getStorageService().read(key); + if (!file || file.size > 12_000_000) throw new Error("Picture unavailable or exceeds 12 MB"); + return file.data; +}); + +const sharedCall = async (key: string, input: object): Promise => { + const namespace = requests.getStore()?.bindings.COORDINATION; + if (!namespace) throw new Error("Cloudflare coordination binding is missing"); + const response = await namespace.get(namespace.idFromName(key)).fetch("https://coordination/", { + method: "POST", + body: JSON.stringify(input), + }); + if (!response.ok) throw new Error("Cloudflare coordination is unavailable"); + return response.json(); +}; +configureCoordination({ + consume: (key, rule) => sharedCall(key, { operation: "consume", ...rule }), + get: (key) => sharedCall(key, { operation: "get" }), + set: async (key, value, ttl) => { + await sharedCall(key, { operation: "set", value, ttl }); + }, + publish: async (key, value) => { + await sharedCall(key, { operation: "publish", value }); + }, + async *subscribe(key, signal) { + const namespace = requests.getStore()?.bindings.COORDINATION; + if (!namespace) throw new Error("Cloudflare coordination binding is missing"); + const response = await namespace + .get(namespace.idFromName(key)) + .fetch("https://coordination/", { headers: { upgrade: "websocket" } }); + const socket = response.webSocket; + if (!socket) throw new Error("Cloudflare subscription is unavailable"); + const closed = new AbortController(); + const stopped = signal ? AbortSignal.any([signal, closed.signal]) : closed.signal; + const messages = on(socket as unknown as EventTarget, "message", { signal: stopped }); + socket.addEventListener("close", () => closed.abort()); + socket.addEventListener("error", () => closed.abort()); + socket.accept(); + try { + for await (const [event] of messages) { + const data = (event as MessageEvent).data as unknown; + if (typeof data === "string") yield data; + } + } catch (error) { + if (!stopped.aborted) throw error; + } finally { + socket.close(1000, "Subscription ended"); + } + }, +} satisfies CoordinationService); + +const app = createApp({ + serveStatic: false, + trustedClient: (request) => request.headers.get("x-real-ip") ?? "unknown", + readWebFile: async (path) => { + const assetPath = path.includes("dist-prerender/") + ? `/_prerender/${path.split("dist-prerender/")[1]}` + : "/index.html"; + const response = await requests.getStore()?.bindings.ASSETS.fetch(new URL(assetPath, env.APP_URL).href); + if (!response?.ok) throw new Error("Web asset is unavailable"); + return response.text(); + }, +}); + +/** Keep the request's pool alive until streaming finishes, including client cancellation. */ +function closePoolAfterResponse( + response: Response, + pool: Pool, + bindings: CloudflareBindings, + ctx: ExecutionContext, +): Response { + let closed = false; + const close = () => { + if (!closed) { + closed = true; + ctx.waitUntil(pool.end()); + } + }; + const run = (callback: () => T) => requests.run({ bindings, ctx }, () => withDatabasePool(pool, callback)); + if (!response.body) { + close(); + return response; + } + const reader = response.body.getReader(); + return new Response( + new ReadableStream({ + pull(controller) { + return run(async () => { + try { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + close(); + } else controller.enqueue(value); + } catch (error) { + controller.error(error); + close(); + } + }); + }, + cancel(reason) { + return run(async () => { + try { + await reader.cancel(reason); + } finally { + close(); + } + }); + }, + }), + response, + ); +} + +export default { + async fetch(request: Request, bindings: CloudflareBindings, ctx: ExecutionContext): Promise { + if (new URL(request.url).pathname.startsWith("/_prerender")) return new Response("Not Found", { status: 404 }); + if (!env.CLOUDFLARE || env.STORAGE_BACKEND !== "r2" || !env.FLAG_DISABLE_IMAGE_PROCESSING || env.REDIS_URL) { + throw new Error("Cloudflare requires CLOUDFLARE=1, R2, disabled image processing and no REDIS_URL."); + } + configureStorageService(new R2StorageService(bindings.BUCKET, env.DEPLOYMENT_NAMESPACE)); + const headers = new Headers(request.headers); + const ip = headers.get("cf-connecting-ip"); + for (const name of TRUSTED_IP_HEADERS) headers.delete(name); + if (ip && isIP(ip)) { + headers.set("x-real-ip", ip); + headers.set("x-forwarded-for", ip); + } + const pool = new Pool({ + connectionString: bindings.HYPERDRIVE.connectionString, + max: 1, + connectionTimeoutMillis: 10_000, + }); + const logError = (error: Error) => { + if (!pool.ending) console.error("[cloudflare] Database connection failed", error.message); + }; + pool.on("error", logError); + pool.on("connect", (client) => client.on("error", logError)); + try { + const response = await requests.run({ bindings, ctx }, () => + withDatabasePool(pool, async () => { + await initializeAuth(); + await init(wasm); + return app.fetch(new Request(request, { headers })); + }), + ); + return closePoolAfterResponse(response, pool, bindings, ctx); + } catch (error) { + ctx.waitUntil(pool.end()); + throw error; + } + }, +}; diff --git a/apps/server/src/cloudflare/r2.ts b/apps/server/src/cloudflare/r2.ts new file mode 100644 index 000000000..1cceb3e5f --- /dev/null +++ b/apps/server/src/cloudflare/r2.ts @@ -0,0 +1,63 @@ +import type { R2Bucket } from "@cloudflare/workers-types"; +import type { StorageService } from "@reactive-resume/api/features/storage"; + +/** Keep the bucket private. Public upload routes and authenticated attachment reads own access control. */ +export class R2StorageService implements StorageService { + constructor( + private readonly bucket: R2Bucket, + private readonly namespace: string, + ) {} + + private path(key: string) { + if (key.startsWith("/") || key.includes("\\") || key.split("/").some((part) => part === "." || part === "..")) { + throw new Error("Invalid storage key"); + } + return `${this.namespace}/${key}`; + } + + async list(prefix: string): Promise { + const keys: string[] = []; + let cursor: string | undefined; + do { + const page = await this.bucket.list({ prefix: this.path(prefix), ...(cursor ? { cursor } : {}) }); + keys.push(...page.objects.map((object) => object.key.slice(this.namespace.length + 1))); + cursor = page.truncated ? page.cursor : undefined; + } while (cursor); + return keys; + } + + async write(input: { key: string; data: Uint8Array; contentType: string; private?: boolean }): Promise { + await this.bucket.put(this.path(input.key), input.data, { httpMetadata: { contentType: input.contentType } }); + } + + async read(key: string) { + const object = await this.bucket.get(this.path(key)); + if (!object) return null; + return { + data: new Uint8Array(await object.arrayBuffer()), + size: object.size, + etag: object.httpEtag, + lastModified: object.uploaded, + ...(object.httpMetadata?.contentType ? { contentType: object.httpMetadata.contentType } : {}), + }; + } + + async delete(key: string): Promise { + const prefix = key.endsWith("/") ? key : `${key}/`; + const keys = (await this.list(key)).filter((candidate) => candidate === key || candidate.startsWith(prefix)); + // R2 permits up to 1,000 keys per delete operation. + for (let start = 0; start < keys.length; start += 1_000) { + await this.bucket.delete(keys.slice(start, start + 1_000).map((candidate) => this.path(candidate))); + } + return keys.length > 0; + } + + async healthcheck() { + try { + await this.bucket.list({ prefix: this.path(".health"), limit: 1 }); + return { status: "healthy" as const, type: "r2" as const, message: "R2 storage is accessible" }; + } catch { + return { status: "unhealthy" as const, type: "r2" as const, message: "R2 storage is unavailable" }; + } + } +} diff --git a/apps/server/src/cloudflare/sharp.ts b/apps/server/src/cloudflare/sharp.ts new file mode 100644 index 000000000..8161590b1 --- /dev/null +++ b/apps/server/src/cloudflare/sharp.ts @@ -0,0 +1,5 @@ +export default function sharp(): never { + throw new Error( + "Cloudflare Workers requires FLAG_DISABLE_IMAGE_PROCESSING=true; upload processing uses native Node.js modules.", + ); +} diff --git a/apps/server/src/cloudflare/wasm.d.ts b/apps/server/src/cloudflare/wasm.d.ts new file mode 100644 index 000000000..14ea26717 --- /dev/null +++ b/apps/server/src/cloudflare/wasm.d.ts @@ -0,0 +1,4 @@ +declare module "*.wasm" { + const wasm: WebAssembly.Module; + export default wasm; +} diff --git a/apps/server/src/http/app.test.ts b/apps/server/src/http/app.test.ts index 41131d9cc..e85e05037 100644 --- a/apps/server/src/http/app.test.ts +++ b/apps/server/src/http/app.test.ts @@ -187,7 +187,7 @@ describe("createApp", () => { expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(2, unknownOpenApiRequest, "unknown"); }); - it("routes GET / to the web app handler so SEO markup is injected", async () => { + it("routes GET / to the web app before static files", async () => { const { createApp } = await import("./app"); const app = createApp(); const request = new Request("http://localhost:3001/"); @@ -195,7 +195,7 @@ describe("createApp", () => { const response = await app.fetch(request); expect(response.status).toBe(200); - expect(mocks.handleWebApp).toHaveBeenCalledWith(request); + expect(await response.text()).toBe("web"); expect(mocks.serveWebDistStatic).not.toHaveBeenCalled(); }); diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index eb5bb65c1..b36b15968 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -1,3 +1,4 @@ +import type { ReadWebFile } from "../static/web"; import type { Http2Bindings, HttpBindings } from "@hono/node-server"; import type { Context } from "hono"; import { BlockList, isIP } from "node:net"; @@ -52,6 +53,7 @@ const getTrustedClient = (context: Context, proxies: BlockLis type AppOptions = { serveStatic?: boolean; trustedClient?: (request: Request) => string; + readWebFile?: ReadWebFile; }; export function createApp(options: AppOptions = {}) { @@ -122,9 +124,9 @@ export function createApp(options: AppOptions = {}) { // Must precede the static middleware: serveStatic resolves "/" to dist/index.html and would // return it verbatim, skipping the OpenGraph/Twitter/canonical/JSON-LD injection in handleWebApp. - app.on(["GET", "HEAD"], "/", (c) => handleWebApp(c.req.raw)); - if (options.serveStatic !== false) app.use("/*", serveWebDistStatic); - app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw)); + app.on(["GET", "HEAD"], "/", (c) => handleWebApp(c.req.raw, options.readWebFile)); + if (options.serveStatic !== false && serveWebDistStatic) app.use("/*", serveWebDistStatic); + app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw, options.readWebFile)); return app; } diff --git a/apps/server/src/http/health.ts b/apps/server/src/http/health.ts index 96c333847..d51da9f9b 100644 --- a/apps/server/src/http/health.ts +++ b/apps/server/src/http/health.ts @@ -39,7 +39,9 @@ function publicCheck(check: CheckResult, name: "Database" | "Storage" | "Redis") status: check.status, latencyMs: check.latencyMs, error: `${name} health check failed.`, - ...(check.type === "local" || check.type === "s3" || check.type === "blob" ? { type: check.type } : {}), + ...(check.type === "local" || check.type === "s3" || check.type === "blob" || check.type === "r2" + ? { type: check.type } + : {}), }; } diff --git a/apps/server/src/static/web.ts b/apps/server/src/static/web.ts index 2127f3536..45a2757af 100644 --- a/apps/server/src/static/web.ts +++ b/apps/server/src/static/web.ts @@ -258,14 +258,16 @@ async function createPublicResumeSeoMarkup(pathname: string, origin: string) { }; } -export const serveWebDistStatic = serveStatic({ - root: staticRoot, - onFound: (_path, context) => { - if (/^\/videos\/.*-v\d+\.(?:mp4|webp)$/.test(context.req.path)) { - context.header("Cache-Control", "public, max-age=31536000, immutable"); - } - }, -}); +export const serveWebDistStatic = env.CLOUDFLARE + ? undefined + : serveStatic({ + root: staticRoot, + onFound: (_path, context) => { + if (/^\/videos\/.*-v\d+\.(?:mp4|webp)$/.test(context.req.path)) { + context.header("Cache-Control", "public, max-age=31536000, immutable"); + } + }, + }); function getFallbackResponseHeaders(pathname: string) { if (pathname === "/" && env.ROOT_RESUME_ID) { @@ -346,7 +348,9 @@ const prerenderedPages: Record< }; // ponytail: GET and HEAD share the same routing logic; method determines body presence -export async function handleWebApp(request: Request) { +export type ReadWebFile = (path: string) => Promise; + +export async function handleWebApp(request: Request, readFile: ReadWebFile = (path) => fs.readFile(path, "utf-8")) { const isHead = request.method === "HEAD"; const pathname = new URL(request.url).pathname; @@ -359,7 +363,7 @@ export async function handleWebApp(request: Request) { if (isHead) return new Response(null, { status: 200, headers }); - const html = await fs.readFile(indexHtmlPath, "utf-8"); + const html = await readFile(indexHtmlPath); if (pathname === "/" && env.ROOT_RESUME_ID) { const canonicalUrl = new URL("/", env.APP_URL).toString(); @@ -380,9 +384,9 @@ export async function handleWebApp(request: Request) { const { locale, requested } = getPageLocale(request); const origin = new URL(env.APP_URL).origin; // Without a prerendered page (a build that skipped it), the app renders the page in the browser. - const page = await fs - .readFile(`${prerenderRoot}/${prerendered.name}/${locale}.html`, "utf-8") - .catch(() => prerendered.fallback(html)); + const page = await readFile(`${prerenderRoot}/${prerendered.name}/${locale}.html`).catch(() => + prerendered.fallback(html), + ); const markup = createPageSeoMarkup({ canonicalUrl: new URL(pathname, env.APP_URL).toString(), locale, diff --git a/docs/docs.json b/docs/docs.json index 6aea12d00..b05370681 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -212,6 +212,7 @@ "self-hosting/job-search-and-ai", "self-hosting/kubernetes", "self-hosting/vercel", + "self-hosting/cloudflare", "self-hosting/sso", "self-hosting/upgrading-to-v6", "self-hosting/migration" diff --git a/docs/self-hosting/cloudflare.mdx b/docs/self-hosting/cloudflare.mdx new file mode 100644 index 000000000..23a110a60 --- /dev/null +++ b/docs/self-hosting/cloudflare.mdx @@ -0,0 +1,168 @@ +--- +title: "Self-hosting on Cloudflare" +description: "Run Reactive Resume on Workers with Static Assets, private R2 storage, Durable Objects, and PostgreSQL through Hyperdrive." +--- + +Reactive Resume runs on **Cloudflare Workers**, with the web app served by **Workers Static Assets**, files in a private **R2** bucket, and shared coordination in **SQLite-backed Durable Objects**. PDF exports use WebAssembly inside Workers; no container, browser service, Redis server, or Cloudflare Images subscription is required. + + + This is the first Cloudflare runtime milestone. Application data still lives in PostgreSQL, connected through + Hyperdrive. Hyperdrive pools connections to your database; it does not host a PostgreSQL database. D1 uses SQLite and + is not compatible with the current PostgreSQL schema and transactions. A fully Cloudflare-hosted database and an + end-to-end deploy button require a separate database migration and provisioning workflow. + + +## Services and cost + +| Service | Purpose | Cost controls | +| ----------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Workers + Static Assets | HTTP/API/MCP, dynamic page metadata, static web files | Assets go directly to Cloudflare's asset service. Only dynamic requests invoke the application. | +| R2 Standard | Pictures, job application files, assistant attachments | Private bucket; native binding needs no S3 credentials. No R2 egress charge. | +| SQLite Durable Objects | Atomic rate limits, live resume updates, assistant cancellation and liveness | Counters expire. Live updates use WebSocket hibernation, with no periodic database polling. | +| Hyperdrive | PostgreSQL connection pooling | Included with Workers; disable query caching to keep sessions and edits current. | +| PostgreSQL | Accounts, resumes, letters, applications, assistant history | Use an existing database or choose a provider with an allowance appropriate for your installation. | + +This setup requires **Workers Paid**: password hashing and PDF generation exceed the Free plan's 10 ms CPU budget. As of October 2026, Workers Paid starts at **$5/month**, including 10 million dynamic requests and 30 million CPU milliseconds. Static asset requests are free and unlimited. The checked-in configuration caps CPU at 30 seconds per request; network waiting time is separate. See [Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/) and [limits](https://developers.cloudflare.com/workers/platform/limits/). + +R2 Standard includes 10 GB-month of storage, 1 million Class A operations and 10 million Class B operations per month. Use Standard storage for these small, frequently accessed files; Infrequent Access adds retrieval fees and a minimum storage duration. See [R2 pricing](https://developers.cloudflare.com/r2/pricing/). + +Hyperdrive pooling is included in Workers Paid, with no separate Hyperdrive query charge. Your PostgreSQL provider bills separately. [Hyperdrive pricing](https://developers.cloudflare.com/hyperdrive/platform/pricing/). + +If your PostgreSQL database scales to zero, avoid frequent uptime probes to `/api/health`: it queries the database and can keep compute awake. + +Durable Object requests, active duration, and SQLite storage have their own included allowances and usage charges. Idle hibernating WebSockets avoid active duration charges, but rate-limit calls and assistant heartbeats still count as operations. Set billing alerts and review [Durable Objects pricing](https://developers.cloudflare.com/durable-objects/platform/pricing/). The $5 Workers subscription is a starting cost, not a guaranteed total bill; database, email and AI provider costs are additional. + +## Before you start + +You need a Cloudflare account with Workers Paid and R2 enabled, Node.js 24, pnpm 12.8.1, and a PostgreSQL database reachable by Hyperdrive. Keep PostgreSQL TLS enabled. Run the commands below from the repository root. + +```bash +pnpm install --frozen-lockfile +pnpm exec wrangler login +``` + +Generate **two independent** secrets and save them in a password manager: + +```bash +openssl rand -hex 32 +openssl rand -hex 32 +``` + +Keep `AUTH_SECRET` and `ENCRYPTION_SECRET` across deployments. Changing them invalidates sessions or makes saved AI credentials unreadable. + +## Configure resources + +1. Create a private R2 bucket: + + ```bash + pnpm exec wrangler r2 bucket create reactive-resume + ``` + + Leave public access and `r2.dev` disabled. The application exposes public profile pictures itself; other uploads require the owning account's session. + +2. Create a **Hyperdrive configuration** in the Cloudflare dashboard using your PostgreSQL connection details. Turn **query caching off**. Do not cache authentication queries, permission checks, or resume edits. If creating it through Wrangler, include `--caching-disabled`; the [Hyperdrive setup guide](https://developers.cloudflare.com/hyperdrive/get-started/) describes the connection options. + +3. Edit root `wrangler.jsonc`: + + - Replace the all-zero `hyperdrive[0].id` with your Hyperdrive ID. + - Set `vars.APP_URL` to your final HTTPS origin, for example `https://reactive-resume..workers.dev` or `https://resume.example.com`. + - If you chose another bucket name, update `r2_buckets[0].bucket_name`. + - Keep `CLOUDFLARE=1`, `STORAGE_BACKEND=r2`, and `FLAG_DISABLE_IMAGE_PROCESSING=true`. + - Keep both compatibility flags. `global_fetch_strictly_public` is part of the external image/page reader's private-network protection. + + Wrangler creates the `Coordination` Durable Object namespace and its SQLite migration on deployment. No manual table or Redis setup is needed. + +4. Store the secrets using Wrangler's interactive prompts: + + ```bash + pnpm exec wrangler secret put AUTH_SECRET + pnpm exec wrangler secret put ENCRYPTION_SECRET + ``` + + + Do not copy Docker's `.env.example` or `.env.local` into Cloudflare. Container hostnames and S3 credentials select the + wrong services. Do not set `DATABASE_URL` or `REDIS_URL` on the Worker: its database connection comes from the + Hyperdrive binding and its coordination comes from Durable Objects. + + +## Migrate and deploy + +Apply the repository's PostgreSQL migrations from a trusted machine **before** deploying. Set `DATABASE_URL` in your shell or secret manager to the database's direct connection string, then run: + +```bash +pnpm --filter @reactive-resume/db db:migrate +``` + +This package command reads `DATABASE_URL` directly. The root `pnpm db:migrate` command loads `.env.local`, so use the package command above to avoid selecting an unrelated local database. Never commit the connection string. Back up an existing database before an upgrade. + +Build and inspect the deployment without publishing: + +```bash +pnpm check:cloudflare +``` + +Publish the application and its static assets: + +```bash +pnpm deploy:cloudflare +``` + +These commands compile the web app and Worker into `apps/server/dist-cloudflare`. Wrangler uploads the Worker, WebAssembly PDF engine, prompt files, and static assets. Database migrations run on your machine, not during requests. Builds and dry runs do not mutate the database. + +For Git deployments through **Workers Builds**, keep the repository root as the project root. Use `pnpm build:cloudflare` as the build command and `pnpm exec wrangler deploy` as the deploy command. Configure the same bindings and persistent secrets; apply migrations in a trusted deployment step before publishing. Preview builds need separate PostgreSQL, Hyperdrive, R2, and Durable Object resources. + +## Check the installation + +1. Open `/api/health`. Database and storage should report `healthy`, with storage type `r2`. +2. Create an account, create a resume, and refresh the page to verify persistence. +3. Upload a profile picture. Share the resume publicly and download its PDF. +4. Open the same resume in two tabs. An edit should invalidate the other tab's data. +5. Optional: add SMTP and OAuth credentials, or server AI credentials, using Wrangler secrets and the [environment variable reference](/self-hosting/environment-variables). Redeploy after changing plain variables. + +For a custom domain, add it to the Worker in Cloudflare and update `APP_URL` and your OAuth callback URLs. Keep `DEPLOYMENT_NAMESPACE` unchanged so stored files remain available. + +## Runtime differences + +- Uploaded images retain their original format. Workers does not run native Sharp, so automatic server resizing/conversion stays disabled. Upload PNG, JPEG or WebP pictures; these formats work in PDF exports. +- Assistant replies stream live, and cancellation works across Worker instances. An in-progress stream cannot reconnect after a reload in this Redis-free setup; completed messages remain in PostgreSQL. +- Workers has a 128 MB memory limit. Large resumes, large images, or multiple concurrent server PDF renders can exceed it. Normal browser downloads render in the browser, reducing server CPU cost. Monitor Worker CPU/memory errors before increasing limits or serving a large public installation. +- SMTP port 25 is blocked by Cloudflare. Use a provider on port 465/587, or another supported submission port. Without SMTP, verification/reset emails appear in logs. +- Private-network image and job-posting URLs are refused. Public images and page redirects are checked before fetching. Operator-configured external AI/search providers still require reachable public endpoints. +- The Worker does not run PostgreSQL migrations or the Node startup schema check. Apply migrations before every upgrade and verify `/api/health` after deployment. + +## Local development and smoke tests + +Build first: + +```bash +pnpm build:cloudflare +``` + +Create an ignored `.dev.vars` containing local-only secrets and the local application origin: + +```dotenv +APP_URL=http://localhost:8787 +AUTH_SECRET= +ENCRYPTION_SECRET= +``` + +Point Hyperdrive's emulator at an already migrated, disposable local PostgreSQL database: + +```bash +export CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE='' +pnpm dev:cloudflare +``` + +The development script prevents Wrangler from reading `.env`/`.env.local`; `.dev.vars` supplies Worker variables. Local R2 and Durable Object data stays under ignored `.wrangler/`. Rebuild after source changes, then restart Wrangler. + +With local PostgreSQL running, test the **built** Worker and its real emulated bindings: + +```bash +pnpm test:cloudflare +``` + +The smoke test creates, migrates, and deletes a new disposable database. It covers authentication, resume transactions, R2 uploads/access control, live updates, PDF pictures, assistant streaming/cancellation, atomic counters across eviction, and state expiry. It never deploys remotely. Its default admin URL is the development Compose PostgreSQL on `localhost:5432`; use `CLOUDFLARE_TEST_DATABASE_ADMIN_URL` to select another **local** database admin connection. + +## Backups and upgrades + +Back up PostgreSQL and R2, and preserve both secrets separately. Redeploy the same Worker and bindings after applying migrations. Keep the Durable Object migration history in `wrangler.jsonc`; do not remove an applied tag. Rolling back code does not roll back PostgreSQL schema or file changes. Moving from Docker/Vercel does not copy data or uploads automatically. diff --git a/docs/self-hosting/environment-variables.mdx b/docs/self-hosting/environment-variables.mdx index 174177260..95faf5579 100644 --- a/docs/self-hosting/environment-variables.mdx +++ b/docs/self-hosting/environment-variables.mdx @@ -112,7 +112,7 @@ backends. | Variable | Default | Description | | ----------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `STORAGE_BACKEND` | automatic | `local`, `s3` or `blob`. When unset: `s3` if `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` and `S3_BUCKET` are all set; otherwise `blob` on Vercel and `local` everywhere else. | +| `STORAGE_BACKEND` | automatic | `local`, `s3`, `blob` or `r2` (Workers only). When unset: `r2` on Cloudflare Workers; otherwise `s3` if `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` and `S3_BUCKET` are all set, `blob` on Vercel and `local` elsewhere. | | `LOCAL_STORAGE_PATH` | `/app/data` in the image | Folder for local uploads. Must be an absolute path. In a source checkout it defaults to `data/` in the repository. The server checks that it is writable at startup and refuses to start if it is not. | | `S3_ACCESS_KEY_ID` | unset | Access key for S3 or an S3-compatible service. | | `S3_SECRET_ACCESS_KEY` | unset | Secret key. | @@ -256,3 +256,7 @@ See [Development setup](/contributing/development) for working on the code. - [Self-hosting with Docker](/self-hosting/docker): a complete setup with PostgreSQL. - [Deployment examples](/self-hosting/examples): reverse proxies, S3 storage, Redis and local AI. - [Single sign-on](/self-hosting/sso): provider setup for the sign-in variables. + +## Cloudflare Workers + +Set `CLOUDFLARE=1` in Wrangler. The runtime uses native R2 (`STORAGE_BACKEND=r2`), disables native image processing, and receives its PostgreSQL connection through the `HYPERDRIVE` binding. Keep `REDIS_URL` unset; SQLite Durable Objects provide shared limits, live updates, and cancellation. See [Self-hosting on Cloudflare](/self-hosting/cloudflare) for required bindings, secrets, migrations, and runtime differences. diff --git a/package.json b/package.json index f6298a270..42311e8dd 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,10 @@ ], "scripts": { "build": "pnpm pdf:translations && turbo run build", + "build:cloudflare": "pnpm pdf:translations && pnpm --filter web build && node tooling/cloudflare/build.mjs", + "dev:cloudflare": "CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false wrangler dev", + "deploy:cloudflare": "pnpm build:cloudflare && wrangler deploy", + "check:cloudflare": "pnpm build:cloudflare && wrangler deploy --dry-run", "check": "pnpm pdf:translations && pnpm lint:fix && pnpm format && pnpm lint", "docs:gen": "pnpm --filter server docs:gen && pnpm --filter @reactive-resume/tooling docs:gen", "db:generate": "dotenvx run --ignore=MISSING_ENV_FILE -f .env.local -- turbo run db:generate --filter=@reactive-resume/db", @@ -47,7 +51,8 @@ "lint:fix": "oxlint --fix", "lint:agent": "oxlint --deny-warnings --format=agent", "format": "oxfmt --write", - "format:check": "oxfmt --check" + "format:check": "oxfmt --check", + "test:cloudflare": "node tooling/cloudflare/smoke.mjs" }, "devDependencies": { "@commitlint/cli": "^21.2.3", @@ -71,6 +76,7 @@ "pg": "catalog:", "turbo": "^2.11.6", "typescript": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "wrangler": "catalog:" } } diff --git a/packages/api/src/features/agent/cancellation.ts b/packages/api/src/features/agent/cancellation.ts index f2841e322..97fca9aeb 100644 --- a/packages/api/src/features/agent/cancellation.ts +++ b/packages/api/src/features/agent/cancellation.ts @@ -6,6 +6,8 @@ const HEARTBEAT_TTL_MS = 10_000; export async function requestRunCancellation(runId: string, reason: string): Promise { try { + const shared = getCoordination(); + if (shared) await shared.set(redisKey("agent-cancellation", runId), reason, CANCELLATION_TTL_MS); await getRedis()?.set(redisKey("agent-cancellation", runId), reason, "PX", CANCELLATION_TTL_MS); } finally { controllers.get(runId)?.abort(new DOMException(reason, "AbortError")); @@ -16,6 +18,7 @@ export async function requestRunCancellation(runId: string, reason: string): Pro export async function monitorRunCancellation(runId: string, controller: AbortController): Promise<() => void> { controllers.set(runId, controller); const redis = getRedis(); + const shared = getCoordination(); let stopped = false; let timeout: ReturnType | undefined; const cleanup = () => { @@ -25,16 +28,21 @@ export async function monitorRunCancellation(runId: string, controller: AbortCon }; const check = async () => { - if (stopped || !redis) return; + if (stopped || (!redis && !shared)) return; try { // Keep beating after an abort: the owner still holds the claim while it persists the transcript. - await redis.set(redisKey("agent-run-alive", runId), "1", "PX", HEARTBEAT_TTL_MS); - const reason = controller.signal.aborted ? null : await redis.get(redisKey("agent-cancellation", runId)); + if (shared) await shared.set(redisKey("agent-run-alive", runId), "1", HEARTBEAT_TTL_MS); + else await redis?.set(redisKey("agent-run-alive", runId), "1", "PX", HEARTBEAT_TTL_MS); + const reason = controller.signal.aborted + ? null + : shared + ? await shared.get(redisKey("agent-cancellation", runId)) + : await redis?.get(redisKey("agent-cancellation", runId)); if (!stopped && reason) controller.abort(new DOMException(reason, "AbortError")); } catch { if (!stopped) controller.abort(new DOMException("CANCELLATION_UNAVAILABLE", "AbortError")); } - // ponytail: two Redis commands per second per run; pub/sub can reduce traffic at higher concurrency. + // ponytail: two shared operations per second per run; pub/sub can reduce traffic at higher concurrency. if (!stopped) timeout = setTimeout(() => void check(), 1_000); }; await check(); @@ -44,8 +52,12 @@ export async function monitorRunCancellation(runId: string, controller: AbortCon /** False once a run's owner has stopped heartbeating, meaning it died without releasing its claim. */ export async function isRunAlive(runId: string, startedAt: Date | null): Promise { const redis = getRedis(); - if (!redis) return true; + const shared = getCoordination(); + if (!redis && !shared) return true; // A just-claimed run may not have written its first heartbeat yet. if (startedAt && Date.now() - startedAt.getTime() < HEARTBEAT_TTL_MS) return true; - return (await redis.exists(redisKey("agent-run-alive", runId))) === 1; + return shared + ? (await shared.get(redisKey("agent-run-alive", runId))) !== null + : (await redis?.exists(redisKey("agent-run-alive", runId))) === 1; } +import { getCoordination } from "@reactive-resume/db/coordination"; diff --git a/packages/api/src/features/resume/events.ts b/packages/api/src/features/resume/events.ts index 06dad7832..379860594 100644 --- a/packages/api/src/features/resume/events.ts +++ b/packages/api/src/features/resume/events.ts @@ -1,5 +1,6 @@ import { on, once } from "node:events"; import { getPool } from "@reactive-resume/db/client"; +import { getCoordination } from "@reactive-resume/db/coordination"; import { getRedis, redisKey } from "@reactive-resume/db/redis"; const RESUME_UPDATED_CHANNEL = "resume_updated"; @@ -39,6 +40,8 @@ function isResumeUpdatedEvent(value: unknown): value is ResumeUpdatedEvent { } export async function publishResumeUpdated(event: ResumeUpdatedEvent) { + const shared = getCoordination(); + if (shared) return shared.publish(redisKey(RESUME_UPDATED_CHANNEL, event.resumeId), JSON.stringify(event)); const redis = getRedis(); if (redis) { await redis.publish(redisKey(RESUME_UPDATED_CHANNEL), JSON.stringify(event)); @@ -63,6 +66,14 @@ const isAbort = (error: unknown) => error instanceof Error && error.name === "Ab export async function* subscribeResumeUpdated({ resumeId, userId, signal }: SubscribeResumeUpdatedInput) { if (signal?.aborted) return; + const shared = getCoordination(); + if (shared) { + for await (const payload of shared.subscribe(redisKey(RESUME_UPDATED_CHANNEL, resumeId), signal)) { + const event = readEvent(payload, resumeId, userId); + if (event) yield event; + } + return; + } const subscriber = getRedis()?.duplicate({ commandTimeout: 5_000 }); const client = subscriber ? undefined : await getPool().connect(); const channel = subscriber ? redisKey(RESUME_UPDATED_CHANNEL) : RESUME_UPDATED_CHANNEL; diff --git a/packages/api/src/features/storage/index.ts b/packages/api/src/features/storage/index.ts index 054719129..1532414dd 100644 --- a/packages/api/src/features/storage/index.ts +++ b/packages/api/src/features/storage/index.ts @@ -1 +1,2 @@ -export { getStorageService, inferContentType } from "./service"; +export type { StorageService } from "./service"; +export { configureStorageService, getStorageService, inferContentType } from "./service"; diff --git a/packages/api/src/features/storage/router.ts b/packages/api/src/features/storage/router.ts index be738774c..25c8c47fe 100644 --- a/packages/api/src/features/storage/router.ts +++ b/packages/api/src/features/storage/router.ts @@ -4,8 +4,6 @@ import { protectedProcedure } from "../../context"; import { storageDeleteRateLimit, storageUploadRateLimit } from "../../middleware/rate-limit"; import { getStorageService, isImageFile, processImageForUpload, uploadFile } from "./service"; -const storageService = getStorageService(); - const fileSchema = z.file().max(10 * 1024 * 1024, "File size must be less than 10MB"); const filenameSchema = z.object({ @@ -98,7 +96,7 @@ export const storageRouter = { throw new ORPCError("FORBIDDEN"); } - const deleted = await storageService.delete(key); + const deleted = await getStorageService().delete(key); if (!deleted) throw new ORPCError("NOT_FOUND"); }), diff --git a/packages/api/src/features/storage/service.ts b/packages/api/src/features/storage/service.ts index 80ec60234..093e61c70 100644 --- a/packages/api/src/features/storage/service.ts +++ b/packages/api/src/features/storage/service.ts @@ -28,7 +28,7 @@ interface StorageReadResult { contentType?: string; } -interface StorageService { +export interface StorageService { list(prefix: string): Promise; write(input: StorageWriteInput): Promise; read(key: string): Promise; @@ -38,7 +38,7 @@ interface StorageService { interface StorageHealthResult { status: "healthy" | "unhealthy"; - type: "local" | "s3" | "blob"; + type: "local" | "s3" | "blob" | "r2"; message: string; error?: string; } @@ -326,7 +326,13 @@ class S3StorageService implements StorageService { let cachedService: StorageService | null = null; +/** Platforms with native storage bindings configure their adapter before handling requests. */ +export function configureStorageService(service: StorageService): void { + cachedService = service; +} + export function getStorageService(): StorageService { + if (env.STORAGE_BACKEND === "r2" && !cachedService) throw new Error("R2 storage binding is not configured"); cachedService ??= env.STORAGE_BACKEND === "blob" ? new BlobStorageService() diff --git a/packages/api/src/features/web-access/builtin.ts b/packages/api/src/features/web-access/builtin.ts index 7cb516328..fd60ffcbd 100644 --- a/packages/api/src/features/web-access/builtin.ts +++ b/packages/api/src/features/web-access/builtin.ts @@ -1,8 +1,13 @@ import type { LookupAddress } from "node:dns"; -import { lookup as lookupAddresses } from "node:dns/promises"; import { request } from "node:https"; import sanitizeHtml from "sanitize-html"; -import { isPrivateOrLoopbackHost, parseUrl, publicLookup } from "@reactive-resume/utils/url-security.node"; +import { + fetchWorkerPublicUrl, + isPrivateOrLoopbackHost, + parseUrl, + publicLookup, + resolveHostAddresses, +} from "@reactive-resume/utils/url-security.node"; import { MAX_PAGE_BYTES, WebAccessError } from "./contracts"; /** Preserve the posting reader's HTTPS-only policy. */ @@ -30,7 +35,7 @@ export function abortable(promise: Promise, signal: AbortSignal): Promise< export async function assertPublicTarget(input: string, signal: AbortSignal) { const url = assertPublicPageUrl(input); - const addresses = await abortable(lookupAddresses(url.hostname, { all: true }), signal).catch((error) => { + const addresses = await abortable(resolveHostAddresses(url.hostname), signal).catch((error) => { signal.throwIfAborted(); if (error instanceof WebAccessError) throw error; throw new WebAccessError("unreachable"); @@ -46,6 +51,7 @@ export function readBuiltinPage( ): Promise<{ html: string; resolvedUrl: string }> { signal.throwIfAborted(); const url = assertPublicPageUrl(input); + if (process.env.CLOUDFLARE === "1") return readWorkerPage(url, signal); return new Promise((resolve, reject) => { const req = request( url, @@ -105,6 +111,43 @@ export function readBuiltinPage( }); } +async function readWorkerPage(url: URL, signal: AbortSignal): Promise<{ html: string; resolvedUrl: string }> { + try { + const response = await fetchWorkerPublicUrl(url, { + signal, + headers: { accept: "text/html,application/xhtml+xml,text/plain;q=0.9" }, + }); + if (!response.ok) { + await response.body?.cancel(); + throw new WebAccessError("unreachable"); + } + if (!/text\/html|application\/xhtml\+xml|text\/plain/i.test(response.headers.get("content-type") ?? "")) { + await response.body?.cancel(); + throw new WebAccessError("not-a-page"); + } + const reader = response.body?.getReader(); + if (!reader) throw new WebAccessError("not-a-page"); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_PAGE_BYTES) throw new WebAccessError("too-large"); + chunks.push(value); + } + } finally { + await reader.cancel(); + } + return { html: Buffer.concat(chunks).toString("utf8"), resolvedUrl: response.url || url.href }; + } catch (error) { + signal.throwIfAborted(); + if (error instanceof WebAccessError) throw error; + throw new WebAccessError(error instanceof Error && error.cause === "unsafe-url" ? "unsafe-url" : "unreachable"); + } +} + const ENTITIES: Record = { amp: "&", lt: "<", diff --git a/packages/api/src/redis.ts b/packages/api/src/redis.ts index 3481e8124..5e7af750b 100644 --- a/packages/api/src/redis.ts +++ b/packages/api/src/redis.ts @@ -2,12 +2,24 @@ import type { Ratelimiter } from "@orpc/experimental-ratelimit"; import type { MemoryRatelimiterOptions } from "@orpc/experimental-ratelimit/memory"; import { MemoryRatelimiter } from "@orpc/experimental-ratelimit/memory"; import { RedisRatelimiter } from "@orpc/experimental-ratelimit/redis"; +import { getCoordination } from "@reactive-resume/db/coordination"; import { getRedis, redisKey } from "@reactive-resume/db/redis"; export function createRateLimiter(name: string, config: MemoryRatelimiterOptions): Ratelimiter { const memory = new MemoryRatelimiter(config); const client = getRedis(); - if (!client) return memory; + if (!client) + return { + async limit(key) { + const service = getCoordination(); + if (!service) return memory.limit(key); + const result = await service.consume(redisKey("rate-limit", name, key), { + window: config.window, + max: config.maxRequests, + }); + return { success: result.allowed, remaining: result.remaining, reset: result.reset }; + }, + }; const shared = new RedisRatelimiter({ ...config, prefix: `${redisKey("rate-limit", name)}:`, diff --git a/packages/auth/src/rate-limit.ts b/packages/auth/src/rate-limit.ts index dcc487609..f27186e19 100644 --- a/packages/auth/src/rate-limit.ts +++ b/packages/auth/src/rate-limit.ts @@ -1,4 +1,5 @@ import type { BetterAuthOptions } from "better-auth"; +import { getCoordination } from "@reactive-resume/db/coordination"; import { getRedis, redisKey } from "@reactive-resume/db/redis"; // Match Better Auth's rolling inactivity window: only accepted requests extend it. @@ -30,17 +31,31 @@ function consumeLocally(key: string, rule: { window: number; max: number }) { return { allowed: true, retryAfter: null }; } -export const authRateLimitStorage: NonNullable["customStorage"] = redis - ? { - async consume(key, rule) { - try { - const result = await redis.eval(consumeScript, 1, redisKey("auth", key), rule.window * 1_000, rule.max); - if (!Array.isArray(result) || result.length !== 2) throw new Error("Invalid rate limit result"); - return { allowed: result[0] === 1, retryAfter: result[0] === 1 ? null : Number(result[1]) }; - } catch (error) { - console.error("[auth] Redis rate limit unavailable; using per-instance limits", error); - return consumeLocally(key, rule); - } - }, - } - : undefined; +export const authRateLimitStorage: NonNullable["customStorage"] = + redis || process.env.CLOUDFLARE === "1" + ? { + async consume(key, rule) { + const service = getCoordination(); + if (service) { + const result = await service.consume(redisKey("auth", key), { + window: rule.window * 1_000, + max: rule.max, + rolling: true, + }); + return { + allowed: result.allowed, + retryAfter: result.allowed ? null : Math.max(1, Math.ceil((result.reset - Date.now()) / 1_000)), + }; + } + if (!redis) throw new Error("Cloudflare auth coordination is not configured"); + try { + const result = await redis.eval(consumeScript, 1, redisKey("auth", key), rule.window * 1_000, rule.max); + if (!Array.isArray(result) || result.length !== 2) throw new Error("Invalid rate limit result"); + return { allowed: result[0] === 1, retryAfter: result[0] === 1 ? null : Number(result[1]) }; + } catch (error) { + console.error("[auth] Redis rate limit unavailable; using per-instance limits", error); + return consumeLocally(key, rule); + } + }, + } + : undefined; diff --git a/packages/db/package.json b/packages/db/package.json index c75916745..f4caa8b10 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -4,6 +4,7 @@ "type": "module", "private": true, "exports": { + "./coordination": "./src/coordination.ts", "./client": { "default": "./src/client.ts" }, diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 12d8d6160..3a5915a24 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,13 +1,18 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import { env } from "@reactive-resume/env/server"; +const requestDatabase = new AsyncLocalStorage<{ pool: Pool; database: ReturnType }>(); + declare global { var __pool: Pool | undefined; var __drizzle: ReturnType | undefined; } export function getPool() { + const request = requestDatabase.getStore(); + if (request) return request.pool; if (!globalThis.__pool) { const pool = new Pool({ connectionString: env.DATABASE_URL, @@ -35,7 +40,19 @@ export function getPool() { // ponytail: two private fns collapsed; getPool() is already a singleton, global cache preserved globalThis.__drizzle ??= drizzle({ client: getPool() }); -export const db = globalThis.__drizzle; + +/** Workers must create sockets inside a request and cannot reuse them in another request. */ +export function withDatabasePool(pool: Pool, callback: () => T): T { + return requestDatabase.run({ pool, database: drizzle({ client: pool }) }, callback); +} + +export const db = new Proxy(globalThis.__drizzle, { + get(target, property) { + const database = requestDatabase.getStore()?.database ?? target; + const value = Reflect.get(database, property, database) as unknown; + return typeof value === "function" ? value.bind(database) : value; + }, +}); /** The client, or a transaction on it: helpers that write take either, so callers choose the transaction. */ export type DbOrTx = typeof db | Parameters[0]>[0]; diff --git a/packages/db/src/coordination.ts b/packages/db/src/coordination.ts new file mode 100644 index 000000000..cf7d35eb2 --- /dev/null +++ b/packages/db/src/coordination.ts @@ -0,0 +1,19 @@ +export type LimitResult = { allowed: boolean; remaining: number; reset: number }; +export type LimitRule = { window: number; max: number; rolling?: boolean }; + +/** Native platform coordination replaces Redis for counters and short-lived run state. */ +export type CoordinationService = { + consume(key: string, rule: LimitRule): Promise; + get(key: string): Promise; + set(key: string, value: string, ttl: number): Promise; + publish(key: string, value: string): Promise; + subscribe(key: string, signal?: AbortSignal): AsyncIterable; +}; + +let coordination: CoordinationService | undefined; + +export function configureCoordination(service: CoordinationService): void { + coordination = service; +} + +export const getCoordination = () => coordination; diff --git a/packages/env/src/deployment.ts b/packages/env/src/deployment.ts index f213da82b..567ec0b96 100644 --- a/packages/env/src/deployment.ts +++ b/packages/env/src/deployment.ts @@ -2,6 +2,7 @@ export function deploymentEnvironment(input: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const value = (key: string) => input[key]?.trim() || undefined; const vercel = value("VERCEL") === "1"; + const cloudflare = value("CLOUDFLARE") === "1"; const hostname = value("VERCEL_ENV") === "production" ? (value("VERCEL_PROJECT_PRODUCTION_URL") ?? value("VERCEL_URL")) @@ -11,12 +12,14 @@ export function deploymentEnvironment(input: NodeJS.ProcessEnv): NodeJS.ProcessE return { ...input, APP_URL: appUrl, - DATABASE_URL: value("DATABASE_URL") ?? (vercel ? value("POSTGRES_URL") : undefined), + // Hyperdrive's actual URL is supplied to the request-scoped pool, never shared across requests. + DATABASE_URL: + value("DATABASE_URL") ?? (cloudflare ? "postgresql://hyperdrive" : vercel ? value("POSTGRES_URL") : undefined), DATABASE_MIGRATION_URL: value("DATABASE_MIGRATION_URL") ?? (vercel ? (value("DATABASE_URL_UNPOOLED") ?? value("POSTGRES_URL_NON_POOLING")) : undefined), REDIS_URL: value("REDIS_URL") ?? (vercel ? value("KV_URL") : undefined), - STORAGE_BACKEND: value("STORAGE_BACKEND") ?? (hasS3 ? "s3" : vercel ? "blob" : "local"), + STORAGE_BACKEND: value("STORAGE_BACKEND") ?? (cloudflare ? "r2" : hasS3 ? "s3" : vercel ? "blob" : "local"), DEPLOYMENT_NAMESPACE: value("DEPLOYMENT_NAMESPACE") ?? (vercel diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts index 210d4782e..d5cd00728 100644 --- a/packages/env/src/server.ts +++ b/packages/env/src/server.ts @@ -6,7 +6,7 @@ import { aiProviderSchema } from "@reactive-resume/ai/types"; import { findWorkspaceRoot } from "@reactive-resume/utils/monorepo.node"; import { deploymentEnvironment } from "./deployment"; -const workspaceRoot = findWorkspaceRoot(); +const workspaceRoot = process.env.CLOUDFLARE === "1" ? null : findWorkspaceRoot(); if (workspaceRoot) { try { @@ -21,6 +21,7 @@ if (workspaceRoot) { export const env = createEnv({ server: { // Application + CLOUDFLARE: z.stringbool().default(false), APP_URL: z.url({ protocol: /https?/ }), ROOT_RESUME_ID: z .string() @@ -96,7 +97,7 @@ export const env = createEnv({ SMTP_SECURE: z.stringbool().default(false), // Storage (Optional) - STORAGE_BACKEND: z.enum(["local", "s3", "blob"]), + STORAGE_BACKEND: z.enum(["local", "s3", "blob", "r2"]), BLOB_READ_WRITE_TOKEN: z.string().min(1).optional(), BLOB_STORE_ID: z.string().min(1).optional(), DEPLOYMENT_NAMESPACE: z.string().regex(/^[a-zA-Z0-9._-]+$/), diff --git a/packages/pdf/src/forme/images.node.ts b/packages/pdf/src/forme/images.node.ts index 290971903..a1aae469f 100644 --- a/packages/pdf/src/forme/images.node.ts +++ b/packages/pdf/src/forme/images.node.ts @@ -1,8 +1,14 @@ import { request as httpRequest } from "node:http"; import { request as httpsRequest } from "node:https"; -import { isPrivateOrLoopbackHost, publicLookup } from "@reactive-resume/utils/url-security.node"; +import { fetchWorkerPublicUrl, isPrivateOrLoopbackHost, publicLookup } from "@reactive-resume/utils/url-security.node"; import { IMAGE_TIMEOUT_MS, MAX_IMAGE_BYTES, readImageBytes } from "./images"; +let ownPictureReader: ((key: string) => Promise) | undefined; +/** A platform binding can read our public pictures directly, avoiding a second HTTP request. */ +export function configureOwnPictureReader(reader: (key: string) => Promise): void { + ownPictureReader = reader; +} + /** Public images plus pictures served by this installation; never arbitrary internal endpoints. */ export function readServerImage( source: string, @@ -25,6 +31,8 @@ export function readServerImage( ) return Promise.reject(new Error("Private or invalid image URL refused")); signal.throwIfAborted(); + if (ownPicture && ownPictureReader) return ownPictureReader(url.pathname.replace(/^\/(?:api\/)?/, "")); + if (process.env.CLOUDFLARE === "1") return readWorkerImage(url, signal); return new Promise((resolve, reject) => { const request = url.protocol === "https:" ? httpsRequest : httpRequest; const req = request(url, { signal, agent: false, ...(ownPicture ? {} : { lookup: publicLookup }) }, (response) => { @@ -57,3 +65,27 @@ export function readServerImage( req.end(); }); } + +async function readWorkerImage(url: URL, signal: AbortSignal): Promise { + const response = await fetchWorkerPublicUrl(url, { signal }); + if (!response.ok || Number(response.headers.get("content-length")) > MAX_IMAGE_BYTES) { + await response.body?.cancel(); + throw new Error("Image request failed or exceeds 12 MB"); + } + const reader = response.body?.getReader(); + if (!reader) throw new Error("Image has no body"); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_IMAGE_BYTES) throw new Error("Image exceeds 12 MB"); + chunks.push(value); + } + } finally { + await reader.cancel(); + } + return Buffer.concat(chunks); +} diff --git a/packages/pdf/src/server.tsx b/packages/pdf/src/server.tsx index 7052e858f..405bc3062 100644 --- a/packages/pdf/src/server.tsx +++ b/packages/pdf/src/server.tsx @@ -7,6 +7,8 @@ import { parseResumeData } from "@reactive-resume/schema/resume/data"; import { readServerImage } from "./forme/images.node.ts"; import { assertPdfText, renderResume } from "./forme/render"; +export { configureOwnPictureReader } from "./forme/images.node"; + export type CreateResumePdfFileOptions = { data: ResumeData; filename: string; diff --git a/packages/utils/src/url-security.node.test.ts b/packages/utils/src/url-security.node.test.ts index 058e81554..8e22735fc 100644 --- a/packages/utils/src/url-security.node.test.ts +++ b/packages/utils/src/url-security.node.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from "vitest"; -import { isAllowedOAuthRedirectUri, isPrivateOrLoopbackHost } from "./url-security.node"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchWorkerPublicUrl, isAllowedOAuthRedirectUri, isPrivateOrLoopbackHost } from "./url-security.node"; + +const dns = vi.hoisted(() => ({ lookup: vi.fn(), resolve4: vi.fn(), resolve6: vi.fn() })); +vi.mock("node:dns/promises", () => dns); describe("isPrivateOrLoopbackHost", () => { it.each([ @@ -134,3 +137,59 @@ describe("isAllowedOAuthRedirectUri", () => { expect(isAllowedOAuthRedirectUri("https://claude.ai/api/mcp/auth_callback", trustedOrigins)).toBe(true); }); }); + +describe("fetchWorkerPublicUrl", () => { + const network = vi.fn(); + const options = () => ({ signal: new AbortController().signal }); + beforeEach(() => { + vi.stubEnv("CLOUDFLARE", "1"); + vi.stubGlobal("fetch", network); + dns.resolve4.mockResolvedValue(["1.1.1.1"]); + dns.resolve6.mockRejectedValue(new Error("No IPv6 records")); + }); + afterEach(() => { + vi.resetAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("reads public redirects without automatically following unchecked destinations", async () => { + network.mockResolvedValueOnce( + new Response(null, { status: 302, headers: { location: "https://next.example/page" } }), + ); + network.mockResolvedValueOnce(new Response("public page")); + const response = await fetchWorkerPublicUrl(new URL("https://example.com"), options()); + expect(await response.text()).toBe("public page"); + expect(network.mock.calls.map(([url, init]) => [String(url), init?.redirect])).toEqual([ + ["https://example.com/", "manual"], + ["https://next.example/page", "manual"], + ]); + }); + + it("refuses a host if any DNS answer is private", async () => { + dns.resolve6.mockResolvedValue(["fd00::1"]); + await expect(fetchWorkerPublicUrl(new URL("https://example.com"), options())).rejects.toMatchObject({ + cause: "unsafe-url", + }); + expect(network).not.toHaveBeenCalled(); + }); + + it("fails closed when DNS cannot resolve either address family", async () => { + dns.resolve4.mockRejectedValue(new Error("DNS unavailable")); + await expect(fetchWorkerPublicUrl(new URL("https://example.com"), options())).rejects.toMatchObject({ + cause: "unsafe-url", + }); + expect(network).not.toHaveBeenCalled(); + }); + + it.each(["https://127.0.0.1/admin", "https://[::1]/admin", "http://next.example/page"])( + "refuses redirect to %s", + async (location) => { + network.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location } })); + await expect(fetchWorkerPublicUrl(new URL("https://example.com"), options())).rejects.toMatchObject({ + cause: "unsafe-url", + }); + expect(network).toHaveBeenCalledTimes(1); + }, + ); +}); diff --git a/packages/utils/src/url-security.node.ts b/packages/utils/src/url-security.node.ts index 60fddf8f3..1f995a76b 100644 --- a/packages/utils/src/url-security.node.ts +++ b/packages/utils/src/url-security.node.ts @@ -1,6 +1,7 @@ import type { LookupAddress } from "node:dns"; import type { LookupFunction } from "node:net"; import { lookup } from "node:dns"; +import { lookup as lookupAddresses, resolve4, resolve6 } from "node:dns/promises"; import { BlockList, isIP } from "node:net"; function normalizeHostname(hostname: string) { @@ -80,6 +81,45 @@ export const publicLookup: LookupFunction = (hostname, options, callback) => { }); }; +/** Workers implements resolve4/resolve6, but not dns.lookup. */ +export async function resolveHostAddresses(hostname: string): Promise[]> { + const host = stripIpv6Brackets(hostname); + if (isIP(host)) return [{ address: host }]; + if (process.env.CLOUDFLARE !== "1") return lookupAddresses(host, { all: true }); + const results = await Promise.allSettled([resolve4(host), resolve6(host)]); + return results.flatMap((result) => + result.status === "fulfilled" ? result.value.map((address) => ({ address })) : [], + ); +} + +/** Requires global_fetch_strictly_public: Workers enforces public routing at connection time, including rebinding. */ +export async function fetchWorkerPublicUrl( + input: URL, + options: { signal: AbortSignal; headers?: HeadersInit }, +): Promise { + if (process.env.CLOUDFLARE !== "1") throw new Error("Public Worker fetch requires Cloudflare"); + let url = input; + for (let redirects = 0; redirects <= 3; redirects++) { + if (!/^https?:$/.test(url.protocol) || url.username || url.password || isPrivateOrLoopbackHost(url.hostname)) { + throw new Error("Private network address refused", { cause: "unsafe-url" }); + } + const addresses = await resolveHostAddresses(url.hostname); + options.signal.throwIfAborted(); + if (!addresses.length || addresses.some(({ address }) => isPrivateOrLoopbackHost(address))) { + throw new Error("Private network address refused", { cause: "unsafe-url" }); + } + const response = await fetch(url, { ...options, redirect: "manual" }); + const location = response.headers.get("location"); + if (response.status < 300 || response.status >= 400 || !location) return response; + await response.body?.cancel(); + const next = new URL(location, url); + if (url.protocol === "https:" && next.protocol !== "https:") + throw new Error("HTTPS redirect required", { cause: "unsafe-url" }); + url = next; + } + throw new Error("Too many redirects"); +} + export function parseUrl(input: string) { try { return new URL(input); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2d4c9aaf..b678904e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,6 +176,9 @@ catalogs: '@better-auth/passkey': specifier: 1.7.7 version: 1.7.7 + '@cloudflare/workers-types': + specifier: ^5.20261002.1 + version: 5.20261002.1 '@orpc/client': specifier: ^1.15.4 version: 1.15.4 @@ -215,6 +218,9 @@ catalogs: es-toolkit: specifier: ^1.52.0 version: 1.52.0 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 fflate: specifier: ^0.8.3 version: 0.8.3 @@ -239,6 +245,9 @@ catalogs: vitest: specifier: ^5.0.3 version: 5.0.3 + wrangler: + specifier: ^4.146.0 + version: 4.146.0 zod: specifier: ^4.6.5 version: 4.6.5 @@ -322,6 +331,9 @@ importers: vitest: specifier: 'catalog:' version: 5.0.3(@types/node@26.6.3)(@vitest/coverage-v8@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + wrangler: + specifier: 'catalog:' + version: 4.146.0(@cloudflare/workers-types@5.20261002.1)(@types/node@26.6.3) apps/server: dependencies: @@ -369,19 +381,19 @@ importers: version: 3.1144.0 '@better-auth/api-key': specifier: 'catalog:' - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5)) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5)) '@better-auth/drizzle-adapter': specifier: 1.7.7 - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5)) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5)) '@better-auth/infra': specifier: 'catalog:' - version: 0.4.13(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(zod@4.6.5) + version: 0.4.13(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(zod@4.6.5) '@better-auth/oauth-provider': specifier: 'catalog:' - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5)) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5)) '@better-auth/passkey': specifier: 'catalog:' - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5))(nanostores@1.5.4) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5))(nanostores@1.5.4) '@bramus/specificity': specifier: ^2.4.2 version: 2.4.2 @@ -430,6 +442,9 @@ importers: '@reactive-resume/mcp': specifier: workspace:* version: link:../../packages/mcp + '@reactive-resume/pdf': + specifier: workspace:* + version: link:../../packages/pdf '@reactive-resume/schema': specifier: workspace:* version: link:../../packages/schema @@ -459,7 +474,7 @@ importers: version: 3.0.3 better-auth: specifier: 'catalog:' - version: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) + version: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) cjk-regex: specifier: ^3.5.0 version: 3.5.0 @@ -471,10 +486,10 @@ importers: version: 9.8.1 drizzle-orm: specifier: 'catalog:' - version: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) + version: 1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) drizzle-zod: specifier: 1.0.0-beta.14-a36c63d - version: 1.0.0-beta.14-a36c63d(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(zod@4.6.5) + version: 1.0.0-beta.14-a36c63d(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(zod@4.6.5) es-toolkit: specifier: 'catalog:' version: 1.52.0 @@ -548,6 +563,9 @@ importers: specifier: 'catalog:' version: 4.6.5 devDependencies: + '@cloudflare/workers-types': + specifier: 'catalog:' + version: 5.20261002.1 '@reactive-resume/config': specifier: workspace:* version: link:../../packages/config @@ -586,16 +604,16 @@ importers: version: 1.8.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@better-auth/api-key': specifier: 'catalog:' - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5)) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5)) '@better-auth/infra': specifier: 'catalog:' - version: 0.4.13(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(zod@4.6.5) + version: 0.4.13(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(zod@4.6.5) '@better-auth/oauth-provider': specifier: 'catalog:' - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5)) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5)) '@better-auth/passkey': specifier: 'catalog:' - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5))(nanostores@1.5.4) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5))(nanostores@1.5.4) '@codemirror/autocomplete': specifier: ^6.20.3 version: 6.20.3 @@ -739,7 +757,7 @@ importers: version: 7.0.124(zod@4.6.5) better-auth: specifier: 'catalog:' - version: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) + version: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -987,13 +1005,13 @@ importers: version: 3.0.3 better-auth: specifier: 'catalog:' - version: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) + version: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) drizzle-orm: specifier: 'catalog:' - version: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) + version: 1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) drizzle-zod: specifier: 1.0.0-beta.14-a36c63d - version: 1.0.0-beta.14-a36c63d(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(zod@4.6.5) + version: 1.0.0-beta.14-a36c63d(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(zod@4.6.5) fflate: specifier: 'catalog:' version: 0.8.3 @@ -1054,19 +1072,19 @@ importers: dependencies: '@better-auth/api-key': specifier: 'catalog:' - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5)) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5)) '@better-auth/drizzle-adapter': specifier: 1.7.7 - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5)) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5)) '@better-auth/infra': specifier: 'catalog:' - version: 0.4.13(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(zod@4.6.5) + version: 0.4.13(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(zod@4.6.5) '@better-auth/oauth-provider': specifier: 'catalog:' - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5)) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5)) '@better-auth/passkey': specifier: 'catalog:' - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5))(nanostores@1.5.4) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5))(nanostores@1.5.4) '@reactive-resume/db': specifier: workspace:* version: link:../db @@ -1084,10 +1102,10 @@ importers: version: 3.0.3 better-auth: specifier: 'catalog:' - version: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) + version: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) drizzle-orm: specifier: 'catalog:' - version: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) + version: 1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) jose: specifier: ^6.2.12 version: 6.2.12 @@ -1123,7 +1141,7 @@ importers: version: link:../utils drizzle-orm: specifier: 'catalog:' - version: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) + version: 1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) ioredis: specifier: 'catalog:' version: 6.0.0(supports-color@7.2.0) @@ -1595,7 +1613,10 @@ importers: version: 7.0.0-dev.20260707.2 drizzle-orm: specifier: 'catalog:' - version: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) + version: 1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) + esbuild: + specifier: 'catalog:' + version: 0.28.2 pg: specifier: 'catalog:' version: 8.23.1 @@ -1611,6 +1632,9 @@ importers: vitest: specifier: 'catalog:' version: 5.0.3(@types/node@26.6.3)(@vitest/coverage-v8@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + wrangler: + specifier: 'catalog:' + version: 4.146.0(@cloudflare/workers-types@5.20261002.1)(@types/node@26.6.3) packages: @@ -2118,6 +2142,52 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.2': + resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20261001.1': + resolution: {integrity: sha512-4cgSgDf28JSw/P5Dj5GCS59hzVqS5XnmGAWNkvYHLI6ODU9idGaMMNEuhJXDkEG/lsABmlVlnCgsdh2VbKWepw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20261001.1': + resolution: {integrity: sha512-8ulAWruEVouNmEIsQsy9WSSCS9zkLu93W2MTwp5esiFyoPp05BNSVFIFsYSPi1pkFmBBd7fsnwMpbLkaJLaVPQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20261001.1': + resolution: {integrity: sha512-kZbTZJGrhsMOdqZ2BIybjaBRLZjYsRLWj7mcNw/6Y3hodOhp5MrNzcz4iWGwNVWwyIV+7Pl+/LX5VcgnRqdHOg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20261001.1': + resolution: {integrity: sha512-oOk3Zj6k/8oP0FJgZBWDn7+BqbsqIMEMaV95pulHPVbwVu4wYoLfQq2hp0vkbCNsCFLqZb7cqixXdrwD54ZIow==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20261001.1': + resolution: {integrity: sha512-uRxm5W4VyBkoSaoP1BfOuH0873tE+vxsknF4sab6/5YIRvIAufChq3QDp+zlAn67PuGPGcn7W8QraA0t4ucmOQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@5.20261002.1': + resolution: {integrity: sha512-4DJVJoQrKkB/Q1IJJE5hd793Nupqmn2J+zuikAh2isHq/1s44G5drucgL/RtCa7/7UeJ3y6J7cmhrCZcIVfNVg==} + '@codemirror/autocomplete@6.20.3': resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} @@ -2233,6 +2303,10 @@ packages: peerDependencies: solid-js: ^1.8 + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@deepseek-ai/cordis@4.0.4': resolution: {integrity: sha512-obgyxqWAmFn3Re8kvsuUnyW+ihrz6eJCnJO4fh1cQzDtmPYz/zzVeUkH9R94I0OwSVOocK67Kgakm04j/oQXzg==} hasBin: true @@ -2833,81 +2907,163 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-arm64@0.35.5': resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + '@img/sharp-darwin-x64@0.35.5': resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-freebsd-wasm32@0.35.5': resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.4': resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.4': resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm64@1.3.4': resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.4': resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.4': resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.4': resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.4': resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.4': resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.4': resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm64@0.35.5': resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} @@ -2915,6 +3071,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm@0.35.5': resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} @@ -2922,6 +3085,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-ppc64@0.35.5': resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} @@ -2929,6 +3099,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-riscv64@0.35.5': resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} @@ -2936,6 +3113,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-linux-s390x@0.35.5': resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} @@ -2943,6 +3127,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-x64@0.35.5': resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} @@ -2950,6 +3141,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-arm64@0.35.5': resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} @@ -2957,6 +3155,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.5': resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} @@ -2964,27 +3169,54 @@ packages: os: [linux] libc: [musl] + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + '@img/sharp-wasm32@0.35.5': resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-webcontainers-wasm32@0.35.5': resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-arm64@0.35.5': resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-ia32@0.35.5': resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@img/sharp-win32-x64@0.35.5': resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} @@ -3021,6 +3253,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-temporal/polyfill@0.5.1': resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} @@ -4342,6 +4577,15 @@ packages: engines: {node: '>=20'} hasBin: true + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@quansync/fs@1.1.0': resolution: {integrity: sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==} @@ -4769,6 +5013,10 @@ packages: '@sinclair/typebox@0.27.12': resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -4882,6 +5130,9 @@ packages: peerDependencies: solid-js: ^1.6.12 + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -6180,6 +6431,9 @@ packages: zod: optional: true + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -6850,6 +7104,9 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -7928,6 +8185,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + miniflare@5.20261001.0-alpha: + resolution: {integrity: sha512-GaimS5mSIOMyvd16ga+e1/QkI8cmp3z35QPHFex0DktqNQf2RVZQwMPQXdyoUreUiFP/0Gpe2MoQInTyMAD5xA==} + engines: {node: '>=22.0.0'} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -8227,6 +8488,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -8760,6 +9024,15 @@ packages: engines: {node: '>=20.18.1'} hasBin: true + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + sharp@0.35.5: resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} @@ -8975,6 +9248,10 @@ packages: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -9159,6 +9436,10 @@ packages: resolution: {integrity: sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==} engines: {node: '>=18.17'} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + undici@7.30.0: resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} engines: {node: '>=20.18.1'} @@ -9167,6 +9448,9 @@ packages: resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-regex@4.3.0: resolution: {integrity: sha512-X1u5dXkMT60vYlJqAKks8Wyr9+KdJsfy0zxNXSjSN9GUbzT740+RBzX0PogRoqC+YZ2tTPzsa5iEAnYN+kB3IQ==} engines: {node: '>=16'} @@ -9425,6 +9709,21 @@ packages: wink-porter2-stemmer@2.0.1: resolution: {integrity: sha512-0g+RkkqhRXFmSpJQStVXW5N/WsshWpJXsoDRW7DwVkGI2uDT6IBCoq3xdH5p6IHLaC6ygk7RWUsUx4alKxoagQ==} + workerd@1.20261001.1: + resolution: {integrity: sha512-d/SIYHFO0PT/wiFZg8in4NpRIxYuFwslX1HdylOtWkBIIUmSpkGFhK820cV84XACFylwJ48xuRoWW/8DWDPsPQ==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.146.0: + resolution: {integrity: sha512-c27eHUH0Isr8HTmgZ6cLtLr/0cxtiBoLren6ywBeTW8ZIzj/dINVNBcqsbZc+iETnI5jrzO7E7Z8mLgLZ/l9iw==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20261001.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -9432,6 +9731,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -9511,6 +9822,12 @@ packages: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + yuku-ast@0.10.2: resolution: {integrity: sha512-UnG9mA6giglCvSErft2/40TVFX750Sj1xgwddPLpG7J7rlr/P1wPADg9G2RK+d/1tNLtRVoLdMMOMVBB9561TQ==} @@ -10089,11 +10406,11 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/api-key@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5))': + '@better-auth/api-key@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5))': dependencies: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.2 - better-auth: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) + better-auth: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) better-call: 1.4.0(zod@4.6.5) zod: 4.6.5 @@ -10109,21 +10426,21 @@ snapshots: nanostores: 1.5.4 zod: 4.6.5 - '@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))': + '@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))': dependencies: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.2 optionalDependencies: - drizzle-orm: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) + drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) - '@better-auth/infra@0.4.13(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(zod@4.6.5)': + '@better-auth/infra@0.4.13(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(zod@4.6.5)': dependencies: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.3 '@better-fetch/fetch': 1.3.2 '@noble/hashes': 2.4.0 '@xmldom/xmldom': 0.9.12 - better-auth: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) + better-auth: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) better-call: 1.4.0(zod@4.6.5) jose: 6.2.12 libphonenumber-js: 1.13.14 @@ -10146,24 +10463,24 @@ snapshots: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.2 - '@better-auth/oauth-provider@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5))': + '@better-auth/oauth-provider@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5))': dependencies: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.2 - better-auth: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) + better-auth: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) better-call: 1.4.0(zod@4.6.5) jose: 6.2.12 zod: 4.6.5 - '@better-auth/passkey@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5))(nanostores@1.5.4)': + '@better-auth/passkey@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3))(better-call@1.4.0(zod@4.6.5))(nanostores@1.5.4)': dependencies: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.2 '@simplewebauthn/browser': 13.3.0 '@simplewebauthn/server': 13.3.3 - better-auth: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) + better-auth: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3) better-call: 1.4.0(zod@4.6.5) nanostores: 1.5.4 zod: 4.6.5 @@ -10197,6 +10514,31 @@ snapshots: dependencies: css-tree: 3.2.1 + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261001.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20261001.1 + + '@cloudflare/workerd-darwin-64@1.20261001.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20261001.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20261001.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20261001.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20261001.1': + optional: true + + '@cloudflare/workers-types@5.20261002.1': {} + '@codemirror/autocomplete@6.20.3': dependencies: '@codemirror/language': 6.12.4 @@ -10372,6 +10714,10 @@ snapshots: '@floating-ui/dom': 1.8.0 solid-js: 1.9.15 + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@deepseek-ai/cordis@4.0.4': dependencies: '@deepseek-ai/cosmokit': 1.8.5 @@ -10828,107 +11174,211 @@ snapshots: '@img/colour@1.1.0': {} + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: '@img/sharp-wasm32': 0.35.5 optional: true + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + '@img/sharp-libvips-linux-arm@1.3.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + '@img/sharp-libvips-linux-x64@1.3.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + '@img/sharp-linux-arm@0.35.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.3.4 optional: true + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + '@img/sharp-linux-x64@0.35.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.3.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: '@img/sharp-wasm32': 0.35.5 optional: true + '@img/sharp-win32-arm64@0.35.4': + optional: true + '@img/sharp-win32-arm64@0.35.5': optional: true + '@img/sharp-win32-ia32@0.35.4': + optional: true + '@img/sharp-win32-ia32@0.35.5': optional: true + '@img/sharp-win32-x64@0.35.4': + optional: true + '@img/sharp-win32-x64@0.35.5': optional: true @@ -10970,6 +11420,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + '@js-temporal/polyfill@0.5.1': dependencies: jsbi: 4.3.2 @@ -12021,6 +12476,18 @@ snapshots: dependencies: playwright: 1.63.0 + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@quansync/fs@1.1.0': dependencies: quansync: 1.0.0 @@ -12353,6 +12820,8 @@ snapshots: '@sinclair/typebox@0.27.12': {} + '@sindresorhus/is@7.2.0': {} + '@sindresorhus/merge-streams@4.0.0': {} '@sindresorhus/slugify@3.0.1': @@ -12477,6 +12946,8 @@ snapshots: dependencies: solid-js: 1.9.15 + '@speed-highlight/core@1.2.24': {} + '@standard-schema/spec@1.1.0': {} '@swc/counter@0.1.3': {} @@ -13618,10 +14089,10 @@ snapshots: bcryptjs@3.0.3: {} - better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3): + better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(next@16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.3): dependencies: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) - '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5)) + '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5)) '@better-auth/kysely-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(kysely@0.29.6) '@better-auth/memory-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2) '@better-auth/mongo-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2) @@ -13639,7 +14110,7 @@ snapshots: zod: 4.6.5 optionalDependencies: drizzle-kit: 1.0.0-rc.4 - drizzle-orm: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) + drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) next: 16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) pg: 8.23.1 react: 19.3.0 @@ -13658,6 +14129,8 @@ snapshots: optionalDependencies: zod: 4.6.5 + blake3-wasm@2.1.5: {} + body-parser@2.3.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 @@ -14070,15 +14543,16 @@ snapshots: get-tsconfig: 4.14.3 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5): + drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5): optionalDependencies: + '@cloudflare/workers-types': 5.20261002.1 '@types/pg': 8.23.1 pg: 8.23.1 zod: 4.6.5 - drizzle-zod@1.0.0-beta.14-a36c63d(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(zod@4.6.5): + drizzle-zod@1.0.0-beta.14-a36c63d(drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5))(zod@4.6.5): dependencies: - drizzle-orm: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) + drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@5.20261002.1)(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5) zod: 4.6.5 dts-resolver@3.0.0(oxc-resolver@11.24.2): @@ -14147,6 +14621,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 + error-stack-parser-es@1.0.5: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -15396,6 +15872,19 @@ snapshots: min-indent@1.0.1: {} + miniflare@5.20261001.0-alpha(@types/node@26.6.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.4(@types/node@26.6.3) + undici: 7.29.1 + workerd: 1.20261001.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + minimalistic-assert@1.0.1: {} minimatch@10.2.6: @@ -15786,6 +16275,8 @@ snapshots: lru-cache: 11.5.3 minipass: 7.1.3 + path-to-regexp@6.3.0: {} + path-to-regexp@8.4.2: {} path-type@4.0.0: {} @@ -16438,6 +16929,39 @@ snapshots: - supports-color - typescript + sharp@0.35.4(@types/node@26.6.3): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 26.6.3 + sharp@0.35.5(@types/node@26.6.3): dependencies: '@img/colour': 1.1.0 @@ -16677,6 +17201,8 @@ snapshots: dependencies: copy-anything: 4.1.4 + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -16842,10 +17368,16 @@ snapshots: undici@6.29.0: {} + undici@7.29.1: {} + undici@7.30.0: {} undici@8.11.2: {} + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + unicode-regex@4.3.0: dependencies: regexp-util: 2.1.0 @@ -17013,6 +17545,32 @@ snapshots: wink-porter2-stemmer@2.0.1: {} + workerd@1.20261001.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20261001.1 + '@cloudflare/workerd-darwin-arm64': 1.20261001.1 + '@cloudflare/workerd-linux-64': 1.20261001.1 + '@cloudflare/workerd-linux-arm64': 1.20261001.1 + '@cloudflare/workerd-windows-64': 1.20261001.1 + + wrangler@4.146.0(@cloudflare/workers-types@5.20261002.1)(@types/node@26.6.3): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261001.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20261001.0-alpha(@types/node@26.6.3) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20261001.1 + optionalDependencies: + '@cloudflare/workers-types': 5.20261002.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 @@ -17021,6 +17579,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.0: {} + ws@8.21.3: {} ws@8.22.0: {} @@ -17072,6 +17632,19 @@ snapshots: yoctocolors@2.2.0: {} + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + yuku-ast@0.10.2: dependencies: '@yuku-toolchain/types': 0.10.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1bae96e43..3f710169d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ catalog: "@better-auth/infra": "^0.4.13" "@better-auth/oauth-provider": "1.7.7" "@better-auth/passkey": "1.7.7" + "@cloudflare/workers-types": ^5.20261002.1 "@orpc/client": "^1.15.4" "@orpc/server": "^1.15.4" "@types/node": "^26.6.3" @@ -21,6 +22,7 @@ catalog: "better-auth": "1.7.7" "drizzle-orm": "1.0.0-rc.4" "es-toolkit": "^1.52.0" + esbuild: ^0.28.2 "fflate": "^0.8.3" "ioredis": "^6.0.0" "jsonrepair": "^3.15.0" @@ -29,11 +31,13 @@ catalog: "ts-pattern": "^5.9.0" "typescript": "^7.0.2" "vitest": "^5.0.3" + wrangler: ^4.146.0 "zod": "^4.6.5" allowBuilds: esbuild: true lefthook: true sharp: true + workerd: true minimumReleaseAge: 0 overrides: "@formepdf/core": file:./vendor/forme/formepdf-core-0.26.0-reactive.1.tgz diff --git a/tooling/cloudflare/build.mjs b/tooling/cloudflare/build.mjs new file mode 100644 index 000000000..36a597751 --- /dev/null +++ b/tooling/cloudflare/build.mjs @@ -0,0 +1,49 @@ +import { cp, mkdir, readdir, rm } from "node:fs/promises"; +import { builtinModules, createRequire } from "node:module"; +import { resolve } from "node:path"; +import { build } from "esbuild"; + +const root = resolve(import.meta.dirname, "../.."); +const output = resolve(root, "apps/server/dist-cloudflare"); +const require = createRequire(resolve(root, "apps/server/package.json")); +await rm(output, { recursive: true, force: true }); +await mkdir(output, { recursive: true }); +await build({ + absWorkingDir: root, + entryPoints: ["apps/server/src/cloudflare/index.ts"], + outfile: `${output}/index.js`, + bundle: true, + format: "esm", + platform: "neutral", + mainFields: ["browser", "module", "main"], + conditions: ["workerd", "worker", "browser"], + target: "es2022", + minify: true, + external: ["node:*", "cloudflare:*", ...builtinModules], + banner: { + js: 'import { createRequire } from "node:module"; const require = createRequire("file:///bundle/index.js");', + }, + alias: { + sharp: "./apps/server/src/cloudflare/sharp.ts", + }, + define: { + __APP_VERSION__: JSON.stringify(require("../../package.json").version), + "import.meta.url": JSON.stringify("file:///bundle/index.js"), + }, + plugins: [ + { + name: "forme-wasm", + setup(builder) { + builder.onResolve({ filter: /^css-tree$/ }, () => ({ path: require.resolve("css-tree/dist/csstree.esm") })); + builder.onResolve({ filter: /\.wasm$/ }, () => ({ path: "./forme.wasm", external: true })); + }, + }, + ], +}); +await cp(require.resolve("@formepdf/core/pkg-web/forme_bg.wasm"), `${output}/forme.wasm`); +await mkdir(`${output}/prompts`); +for (const name of await readdir(resolve(root, "packages/ai/src/prompts"))) { + if (name.endsWith(".md")) await cp(resolve(root, "packages/ai/src/prompts", name), `${output}/prompts/${name}`); +} +await cp(resolve(root, "apps/web/dist"), `${output}/assets`, { recursive: true }); +await cp(resolve(root, "apps/web/dist-prerender"), `${output}/assets/_prerender`, { recursive: true }); diff --git a/tooling/cloudflare/smoke.mjs b/tooling/cloudflare/smoke.mjs new file mode 100644 index 000000000..d8e088ae0 --- /dev/null +++ b/tooling/cloudflare/smoke.mjs @@ -0,0 +1,266 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { setTimeout } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; +import { Pool } from "pg"; +import { createTestHarness, experimental_readRawConfig } from "wrangler"; + +// Tests the deployable bundle, not a Node mock of Workers. Only a new local database is migrated. +const root = resolve(import.meta.dirname, "../.."); +const adminUrl = new URL( + process.env.CLOUDFLARE_TEST_DATABASE_ADMIN_URL ?? "postgresql://postgres:postgres@localhost:5432/postgres", +); +assert( + ["localhost", "127.0.0.1", "[::1]"].includes(adminUrl.hostname), + "Smoke tests require local disposable PostgreSQL", +); +const databaseName = `cloudflare_smoke_${randomBytes(8).toString("hex")}`; +const databaseUrl = new URL(adminUrl); +databaseUrl.pathname = `/${databaseName}`; +const admin = new Pool({ connectionString: adminUrl.href }); +const temporary = await mkdtemp(resolve(tmpdir(), "reactive-resume-cloudflare-")); +let harness; +let created = false; +let ai; +const origin = "http://localhost"; +try { + await admin.query(`CREATE DATABASE "${databaseName}"`); + created = true; + execFileSync("pnpm", ["--filter", "@reactive-resume/db", "db:migrate"], { + cwd: root, + env: { ...process.env, DATABASE_URL: databaseUrl.href, CLOUDFLARE: "0" }, + stdio: "pipe", + }); + const { rawConfig: config } = experimental_readRawConfig({ config: resolve(root, "wrangler.jsonc") }); + config.main = resolve(root, config.main); + config.assets.directory = resolve(root, config.assets.directory); + config.hyperdrive[0].localConnectionString = databaseUrl.href; + config.vars.APP_URL = origin; + config.vars.AUTH_SECRET = randomBytes(32).toString("hex"); + config.vars.ENCRYPTION_SECRET = randomBytes(32).toString("hex"); + // Minimal external provider boundary; the real AI SDK and Worker stream stay under test. + const provider = createServer((request, response) => { + let body = ""; + request.on("data", (chunk) => { + body += chunk; + }); + request.on("end", () => { + void (async () => { + const slow = body.includes("slowly"); + response.writeHead(200, { "content-type": "text/event-stream" }); + const chunk = (delta, finish = null) => + response.write( + `data: ${JSON.stringify({ id: "smoke", object: "chat.completion.chunk", created: 0, model: "stub", choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`, + ); + for (const word of slow ? Array.from({ length: 60 }, (_, i) => `word${i} `) : ["You left the document out."]) { + if (response.destroyed) return; + chunk({ content: word }); + if (slow) await setTimeout(150); + } + chunk({}, "stop"); + response.end("data: [DONE]\n\n"); + })(); + }); + }); + await new Promise((resolve) => provider.listen(0, "127.0.0.1", resolve)); + ai = { + baseURL: `http://127.0.0.1:${provider.address().port}/v1`, + close: () => + new Promise((resolve) => { + provider.closeAllConnections(); + provider.close(resolve); + }), + }; + Object.assign(config.vars, { + AI_PROVIDER: "openai-compatible", + AI_MODEL: "stub", + AI_API_KEY: "local-smoke-key", + AI_BASE_URL: ai.baseURL, + FLAG_ALLOW_UNSAFE_AI_BASE_URL: "true", + }); + const configPath = resolve(temporary, "wrangler.json"); + await writeFile(configPath, JSON.stringify(config)); + process.env.CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV = "false"; + harness = createTestHarness({ root, workers: [{ configPath }] }); + await harness.listen(); + const worker = harness.getWorker(); + const request = (path, options = {}) => harness.fetch(`${origin}${path}`, options); + let response = await request("/api/health"); + assert.equal(response.status, 200); + assert.equal((await response.json()).storage.type, "r2"); + response = await request("/"); + assert.equal(response.status, 200); + assert.match(await response.text(), /rel="canonical"/); + assert.equal((await request("/_prerender/home/en-US.html")).status, 404); + assert.equal((await request("/missing.js")).status, 404); + assert.match((await request("/dashboard")).headers.get("x-robots-tag"), /noindex/); + response = await request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ + name: "Cloudflare smoke", + email: "worker@example.invalid", + username: "workersmoke", + displayUsername: "workersmoke", + password: "smoke-password-123", + }), + }); + assert.equal(response.status, 200, await response.clone().text()); + const cookie = response.headers + .getSetCookie() + .map((entry) => entry.split(";")[0]) + .join("; "); + assert(cookie); + const user = (await response.json()).user; + const require = createRequire(resolve(root, "apps/server/package.json")); + const { createORPCClient } = await import(pathToFileURL(require.resolve("@orpc/client")).href); + const { RPCLink } = await import(pathToFileURL(require.resolve("@orpc/client/fetch")).href); + const client = createORPCClient( + new RPCLink({ + url: `${origin}/api/rpc`, + headers: { cookie, origin }, + fetch: async (input, init) => { + const outgoing = new Request(input, init); + return harness.fetch(outgoing.url, { + method: outgoing.method, + headers: Object.fromEntries(outgoing.headers), + signal: outgoing.signal, + ...(!["GET", "HEAD"].includes(outgoing.method) ? { body: await outgoing.arrayBuffer() } : {}), + }); + }, + }), + ); + const id = await client.resume.create({ name: "Worker test", tags: [], withSampleData: true }); + const png = await readFile(resolve(root, "apps/web/public/pwa-64x64.png")); + const upload = await client.storage.uploadFile(new File([png], "picture.png", { type: "image/png" })); + assert.equal(upload.contentType, "image/png"); + response = await request(new URL(upload.url).pathname); + assert.equal(response.status, 200); + assert.deepEqual(Buffer.from(await response.arrayBuffer()), png); + const resume = await client.resume.getById({ id }); + resume.data.picture.url = upload.url; + resume.data.picture.hidden = false; + await client.resume.update({ id, isPublic: true, data: resume.data }); + const streamAbort = new AbortController(); + const events = await client.resume.updates.subscribe({ id }, { signal: streamAbort.signal }); + assert.equal((await events.next()).value.mutation, "sync"); + const nextEvent = events.next(); + // The first event is the initial DB snapshot; allow the following subscription handshake to finish. + await setTimeout(100); + await client.resume.patch({ id, operations: [{ op: "replace", path: "/basics/name", value: "Cloudflare Native" }] }); + const event = await Promise.race([ + nextEvent, + setTimeout(10_000).then(() => { + throw new Error("Resume update stream timed out"); + }), + ]); + assert.equal(event.value.mutation, "patch"); + assert.equal(event.value.resumeId, id); + streamAbort.abort(); + await events.return(); + response = await request(`/api/resumes/workersmoke/${resume.slug}/pdf`); + assert.equal(response.status, 200, await response.clone().text()); + const pdf = Buffer.from(await response.arrayBuffer()); + assert.equal(pdf.subarray(0, 5).toString(), "%PDF-"); + assert(pdf.includes(Buffer.from("/Subtype /Image")), "Uploaded picture must be embedded in PDF"); + assert.equal((await client.resume.getById({ id })).data.basics.name, "Cloudflare Native"); + const thread = await client.agent.threads.start({ resumeId: id }); + const send = (text, signal) => + client.agent.messages.send( + { + threadId: thread.id, + message: { id: randomBytes(12).toString("hex"), role: "user", parts: [{ type: "text", text }] }, + context: { document: false, posting: false }, + }, + { signal }, + ); + const reply = await send("Reply without the document", AbortSignal.timeout(15_000)); + let transcript = ""; + for await (const chunk of reply) transcript += chunk; + assert.match(transcript, /text-delta/); + let conversation = await client.agent.threads.get({ id: thread.id }); + assert.equal(conversation.thread.activeRunId, null); + assert( + conversation.messages.some( + (message) => + message.role === "assistant" && + message.parts.some((part) => part.type === "text" && part.text.includes("left the document out")), + ), + ); + const replyAbort = new AbortController(); + const slow = await send("Reply slowly", replyAbort.signal); + for await (const chunk of slow) { + if (chunk.includes("text-delta")) break; + } + replyAbort.abort(); + await client.agent.messages.stop({ threadId: thread.id }); + await slow.return(); + for (let attempt = 0; attempt < 50; attempt++) { + conversation = await client.agent.threads.get({ id: thread.id }); + if (!conversation.thread.activeRunId) break; + await setTimeout(100); + } + assert.equal(conversation.thread.activeRunId, null, "Stopping an assistant must release its claim"); + assert( + conversation.messages.some( + (message) => + message.role === "assistant" && + message.parts.some((part) => part.type === "text" && part.text.includes("word0")), + ), + "Stopped assistant text must persist", + ); + // Private R2 attachments must remain behind the owning session, even with spoofed forwarding headers. + const bindings = await worker.getEnv(); + const attachment = `uploads/${user.id}/pictures/smoke.pdf`; + await bindings.BUCKET.put(`production/${attachment}`, "private attachment"); + assert.equal((await request(`/${attachment}`, { headers: { "x-real-ip": "8.8.8.8" } })).status, 404); + response = await request(`/${attachment}`, { headers: { cookie } }); + assert.equal(response.status, 200); + assert.equal(await response.text(), "private attachment"); + await client.storage.deleteFile({ filename: upload.path }); + assert.equal((await request(new URL(upload.url).pathname)).status, 404); + // Concurrent limits must remain atomic, and persisted state must survive eviction. + const counter = bindings.COORDINATION.get(bindings.COORDINATION.idFromName("smoke-counter")); + const consume = () => + counter + .fetch("https://coordination/", { + method: "POST", + body: JSON.stringify({ operation: "consume", max: 5, window: 60_000 }), + }) + .then((r) => r.json()); + assert.equal((await Promise.all(Array.from({ length: 20 }, consume))).filter((result) => result.allowed).length, 5); + await worker.evictDurableObject("COORDINATION", { name: "smoke-counter" }); + assert.equal((await consume()).allowed, false); + const state = bindings.COORDINATION.get(bindings.COORDINATION.idFromName("smoke-cancellation")); + await state.fetch("https://coordination/", { + method: "POST", + body: JSON.stringify({ operation: "set", value: "stop", ttl: 100 }), + }); + await setTimeout(150); + assert.equal( + await ( + await state.fetch("https://coordination/", { method: "POST", body: JSON.stringify({ operation: "get" }) }) + ).json(), + null, + ); + console.log( + "Cloudflare smoke passed: auth, transactions, R2 privacy, live updates, PDF pictures, assistant streaming/stop, atomic limits, expiry.", + ); +} catch (error) { + for (const log of harness?.getLogs() ?? []) { + if (["error", "warn"].includes(log.level)) console.error(log.message); + } + throw error; +} finally { + await harness?.close(); + await ai?.close(); + if (created) await admin.query(`DROP DATABASE "${databaseName}" WITH (FORCE)`); + await admin.end(); + await rm(temporary, { recursive: true, force: true }); +} diff --git a/tooling/deployment/smoke.mjs b/tooling/deployment/smoke.mjs index c91ecad65..1d96dcc63 100644 --- a/tooling/deployment/smoke.mjs +++ b/tooling/deployment/smoke.mjs @@ -101,7 +101,8 @@ try { form.set("data", JSON.stringify({ json: {}, maps: [[]] })); form.set("0", file); const uploaded = await rpc("storage/uploadFile", form, staged); - const download = await checked(await request(uploaded.url, {}, false)); + assert.equal((await request(uploaded.url, {}, false)).status, 404, "non-image files require owner authentication"); + const download = await checked(await request(uploaded.url)); assert.equal((await download.arrayBuffer()).byteLength, file.size); await rpc("storage/deleteFile", { filename: uploaded.path }); console.log("Pages, auth, resume CRUD, public PDF, 10 MiB upload/download: passed"); diff --git a/tooling/package.json b/tooling/package.json index 433515ae2..607fbf367 100644 --- a/tooling/package.json +++ b/tooling/package.json @@ -27,10 +27,12 @@ "@types/react-dom": "catalog:", "@typescript/native-preview": "catalog:", "drizzle-orm": "catalog:", + "esbuild": "catalog:", "pg": "catalog:", "react": "19.3.0", "react-dom": "19.3.0", "tsx": "^4.23.15", - "vitest": "catalog:" + "vitest": "catalog:", + "wrangler": "catalog:" } } diff --git a/turbo.json b/turbo.json index df09d3066..a4770796a 100644 --- a/turbo.json +++ b/turbo.json @@ -82,6 +82,7 @@ "AI_*", "FLAG_*", "STRICT_SCHEMA_CHECK", + "CLOUDFLARE", "VERCEL", "VERCEL_ENV", "VERCEL_URL", @@ -122,6 +123,7 @@ "AI_*", "FLAG_*", "STRICT_SCHEMA_CHECK", + "CLOUDFLARE", "VERCEL", "VERCEL_ENV", "VERCEL_URL", @@ -163,6 +165,7 @@ "AI_*", "FLAG_*", "STRICT_SCHEMA_CHECK", + "CLOUDFLARE", "VERCEL", "VERCEL_ENV", "VERCEL_URL", @@ -204,6 +207,7 @@ "AI_*", "FLAG_*", "STRICT_SCHEMA_CHECK", + "CLOUDFLARE", "VERCEL", "VERCEL_ENV", "VERCEL_URL", @@ -297,6 +301,7 @@ "FLAG_ALLOW_UNSAFE_AI_BASE_URL", "AI_TEST_TIMEOUT_MS", "STRICT_SCHEMA_CHECK", + "CLOUDFLARE", "VERCEL", "VERCEL_ENV", "VERCEL_URL", diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 000000000..337f92cc7 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,48 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "reactive-resume", + "main": "apps/server/dist-cloudflare/index.js", + "compatibility_date": "2026-10-02", + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + "no_bundle": true, + "find_additional_modules": true, + "rules": [ + { "type": "ESModule", "globs": ["index.js"], "fallthrough": false }, + { "type": "CompiledWasm", "globs": ["*.wasm"], "fallthrough": false }, + { "type": "Text", "globs": ["prompts/*.md"], "fallthrough": false }, + ], + "assets": { + "directory": "apps/server/dist-cloudflare/assets", + "binding": "ASSETS", + "run_worker_first": [ + "/", + "/index.html", + "/ats-checker", + "/api/*", + "/uploads/*", + "/mcp", + "/mcp/*", + "/.well-known/*", + "/_prerender", + "/_prerender/*", + "/robots.txt", + "/sitemap.xml", + "/llms.txt", + "/schema.json", + ], + }, + "vars": { + "NODE_ENV": "production", + "CLOUDFLARE": "1", + "STORAGE_BACKEND": "r2", + "FLAG_DISABLE_IMAGE_PROCESSING": "true", + "DEPLOYMENT_NAMESPACE": "production", + "APP_URL": "http://localhost:8787", + }, + "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "00000000000000000000000000000000" }], + "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "reactive-resume" }], + "durable_objects": { "bindings": [{ "name": "COORDINATION", "class_name": "Coordination" }] }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Coordination"] }], + "limits": { "cpu_ms": 30000 }, + "observability": { "enabled": true, "head_sampling_rate": 0.1 }, +}