From f227c99311870e190b122b53da82a17aa33eb24f Mon Sep 17 00:00:00 2001 From: RedStar Date: Tue, 20 Oct 2026 19:40:50 +0200 Subject: [PATCH 1/3] feat(dashboard): run the dashboard as a Cloudflare Worker The managed cloud runs on Cloudflare, and the dashboard is the part that needs a database. A Worker cannot reuse a socket across requests, so the database follows the request through Hyperdrive, task history lives in Workers KV, and the poller stays off. Adds the Cloudflare build target, the Worker configuration, stand-ins for the four packages that cannot run in a Worker, and a manual deploy workflow. --- .github/workflows/deploy-dashboard.yml | 75 ++++++++++++++++ AGENTS.md | 1 + apps/docs/content/1.guide/16.deployment.md | 21 ++++- apps/web/nuxt.config.ts | 57 +++++++++++- apps/web/server/auth.config.ts | 14 ++- apps/web/server/cloudflare/kv.ts | 79 +++++++++++++++++ .../server/cloudflare/react-email-render.ts | 11 +++ apps/web/server/cloudflare/safer-buffer.ts | 21 +++++ .../cloudflare/subscription-provider.ts | 16 ++++ apps/web/server/plugins/poller.ts | 8 ++ apps/web/server/utils/database.ts | 86 ++++++++++++++++++- apps/web/server/utils/runtime.ts | 11 +++ apps/web/wrangler.jsonc | 28 ++++++ packages/auth/src/auth.test.ts | 31 +++++++ packages/auth/src/auth.ts | 52 +++++++++-- packages/database/src/client.test.ts | 64 +++++++++++++- packages/database/src/client.ts | 42 +++++++++ packages/database/src/index.ts | 1 + 18 files changed, 602 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/deploy-dashboard.yml create mode 100644 apps/web/server/cloudflare/kv.ts create mode 100644 apps/web/server/cloudflare/react-email-render.ts create mode 100644 apps/web/server/cloudflare/safer-buffer.ts create mode 100644 apps/web/server/cloudflare/subscription-provider.ts create mode 100644 apps/web/server/utils/runtime.ts create mode 100644 apps/web/wrangler.jsonc diff --git a/.github/workflows/deploy-dashboard.yml b/.github/workflows/deploy-dashboard.yml new file mode 100644 index 00000000..1d38a7a5 --- /dev/null +++ b/.github/workflows/deploy-dashboard.yml @@ -0,0 +1,75 @@ +name: Deploy dashboard + +# Publishes the managed cloud's dashboard to app.code-zero.dev as one Cloudflare Worker (see +# `apps/web/wrangler.jsonc`). Manual on purpose: a deploy changes what customers run, and applying +# the schema is a decision of its own, so each is chosen when the workflow is started. +# +# The Worker's own secrets (`NUXT_BETTER_AUTH_SECRET`, the model provider's key, mail) live on +# Cloudflare and are set once with `wrangler secret put`; this workflow never reads or sets them. + +on: + workflow_dispatch: + inputs: + migrate: + description: Apply database migrations before deploying + type: boolean + default: false + +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +env: + # Pinned so a deploy is reproducible; bump together with the compatibility date in wrangler.jsonc. + WRANGLER_VERSION: 4.151.1 + +jobs: + deploy: + name: Deploy dashboard + if: github.repository == 'wolfstar-project/code-zero' + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + environment: + name: dashboard + url: https://app.code-zero.dev + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup toolchain + uses: ./.github/actions/setup-toolchain + + - name: Build workspace dependencies + run: aube exec turbo run build --filter='@code-zero/web^...' + + - name: Apply database migrations + if: inputs.migrate + working-directory: packages/database + env: + # The origin database, reached directly: Hyperdrive is for the Worker, not for migrations. + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: aube run db:migrate + + - name: Build the Worker + working-directory: apps/web + env: + VITEHUB_HOSTING: cloudflare + NITRO_PRESET: cloudflare-module + CODE_ZERO_BUILD_COMMIT: ${{ github.sha }} + CODE_ZERO_BUILD_BRANCH: ${{ github.ref_name }} + # Every dependency is inlined into the Worker bundle, which needs more than Node's default. + NODE_OPTIONS: --max-old-space-size=6144 + run: aube exec nuxt build + + - name: Deploy to Cloudflare + working-directory: apps/web + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: npx --yes "wrangler@${WRANGLER_VERSION}" deploy --config .output/server/wrangler.json diff --git a/AGENTS.md b/AGENTS.md index 007a4000..fa7aa802 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,6 +184,7 @@ aube run build | `ci.yaml` | Lint, repository metadata, typecheck, tests, build, i18n | PR, push to `main`, merge group | | `cd.yml` | Builds and publishes the dashboard image to GHCR | Push to `main`, `vX.Y.Z` tags, manual | | `deploy-sites.yml` | Deploys marketing and docs to Cloudflare Workers | Push to `main` (site paths), manual | +| `deploy-dashboard.yml` | Deploys the dashboard to Cloudflare Workers | Manual | | `autofix.yml` | Pushes formatting and lint fixes back to the pull request | PR, merge group | | `zizmor.yaml` | Static analysis of GitHub Actions workflows | PR, push to `main`, merge group | | `semantic-pull-requests.yml` | Validates PR titles against Conventional Commits | PR opened, edited, synchronized | diff --git a/apps/docs/content/1.guide/16.deployment.md b/apps/docs/content/1.guide/16.deployment.md index 28cbd71b..8054324e 100644 --- a/apps/docs/content/1.guide/16.deployment.md +++ b/apps/docs/content/1.guide/16.deployment.md @@ -27,7 +27,26 @@ node apps/web/.output/server/index.mjs ## Task history storage -Task history uses the ViteHub KV Runtime Helper: filesystem-backed `fs-lite` by default (`.data/kv`), with Cloudflare KV, Deno KV, or Upstash available as driver configuration without touching application code. Records are redacted before they are written. +Task history uses the ViteHub KV Runtime Helper: filesystem-backed `fs-lite` by default (`.data/kv`), with Deno KV or Upstash available as driver configuration without touching application code. On Cloudflare Workers it uses a Workers KV namespace bound as `KV`, through the same contract. Records are redacted before they are written. + +## Cloudflare Workers + +The managed cloud runs the dashboard as one Worker. Build it with the Cloudflare target and deploy the merged configuration: + +```bash +VITEHUB_HOSTING=cloudflare aube exec --filter @code-zero/web nuxt build +npx wrangler deploy --config apps/web/.output/server/wrangler.json +``` + +`apps/web/wrangler.jsonc` holds the bindings, and the build merges it into `.output/server/wrangler.json`: + +- **`HYPERDRIVE`** — Cloudflare's pool in front of Postgres. Create it with `wrangler hyperdrive create --connection-string=...` and turn query caching off (`--caching-disabled=true`): sessions and permissions must never be read from a cache. A Worker opens a new database client for every request, because a connection cannot outlive the request that opened it. +- **`KV`** — the task-history namespace. +- **Secrets** — `wrangler secret put NUXT_BETTER_AUTH_SECRET`, plus the model provider's key and the mail credentials. They are read as environment variables. + +Two things differ from a self-hosted deployment. The poller does not run, because a Worker is frozen between requests; webhooks are the way work arrives until a scheduled trigger replaces it. Subscription model providers (`claude-code`, `codex-cli`) are not available, because they drive a CLI on the host. + +The **Deploy dashboard** workflow builds and deploys the Worker, and can apply the schema first (`migrate`). It runs on demand, and needs the `CLOUDFLARE_API_TOKEN` secret, the `CLOUDFLARE_ACCOUNT_ID` variable, and a `DATABASE_URL` secret for migrations. ## Hosted sandboxes diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index c6643e28..b1d00d44 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -1,6 +1,7 @@ import { symlink, unlink } from 'node:fs/promises'; import { basename } from 'node:path'; import process from 'node:process'; +import { fileURLToPath } from 'node:url'; // Read from `@code-zero/auth/config` rather than restated here, so the build-time flag below and // the server plugins in `server/auth.config.ts` cannot drift. That subpath carries policy only, @@ -9,7 +10,11 @@ import { infraFromEnvironment } from '@code-zero/auth/config'; import { defaultLocale, i18nLocalesFor, localeCookieName } from '@code-zero/i18n'; import { defineNuxtConfig } from 'nuxt/config'; -import { viteHubPresetFromEnvironment, viteHubVercelEntryAlias } from './config/env.js'; +import { + viteHubPresetFromEnvironment, + viteHubVercelEntryAlias, + type ViteHubPreset, +} from './config/env.js'; // The sign-in policy is deliberately not read here. Pages ask `/api/capabilities` at runtime, which // derives it from the same environment `server/auth.config.ts` builds Better Auth from, so changing @@ -30,6 +35,11 @@ const infraKvUrl = infra?.kvUrl ?? ''; // self-hosted `.output/` bundle, `vercel` emits `.vercel/output`. See `config/env.ts`. const viteHubPreset = viteHubPresetFromEnvironment(); +// The ViteHub integration, except where the build emits a Worker: see the comment on the module +// list below for why Cloudflare goes without it. +const viteHubModules: [string, { preset: ViteHubPreset; kv: true }][] = + viteHubPreset === 'cloudflare' ? [] : [['vite-hub/nuxt', { preset: viteHubPreset, kv: true }]]; + // Set by the `compiled` listener below and consumed by `close`, which removes the bridge again. let viteHubVercelEntry: string | undefined; @@ -68,7 +78,11 @@ export default defineNuxtConfig({ // its resolved default (`fs-lite` under `.data/kv` when self-hosted, the host's own driver — // Upstash on Vercel — otherwise): ViteHub picks the driver from the deployment preset, and a // serverless function has no writable filesystem to keep task history on. - ['vite-hub/nuxt', { preset: viteHubPreset, kv: true }], + // + // Not registered for Cloudflare: its KV runtime cannot be resolved by the Nitro build that + // emits a Worker, so that target aliases `vite-hub/kv` to a Workers KV adapter (see `nitro` + // below) and has no use for the rest of the integration. + ...viteHubModules, // Publishes what this build is (version, commit, branch, deploy channel) under // `runtimeConfig.public.buildInfo`. On Vercel the values are resolved while the build runs; // every other target resolves what it can and the server completes the rest at boot, so a @@ -187,6 +201,45 @@ export default defineNuxtConfig({ }, nitro: { + ...(viteHubPreset === 'cloudflare' + ? { + // A Worker, not Pages: the dashboard has server routes and a custom domain of its own. + preset: 'cloudflare_module', + // The scoped database (`server/utils/database.ts`) finds the Hyperdrive binding of the + // request being served through `useEvent()`, which needs the request kept in async + // context. Only this target pays for it. + experimental: { asyncContext: true }, + // `deployConfig` merges `wrangler.jsonc` into `.output/server/wrangler.json`, the file + // `wrangler deploy` reads; `nodeCompat` makes the build target `nodejs_compat`, which the + // database driver and Better Auth rely on. + cloudflare: { deployConfig: true, nodeCompat: true }, + // The passkey verifier (`@peculiar/x509` → `tsyringe`) needs `reflect-metadata` to run + // before it, and imports it only for that effect. Left to tree-shaking, the bundle keeps + // the code but never runs it, and the Worker refuses to start. + moduleSideEffects: ['reflect-metadata'], + // `store.ts` keeps importing `kv` from `vite-hub/kv`; on Cloudflare that name resolves to + // an adapter over the Worker's own KV binding. + // Packages that cannot run in a Worker, replaced by modules that say why. `resend` loads + // a React renderer Code Zero does not use, the two subscription providers drive a local + // CLI, and `safer-buffer` throws while the bundle starts. A bundle cannot leave an import + // unresolved, so each one points at a stand-in. + alias: { + 'safer-buffer': fileURLToPath( + new URL('./server/cloudflare/safer-buffer.ts', import.meta.url), + ), + 'ai-sdk-provider-claude-code': fileURLToPath( + new URL('./server/cloudflare/subscription-provider.ts', import.meta.url), + ), + 'ai-sdk-provider-codex-cli': fileURLToPath( + new URL('./server/cloudflare/subscription-provider.ts', import.meta.url), + ), + 'vite-hub/kv': fileURLToPath(new URL('./server/cloudflare/kv.ts', import.meta.url)), + '@react-email/render': fileURLToPath( + new URL('./server/cloudflare/react-email-render.ts', import.meta.url), + ), + }, + } + : {}), // Registered as a Nitro module rather than through `nitro.hooks`: a handler under that key // replaces the preset's own handler for the same hook, and the `vercel` preset writes // `config.json` and each function's `.vc-config.json` from its `compiled` hook — losing it diff --git a/apps/web/server/auth.config.ts b/apps/web/server/auth.config.ts index f0d3be86..1086de79 100644 --- a/apps/web/server/auth.config.ts +++ b/apps/web/server/auth.config.ts @@ -1,9 +1,12 @@ import { authBetterAuthOptions, authDatabaseOptionsFromEnvironment } from '@code-zero/auth'; +import { optionalDatabaseUrlFromEnvironment } from '@code-zero/database'; import { createMailer, mailProviderNameFromEnvironment } from '@code-zero/mail'; import { defineServerAuth } from '@onmax/nuxt-better-auth/config'; import { memoryAdapter } from 'better-auth/adapters/memory'; import { testUtils } from 'better-auth/plugins'; +import { database } from './utils/database.js'; + // This module is the composition root for authentication, so it is where the mail transport is // bound and injected. `packages/auth` declares the delivery contract structurally and never // imports `@code-zero/mail`, which keeps one capability package from depending on another. @@ -37,7 +40,16 @@ const dashboardUrl = dashboardUrlFromEnvironment(process.env); * No entitlements are passed, so SSO and SCIM stay unregistered until an edition supplies them. */ const options = authBetterAuthOptions({ - ...authDatabaseOptionsFromEnvironment(authEnvironment()), + // A connection string means this process owns a pool, opened here as it always was. Without one + // the database comes from the host: on Cloudflare the connection is a Hyperdrive binding, which + // no environment variable carries and which only exists while a request is being served. The + // shared `database()` follows the request, so Better Auth, which keeps what it is given for the + // life of the process, never holds a socket that the next request could not use. A deployment + // that sets neither fails on its first query with the same message the pool would have raised. + ...authDatabaseOptionsFromEnvironment( + authEnvironment(), + optionalDatabaseUrlFromEnvironment(authEnvironment()) ? undefined : database(), + ), ...(dashboardUrl ? { dashboardUrl } : {}), ...(deliversMail ? authMailDelivery(sendMail) : {}), }); diff --git a/apps/web/server/cloudflare/kv.ts b/apps/web/server/cloudflare/kv.ts new file mode 100644 index 00000000..04fbe834 --- /dev/null +++ b/apps/web/server/cloudflare/kv.ts @@ -0,0 +1,79 @@ +/** + * The key-value helper the dashboard uses on Cloudflare, over a Workers KV binding. + * + * `nuxt.config.ts` aliases `vite-hub/kv` to this file in a Cloudflare build, so + * `server/utils/store.ts` imports the same `kv` there as everywhere else. It answers with the same + * `[error, value]` pairs, and nothing outside this file knows which store is behind them. + * + * Values are JSON, as `KeyValueStorage` documents. A key is whatever the caller passes; a prefix + * query returns the keys that begin with it, which is what Workers KV's `list` does natively. + */ + +interface KVNamespace { + get(key: string, type: 'json'): Promise; + put(key: string, value: string): Promise; + delete(key: string): Promise; + list(options: { + prefix?: string; + cursor?: string; + }): Promise<{ keys: { name: string }[]; list_complete: boolean; cursor?: string }>; +} + +type Result = [error: Error | undefined, value: T | undefined]; + +function namespace(): KVNamespace { + const binding = kvBinding(useEvent().context.cloudflare); + if (!binding) throw new Error('the KV binding is not configured on this Worker'); + return binding; +} + +/** Narrows what Nitro leaves untyped on `event.context.cloudflare` to the KV binding. */ +function kvBinding(cloudflare: unknown): KVNamespace | undefined { + if (typeof cloudflare !== 'object' || cloudflare === null || !('env' in cloudflare)) { + return undefined; + } + const { env } = cloudflare; + if (typeof env !== 'object' || env === null || !('KV' in env)) return undefined; + const { KV: binding } = env; + return isKVNamespace(binding) ? binding : undefined; +} + +function isKVNamespace(value: unknown): value is KVNamespace { + return ( + typeof value === 'object' && + value !== null && + 'get' in value && + 'put' in value && + 'delete' in value && + 'list' in value + ); +} + +async function attempt(operation: () => Promise): Promise> { + try { + return [undefined, await operation()]; + } catch (error) { + return [error instanceof Error ? error : new Error(String(error)), undefined]; + } +} + +export const kv = { + get: (key: string): Promise> => attempt(() => namespace().get(key, 'json')), + + set: (key: string, value: unknown): Promise> => + attempt(() => namespace().put(key, JSON.stringify(value))), + + del: (key: string): Promise> => attempt(() => namespace().delete(key)), + + keys: (base = ''): Promise> => + attempt(async () => { + const names: string[] = []; + let cursor: string | undefined; + do { + const page = await namespace().list({ prefix: base, ...(cursor ? { cursor } : {}) }); + names.push(...page.keys.map((key) => key.name)); + cursor = page.list_complete ? undefined : page.cursor; + } while (cursor); + return names; + }), +}; diff --git a/apps/web/server/cloudflare/react-email-render.ts b/apps/web/server/cloudflare/react-email-render.ts new file mode 100644 index 00000000..93a5b6b2 --- /dev/null +++ b/apps/web/server/cloudflare/react-email-render.ts @@ -0,0 +1,11 @@ +/** + * Stands in for `@react-email/render`, which `resend` loads only to render a React element. + * + * Code Zero sends HTML compiled from `packages/mail`, never a React tree, so that path is never + * taken. A Worker bundle cannot leave an import unresolved, though, so `nuxt.config.ts` aliases the + * package here on Cloudflare; if a React message were ever sent, it fails with this message instead + * of a missing module. + */ +export function render(): never { + throw new Error('rendering React emails is not supported; send the compiled HTML instead'); +} diff --git a/apps/web/server/cloudflare/safer-buffer.ts b/apps/web/server/cloudflare/safer-buffer.ts new file mode 100644 index 00000000..85ab2684 --- /dev/null +++ b/apps/web/server/cloudflare/safer-buffer.ts @@ -0,0 +1,21 @@ +/** + * Stands in for `safer-buffer`, which cannot load in a Worker. + * + * `safer-buffer` copies the properties of `require('buffer')` with `buffer.hasOwnProperty(key)`, and + * under `nodejs_compat` that module is a namespace without the method, so the copy throws while the + * bundle starts. What the package adds on every runtime this app targets is one thing: a `Buffer` + * without `allocUnsafe` and `allocUnsafeSlow`, so a caller cannot get uninitialised memory by + * mistake. This keeps exactly that, and nothing of the fallbacks for Node versions older than 4.5. + * `nuxt.config.ts` aliases the package here on Cloudflare. + */ +import { Buffer as NodeBuffer } from 'node:buffer'; + +const UNINITIALISED = new Set(['allocUnsafe', 'allocUnsafeSlow']); + +export const Buffer = Object.assign( + Object.fromEntries(Object.entries(NodeBuffer).filter(([key]) => !UNINITIALISED.has(key))), + { from: NodeBuffer.from.bind(NodeBuffer), alloc: NodeBuffer.alloc.bind(NodeBuffer) }, + { prototype: NodeBuffer.prototype }, +); + +export default { Buffer }; diff --git a/apps/web/server/cloudflare/subscription-provider.ts b/apps/web/server/cloudflare/subscription-provider.ts new file mode 100644 index 00000000..a026c184 --- /dev/null +++ b/apps/web/server/cloudflare/subscription-provider.ts @@ -0,0 +1,16 @@ +/** + * Stands in for the two vendor packages that drive a local coding-agent CLI. + * + * `ai-sdk-provider-claude-code` and `ai-sdk-provider-codex-cli` start the vendor's executable on + * the host and use the user's own login, which a Worker has neither of. `nuxt.config.ts` aliases + * both here on Cloudflare so the bundle builds; a deployment that asks for a subscription model is + * refused with this message rather than failing on a missing module, and an authentication check + * on a failure that never reached a CLI answers no. + */ +function unavailable(): never { + throw new Error('subscription model providers need a local CLI and are not available on Workers'); +} + +export const createClaudeCode = unavailable; +export const createCodexCli = unavailable; +export const isAuthenticationError = (): boolean => false; diff --git a/apps/web/server/plugins/poller.ts b/apps/web/server/plugins/poller.ts index 63d55528..9612c7f8 100644 --- a/apps/web/server/plugins/poller.ts +++ b/apps/web/server/plugins/poller.ts @@ -29,6 +29,14 @@ import { GitHubPullRequests } from '@code-zero/source-control'; * webhook route. */ export default defineNitroPlugin((nitroApp) => { + // A Worker is frozen between requests, so a timer scheduled here would fire only while some + // unrelated request happened to be in flight. Polling on Cloudflare is a scheduled trigger's job; + // webhooks, the push-based path, work there as everywhere else. + if (isCloudflareWorker()) { + console.info('[poll] running on Cloudflare Workers; not polling from inside the Worker'); + return; + } + const token = githubTokenFromEnvironment(); if (!token) { console.warn('[poll] GITHUB_TOKEN is not configured; not polling'); diff --git a/apps/web/server/utils/database.ts b/apps/web/server/utils/database.ts index 6deaaabc..209561c5 100644 --- a/apps/web/server/utils/database.ts +++ b/apps/web/server/utils/database.ts @@ -1,7 +1,73 @@ -import { createDatabase, databaseUrlFromEnvironment, type Database } from '@code-zero/database'; +import { + createDatabase, + createScopedDatabase, + databaseUrlFromEnvironment, + type Database, +} from '@code-zero/database'; + +/** The part of a Hyperdrive binding this app reads: where to connect for the current request. */ +interface HyperdriveBinding { + readonly connectionString: string; +} + +/** + * Connections a single request may open through Hyperdrive. + * + * Hyperdrive owns the pool towards Postgres, so a request needs no more than a handful of its own + * connections to it; more would only spend the Worker's six simultaneous outbound connections. + */ +const REQUEST_MAXIMUM_CONNECTIONS = 5; + +/** The client opened for each request that reached Postgres through Hyperdrive. */ +const requestClients = new WeakMap(); + +/** Narrows what Nitro leaves untyped on `event.context.cloudflare` to the binding this app reads. */ +function hyperdriveBinding(cloudflare: unknown): HyperdriveBinding | undefined { + if (typeof cloudflare !== 'object' || cloudflare === null || !('env' in cloudflare)) { + return undefined; + } + const { env } = cloudflare; + if (typeof env !== 'object' || env === null || !('HYPERDRIVE' in env)) return undefined; + const { HYPERDRIVE: binding } = env; + if (typeof binding !== 'object' || binding === null || !('connectionString' in binding)) { + return undefined; + } + return typeof binding.connectionString === 'string' + ? { connectionString: binding.connectionString } + : undefined; +} + +/** The request being served, or nothing when none is in progress or the host keeps no context. */ +function requestEvent() { + try { + return useEvent(); + } catch { + return undefined; + } +} + +function requestDatabase(): Database | undefined { + const event = requestEvent(); + const binding = hyperdriveBinding(event?.context.cloudflare); + if (!event || !binding) return undefined; + + let client = requestClients.get(event); + if (!client) { + // A new client for every request: a socket opened while serving one cannot be used by the next, + // which is why a Worker cannot share the process-wide pool below. Hyperdrive keeps the + // connections to Postgres warm, so opening a client here is cheap. + client = createDatabase({ + connectionString: binding.connectionString, + maximumConnections: REQUEST_MAXIMUM_CONNECTIONS, + fetchTypes: false, + }); + requestClients.set(event, client); + } + return client; +} /** - * The one connection pool this process opens, created on first use. + * The one connection pool a long-lived process opens, created on first use. * * Lazy rather than at module load: a request that never touches the store — every read the board * makes, the health check a load balancer polls — should not have opened a socket, and a process @@ -14,7 +80,21 @@ import { createDatabase, databaseUrlFromEnvironment, type Database } from '@code */ let pool: Database | undefined; -export function database(): Database { +function processDatabase(): Database { pool ??= createDatabase({ connectionString: databaseUrlFromEnvironment() }); return pool; } + +/** + * The database every caller shares, which resolves to the right client at the moment it is used. + * + * A request served through a Hyperdrive binding gets a client of its own (`requestDatabase`); every + * other request, and anything running outside one, uses the process-wide pool. The answer is a + * property of the call and not of the module, so this single object is safe to hand to a library + * that keeps it for good, as Better Auth does. + */ +const scopedDatabase = createScopedDatabase(() => requestDatabase() ?? processDatabase()); + +export function database(): Database { + return scopedDatabase; +} diff --git a/apps/web/server/utils/runtime.ts b/apps/web/server/utils/runtime.ts new file mode 100644 index 00000000..1009b86f --- /dev/null +++ b/apps/web/server/utils/runtime.ts @@ -0,0 +1,11 @@ +/** + * Whether this code is running inside a Cloudflare Worker. + * + * Read from the runtime rather than from the build: the Worker identifies itself in + * `navigator.userAgent`, and every other host this app builds for (Node, Vercel) does not, so one + * bundle never needs to be told where it is. What differs there is not a preference but a limit — + * a Worker holds no process-wide timers and no connection that outlives a request. + */ +export function isCloudflareWorker(): boolean { + return globalThis.navigator?.userAgent === 'Cloudflare-Workers'; +} diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc new file mode 100644 index 00000000..ca6efdea --- /dev/null +++ b/apps/web/wrangler.jsonc @@ -0,0 +1,28 @@ +{ + // The managed cloud's dashboard, as one Worker: the Nuxt server (typed RPC, REST, Better Auth) + // and the prerendered assets. `nuxt build` with the Cloudflare preset merges this file into + // `.output/server/wrangler.json`, which is what `wrangler deploy` reads. + "name": "code-zero-app", + "compatibility_date": "2026-10-01", + "compatibility_flags": ["nodejs_compat"], + "routes": [{ "pattern": "app.code-zero.dev", "custom_domain": true }], + "workers_dev": true, + "observability": { "enabled": true }, + "vars": { + // Stated, never inferred from the host: see `CODE_ZERO_EDITION` in the deployment guide. + "CODE_ZERO_EDITION": "cloud", + "NUXT_PUBLIC_SITE_URL": "https://app.code-zero.dev", + }, + "hyperdrive": [ + { + // Postgres, through Cloudflare's pool. Query caching is off on this configuration: sessions + // and permissions must never be read from a cache. + "binding": "HYPERDRIVE", + "id": "1f4f5b071333498380c36ec60edbbcb8", + }, + ], + "kv_namespaces": [ + // Task history, behind the `KeyValueStorage` contract. + { "binding": "KV", "id": "ab02095fe00f450587bf2ee731aa6838" }, + ], +} diff --git a/packages/auth/src/auth.test.ts b/packages/auth/src/auth.test.ts index 8095be78..009fb804 100644 --- a/packages/auth/src/auth.test.ts +++ b/packages/auth/src/auth.test.ts @@ -1,3 +1,4 @@ +import { createDatabase } from '@code-zero/database'; import { betterAuth } from 'better-auth'; import { memoryAdapter } from 'better-auth/adapters/memory'; import { describe, expect, it } from 'vitest'; @@ -19,6 +20,8 @@ const completeEnvironment = { DATABASE_URL: 'postgres://postgres:postgres@localhost:5432/code_zero_auth', }; +const MISSING_STORE_PATTERN = /need a `database` or a `databaseUrl`/; +const BOTH_STORES_PATTERN = /either `database` or `databaseUrl`/; const MISSING_URL_MESSAGE = /missing required environment variable: BETTER_AUTH_URL/; const MISSING_SEND_PRIVATE_INVITATION_PATTERN = /sendPrivateInvitationEmail/; const MISSING_DASHBOARD_URL_PATTERN = /dashboardUrl/; @@ -162,6 +165,34 @@ describe('authOptionsFromEnvironment', () => { ).toBe(DATABASE_URL); }); + it('does not read the connection string when the host owns the database', () => { + const { DATABASE_URL, ...environment } = completeEnvironment; + // Opens nothing: the client connects on its first query, and none is made here. + const database = createDatabase({ connectionString: completeEnvironment.DATABASE_URL }); + + const options = authDatabaseOptionsFromEnvironment(environment, database); + + expect(options.database).toBe(database); + expect(options.databaseUrl).toBeUndefined(); + expect(DATABASE_URL).toBeDefined(); + }); + + it('refuses to build without a database or a connection string', () => { + expect(() => authBetterAuthOptions({ config: defaultAuthConfig })).toThrow( + MISSING_STORE_PATTERN, + ); + }); + + it('refuses to build with both a database and a connection string', () => { + expect(() => + authBetterAuthOptions({ + config: defaultAuthConfig, + database: createDatabase({ connectionString: completeEnvironment.DATABASE_URL }), + databaseUrl: completeEnvironment.DATABASE_URL, + }), + ).toThrow(BOTH_STORES_PATTERN); + }); + it('omits GitHub unless both credentials are configured', () => { expect(authOptionsFromEnvironment(completeEnvironment).github).toBeUndefined(); expect( diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index c5dfa214..754046c2 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -3,7 +3,12 @@ import { dash, sentinel } from '@better-auth/infra'; import { passkey } from '@better-auth/passkey'; import { scim } from '@better-auth/scim'; import { sso } from '@better-auth/sso'; -import { createDatabase, databaseUrlFromEnvironment, schema } from '@code-zero/database'; +import { + createDatabase, + databaseUrlFromEnvironment, + schema, + type Database, +} from '@code-zero/database'; import { betterEnrollment } from '@octopi-ai/better-enrollment'; import { betterAuth } from 'better-auth'; import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth'; @@ -133,9 +138,17 @@ export interface AuthDatabaseOptions { * Postgres connection string holding users and sessions. * * The pool is opened by `@code-zero/database`, which owns the schema and the migrations for - * those tables. + * those tables. Exactly one of this and `database` is given. + */ + readonly databaseUrl?: string; + /** + * A database the caller already owns, used instead of opening a pool from `databaseUrl`. + * + * For a host whose connections cannot outlive a request, where the caller passes a database that + * follows the request (`createScopedDatabase`) rather than one pool that Better Auth would hold + * for the life of the process. */ - readonly databaseUrl: string; + readonly database?: Database; readonly config: AuthConfig; readonly github?: GithubOauthCredentials; readonly gitlab?: GitlabOauthCredentials; @@ -257,10 +270,10 @@ export function authBetterAuthOptions(options: AuthDatabaseOptions): Pick< // The client and the tables both come from `@code-zero/database`: this package owns // authentication policy, not the store, so the shape of `user` and `session` stays reviewable in // one place and any other consumer of those tables sees the same declarations. - const database: BetterAuthOptions['database'] = drizzleAdapter( - createDatabase({ connectionString: options.databaseUrl }), - { provider: 'pg', schema }, - ); + const database: BetterAuthOptions['database'] = drizzleAdapter(authDatabase(options), { + provider: 'pg', + schema, + }); const { sendVerificationEmail, sendPasswordResetEmail } = options; @@ -798,9 +811,14 @@ function requireEnvironmentValue( * regardless of which process opens it; it accepts the pre-split `AUTH_DATABASE_URL` as well. The * error messages name the variable but never echo its value, so a misconfigured deployment cannot * leak a secret or a connection string into a crash log. + * + * A host that owns its database passes it as `database`, and the connection string is then not + * read at all: on Cloudflare the connection comes from a Hyperdrive binding, which no environment + * variable carries. */ export function authDatabaseOptionsFromEnvironment( environment: Readonly> = process.env, + database?: Database, ): AuthDatabaseOptions { const github = githubCredentialsFromEnvironment(environment); const gitlab = gitlabCredentialsFromEnvironment(environment); @@ -810,7 +828,7 @@ export function authDatabaseOptionsFromEnvironment( const oauthProxy = oauthProxyFromEnvironment(environment); const infra = infraFromEnvironment(environment); return { - databaseUrl: databaseUrlFromEnvironment(environment), + ...(database ? { database } : { databaseUrl: databaseUrlFromEnvironment(environment) }), config: authConfigFromEnvironment(environment), ...(github ? { github } : {}), ...(gitlab ? { gitlab } : {}), @@ -822,6 +840,24 @@ export function authDatabaseOptionsFromEnvironment( }; } +/** + * The database Better Auth reads and writes, from whichever of the two options was given. + * + * Both or neither is a deployment error, refused here for the same reason a missing secret is: a + * guess would send authentication to a store nobody chose. The message names the options and never + * echoes a connection string. + */ +function authDatabase(options: AuthDatabaseOptions): Database { + if (options.database && options.databaseUrl) { + throw new Error('pass either `database` or `databaseUrl` to the auth options, not both'); + } + if (options.database) return options.database; + if (!options.databaseUrl) { + throw new Error('the auth options need a `database` or a `databaseUrl`'); + } + return createDatabase({ connectionString: options.databaseUrl }); +} + /** * Resolve `createAuth` options from the environment. * diff --git a/packages/database/src/client.test.ts b/packages/database/src/client.test.ts index 66d6cb6f..7d31191b 100644 --- a/packages/database/src/client.test.ts +++ b/packages/database/src/client.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { databaseUrlFromEnvironment, optionalDatabaseUrlFromEnvironment } from './client.js'; +import { + createScopedDatabase, + databaseUrlFromEnvironment, + optionalDatabaseUrlFromEnvironment, +} from './client.js'; import { schema } from './schema/index.js'; const CONNECTION_STRING = 'postgres://postgres:postgres@localhost:5432/code_zero'; @@ -115,3 +119,61 @@ describe('schema', () => { ]); }); }); + +// A stand-in with the one thing the proxy relies on: members that read `this`. Nothing here +// opens a socket, which is what keeps the test deterministic. +interface Probe { + readonly name: string; + describe(): string; +} + +function probe(name: string): Probe { + return { + name, + describe() { + return this.name; + }, + }; +} + +describe('createScopedDatabase', () => { + it('answers from whichever client is current when a member is read', () => { + let current = probe('first'); + const scoped = createScopedDatabase(() => current); + + expect(scoped.name).toBe('first'); + current = probe('second'); + expect(scoped.name).toBe('second'); + }); + + it('runs a method against the client it was read from, even when called detached', () => { + let current = probe('first'); + const scoped = createScopedDatabase(() => current); + + // Detached on purpose: this is the case under test. + // oxlint-disable-next-line typescript/unbound-method -- reading the method off the proxy is the point + const detached = scoped.describe; + current = probe('second'); + + expect(detached()).toBe('first'); + expect(scoped.describe()).toBe('second'); + }); + + it('opens nothing until a member is used', () => { + let resolutions = 0; + createScopedDatabase(() => { + resolutions += 1; + return probe('unused'); + }); + + expect(resolutions).toBe(0); + }); + + it('lets a failure to resolve a client reach the caller', () => { + const scoped = createScopedDatabase((): Probe => { + throw new Error('no request in progress'); + }); + + expect(() => scoped.name).toThrow('no request in progress'); + }); +}); diff --git a/packages/database/src/client.ts b/packages/database/src/client.ts index c551da8a..525c5f73 100644 --- a/packages/database/src/client.ts +++ b/packages/database/src/client.ts @@ -23,6 +23,14 @@ export interface DatabaseOptions { * whatever else shares it. */ readonly maximumConnections?: number; + /** + * Whether `postgres` asks the server for its array types when it connects. + * + * It is on by default. Behind Hyperdrive it is turned off, as Cloudflare documents for this + * driver: the lookup costs a round trip on every new connection, and no table here has an array + * column whose type the driver would need to learn. + */ + readonly fetchTypes?: boolean; } /** Matches the default cap Postgres ships with divided across a handful of processes. */ @@ -38,11 +46,45 @@ export const DEFAULT_MAXIMUM_CONNECTIONS = 10; export function createDatabase(options: DatabaseOptions): Database { const connection = postgres(options.connectionString, { max: options.maximumConnections ?? DEFAULT_MAXIMUM_CONNECTIONS, + ...(options.fetchTypes === undefined ? {} : { fetch_types: options.fetchTypes }), }); return drizzle(connection, { schema }); } +/** + * A database that is looked up each time it is used, rather than opened once. + * + * A Cloudflare Worker cannot reuse a socket across requests: one opened while serving a request is + * unusable from the next, so a connection held in module state breaks on the second request. + * Libraries that take a database once, when they are built (Better Auth does), therefore need an + * object that stays the same while the client behind it follows the request. `resolve` returns the + * client for the work in progress; every property read and call goes to it, so the caller never + * holds a connection of its own. + * + * Functions are bound to the client they were read from, so a method detached from the proxy still + * runs against the client that was current when it was read. + */ +export function createScopedDatabase(resolve: () => Client): Client { + // The target only has to be an object: every trap answers from `resolve()`, so nothing is ever + // read from it. Typing it as `Client` is the one place a proxy needs to claim a shape it does not + // have, which is also why this is generic, so callers and tests never assert anything themselves. + // oxlint-disable-next-line no-unsafe-type-assertion -- the traps answer from the resolved client + const target = {} as Client; + return new Proxy(target, { + get(_target, property) { + const client = resolve(); + // Property reads are the proxy's whole job; the key is whatever the caller asked for. + // oxlint-disable-next-line anti-slop/no-reflect-get -- forwarding an arbitrary key to the client + const value: unknown = Reflect.get(client, property, client); + return typeof value === 'function' ? value.bind(client) : value; + }, + has(_target, property) { + return Reflect.has(resolve(), property); + }, + }); +} + /** * The variable a deployment sets to point at Postgres. * diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 573e4bab..666af6d9 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,5 +1,6 @@ export { createDatabase, + createScopedDatabase, databaseUrlFromEnvironment, DATABASE_URL_VARIABLE, DEFAULT_MAXIMUM_CONNECTIONS, From 877e7afe794fff9f887b2c752c3f39b7e5732520 Mon Sep 17 00:00:00 2001 From: RedStar Date: Tue, 20 Oct 2026 20:30:00 +0200 Subject: [PATCH 2/3] test(dashboard): cover how a request picks its database client The selection between a client opened for the request and the process-wide pool had no test of its own, so a change that shared one client across requests passed every existing test. It now lives in a module of its own and is tested with overlapping requests. --- apps/web/server/utils/database.ts | 60 ++++----------- apps/web/server/utils/request-database.ts | 63 ++++++++++++++++ apps/web/test/unit/request-database.test.ts | 83 +++++++++++++++++++++ 3 files changed, 160 insertions(+), 46 deletions(-) create mode 100644 apps/web/server/utils/request-database.ts create mode 100644 apps/web/test/unit/request-database.test.ts diff --git a/apps/web/server/utils/database.ts b/apps/web/server/utils/database.ts index 209561c5..96300f95 100644 --- a/apps/web/server/utils/database.ts +++ b/apps/web/server/utils/database.ts @@ -5,11 +5,6 @@ import { type Database, } from '@code-zero/database'; -/** The part of a Hyperdrive binding this app reads: where to connect for the current request. */ -interface HyperdriveBinding { - readonly connectionString: string; -} - /** * Connections a single request may open through Hyperdrive. * @@ -18,25 +13,6 @@ interface HyperdriveBinding { */ const REQUEST_MAXIMUM_CONNECTIONS = 5; -/** The client opened for each request that reached Postgres through Hyperdrive. */ -const requestClients = new WeakMap(); - -/** Narrows what Nitro leaves untyped on `event.context.cloudflare` to the binding this app reads. */ -function hyperdriveBinding(cloudflare: unknown): HyperdriveBinding | undefined { - if (typeof cloudflare !== 'object' || cloudflare === null || !('env' in cloudflare)) { - return undefined; - } - const { env } = cloudflare; - if (typeof env !== 'object' || env === null || !('HYPERDRIVE' in env)) return undefined; - const { HYPERDRIVE: binding } = env; - if (typeof binding !== 'object' || binding === null || !('connectionString' in binding)) { - return undefined; - } - return typeof binding.connectionString === 'string' - ? { connectionString: binding.connectionString } - : undefined; -} - /** The request being served, or nothing when none is in progress or the host keeps no context. */ function requestEvent() { try { @@ -46,26 +22,6 @@ function requestEvent() { } } -function requestDatabase(): Database | undefined { - const event = requestEvent(); - const binding = hyperdriveBinding(event?.context.cloudflare); - if (!event || !binding) return undefined; - - let client = requestClients.get(event); - if (!client) { - // A new client for every request: a socket opened while serving one cannot be used by the next, - // which is why a Worker cannot share the process-wide pool below. Hyperdrive keeps the - // connections to Postgres warm, so opening a client here is cheap. - client = createDatabase({ - connectionString: binding.connectionString, - maximumConnections: REQUEST_MAXIMUM_CONNECTIONS, - fetchTypes: false, - }); - requestClients.set(event, client); - } - return client; -} - /** * The one connection pool a long-lived process opens, created on first use. * @@ -88,12 +44,24 @@ function processDatabase(): Database { /** * The database every caller shares, which resolves to the right client at the moment it is used. * - * A request served through a Hyperdrive binding gets a client of its own (`requestDatabase`); every + * A request served through a Hyperdrive binding gets a client of its own (`createRequestDatabase`); every * other request, and anything running outside one, uses the process-wide pool. The answer is a * property of the call and not of the module, so this single object is safe to hand to a library * that keeps it for good, as Better Auth does. */ -const scopedDatabase = createScopedDatabase(() => requestDatabase() ?? processDatabase()); +const scopedDatabase = createScopedDatabase( + createRequestDatabase({ + request: requestEvent, + // Hyperdrive keeps the connections to Postgres warm, so opening a client here is cheap. + open: (connectionString) => + createDatabase({ + connectionString, + maximumConnections: REQUEST_MAXIMUM_CONNECTIONS, + fetchTypes: false, + }), + fallback: processDatabase, + }), +); export function database(): Database { return scopedDatabase; diff --git a/apps/web/server/utils/request-database.ts b/apps/web/server/utils/request-database.ts new file mode 100644 index 00000000..775a6a08 --- /dev/null +++ b/apps/web/server/utils/request-database.ts @@ -0,0 +1,63 @@ +/** The part of a Hyperdrive binding this app reads: where to connect for the current request. */ +interface HyperdriveBinding { + readonly connectionString: string; +} + +/** What the selection needs of the request being served. */ +export interface RequestScope { + readonly context: object; +} + +export interface RequestDatabaseOptions { + /** The request being served, or nothing when none is in progress. */ + readonly request: () => RequestScope | undefined; + /** Opens a client for the connection string a Hyperdrive binding hands this request. */ + readonly open: (connectionString: string) => Client; + /** The client for everything that did not arrive through a Hyperdrive binding. */ + readonly fallback: () => Client; +} + +/** Narrows what Nitro leaves untyped on `event.context.cloudflare` to the binding this app reads. */ +function hyperdriveBinding(cloudflare: unknown): HyperdriveBinding | undefined { + if (typeof cloudflare !== 'object' || cloudflare === null || !('env' in cloudflare)) { + return undefined; + } + const { env } = cloudflare; + if (typeof env !== 'object' || env === null || !('HYPERDRIVE' in env)) return undefined; + const { HYPERDRIVE: binding } = env; + if (typeof binding !== 'object' || binding === null || !('connectionString' in binding)) { + return undefined; + } + return typeof binding.connectionString === 'string' + ? { connectionString: binding.connectionString } + : undefined; +} + +/** + * Picks the client for the work in progress. + * + * A request that arrived through a Hyperdrive binding gets a client of its own, opened on first use + * and reused for the rest of that request. A new client for every request is the point: a socket + * opened while serving one cannot be used by the next, which is why a Worker cannot share a + * process-wide pool. Anything else, including code running outside a request, gets the fallback. + */ +export function createRequestDatabase( + options: RequestDatabaseOptions, +): () => Client { + const clients = new WeakMap(); + + return () => { + const request = options.request(); + const binding = hyperdriveBinding( + request && 'cloudflare' in request.context ? request.context.cloudflare : undefined, + ); + if (!request || !binding) return options.fallback(); + + let client = clients.get(request); + if (!client) { + client = options.open(binding.connectionString); + clients.set(request, client); + } + return client; + }; +} diff --git a/apps/web/test/unit/request-database.test.ts b/apps/web/test/unit/request-database.test.ts new file mode 100644 index 00000000..1eafb91c --- /dev/null +++ b/apps/web/test/unit/request-database.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { createRequestDatabase, type RequestScope } from '../../server/utils/request-database.js'; + +interface Client { + readonly connectionString: string; +} + +function worker(connectionString: string): RequestScope { + return { context: { cloudflare: { env: { HYPERDRIVE: { connectionString } } } } }; +} + +const FALLBACK: Client = { connectionString: 'process-wide' }; + +/** A selection over a request that tests switch between, and a record of every client opened. */ +function harness() { + const opened: Client[] = []; + const state: { current: RequestScope | undefined } = { current: undefined }; + const select = createRequestDatabase({ + request: () => state.current, + open: (connectionString) => { + const client = { connectionString }; + opened.push(client); + return client; + }, + fallback: () => FALLBACK, + }); + + return { opened, state, select }; +} + +describe('createRequestDatabase', () => { + it('opens one client per request and reuses it for the rest of that request', () => { + const { select, state, opened } = harness(); + state.current = worker('postgres://hyperdrive/one'); + + const first = select(); + const second = select(); + + expect(second).toBe(first); + expect(opened).toHaveLength(1); + }); + + it('gives each request its own client, even while two overlap', () => { + const { select, state } = harness(); + const one = worker('postgres://hyperdrive/one'); + const two = worker('postgres://hyperdrive/two'); + + state.current = one; + const forOne = select(); + state.current = two; + const forTwo = select(); + state.current = one; + + expect(forTwo).not.toBe(forOne); + expect(select()).toBe(forOne); + expect(forOne.connectionString).toBe('postgres://hyperdrive/one'); + expect(forTwo.connectionString).toBe('postgres://hyperdrive/two'); + }); + + it.each([ + ['no request in progress', undefined], + ['a request without a Cloudflare context', { context: {} }], + ['a request without the Hyperdrive binding', { context: { cloudflare: { env: {} } } }], + [ + 'a binding that carries no connection string', + { context: { cloudflare: { env: { HYPERDRIVE: {} } } } }, + ], + ])('uses the process-wide client for %s', (_name, request) => { + const { select, state, opened } = harness(); + state.current = request; + + expect(select()).toBe(FALLBACK); + expect(opened).toHaveLength(0); + }); + + it('opens nothing until a client is asked for', () => { + const { state, opened } = harness(); + state.current = worker('postgres://hyperdrive/one'); + + expect(opened).toHaveLength(0); + }); +}); From 1a1cb5cfba5707ebbdab97c8e314df2183ded5fc Mon Sep 17 00:00:00 2001 From: RedStar Date: Tue, 20 Oct 2026 21:34:04 +0200 Subject: [PATCH 3/3] fix(dashboard): import the request database helper explicitly The unit tests load the database util without Nitro's auto-imports, and the helper is called when the module loads, so it failed with a ReferenceError there. --- apps/web/server/utils/database.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/server/utils/database.ts b/apps/web/server/utils/database.ts index 96300f95..6d9309f5 100644 --- a/apps/web/server/utils/database.ts +++ b/apps/web/server/utils/database.ts @@ -5,6 +5,8 @@ import { type Database, } from '@code-zero/database'; +import { createRequestDatabase } from './request-database.js'; + /** * Connections a single request may open through Hyperdrive. *