diff --git a/.github/workflows/deploy-dashboard.yml b/.github/workflows/deploy-dashboard.yml new file mode 100644 index 0000000..1d38a7a --- /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 007a400..fa7aa80 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 28cbd71..8054324 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 c6643e2..b1d00d4 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 f0d3be8..1086de7 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 0000000..04fbe83 --- /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 0000000..93a5b6b --- /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 0000000..85ab268 --- /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 0000000..a026c18 --- /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 63d5552..9612c7f 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 6deaaab..6d9309f 100644 --- a/apps/web/server/utils/database.ts +++ b/apps/web/server/utils/database.ts @@ -1,7 +1,31 @@ -import { createDatabase, databaseUrlFromEnvironment, type Database } from '@code-zero/database'; +import { + createDatabase, + createScopedDatabase, + databaseUrlFromEnvironment, + type Database, +} from '@code-zero/database'; + +import { createRequestDatabase } from './request-database.js'; + +/** + * 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 request being served, or nothing when none is in progress or the host keeps no context. */ +function requestEvent() { + try { + return useEvent(); + } catch { + return undefined; + } +} /** - * 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 +38,33 @@ 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 (`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( + 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 0000000..775a6a0 --- /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/server/utils/runtime.ts b/apps/web/server/utils/runtime.ts new file mode 100644 index 0000000..1009b86 --- /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/test/unit/request-database.test.ts b/apps/web/test/unit/request-database.test.ts new file mode 100644 index 0000000..1eafb91 --- /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); + }); +}); diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc new file mode 100644 index 0000000..ca6efde --- /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 8095be7..009fb80 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 c5dfa21..754046c 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 66d6cb6..7d31191 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 c551da8..525c5f7 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 573e4ba..666af6d 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,