Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/deploy-dashboard.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
21 changes: 20 additions & 1 deletion apps/docs/content/1.guide/16.deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
57 changes: 55 additions & 2 deletions apps/web/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion apps/web/server/auth.config.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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) : {}),
});
Expand Down
79 changes: 79 additions & 0 deletions apps/web/server/cloudflare/kv.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
put(key: string, value: string): Promise<void>;
delete(key: string): Promise<void>;
list(options: {
prefix?: string;
cursor?: string;
}): Promise<{ keys: { name: string }[]; list_complete: boolean; cursor?: string }>;
}

type Result<T> = [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<T>(operation: () => Promise<T>): Promise<Result<T>> {
try {
return [undefined, await operation()];
} catch (error) {
return [error instanceof Error ? error : new Error(String(error)), undefined];
}
}

export const kv = {
get: (key: string): Promise<Result<unknown>> => attempt(() => namespace().get(key, 'json')),

set: (key: string, value: unknown): Promise<Result<void>> =>
attempt(() => namespace().put(key, JSON.stringify(value))),

del: (key: string): Promise<Result<void>> => attempt(() => namespace().delete(key)),

keys: (base = ''): Promise<Result<string[]>> =>
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;
}),
};
11 changes: 11 additions & 0 deletions apps/web/server/cloudflare/react-email-render.ts
Original file line number Diff line number Diff line change
@@ -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');
}
21 changes: 21 additions & 0 deletions apps/web/server/cloudflare/safer-buffer.ts
Original file line number Diff line number Diff line change
@@ -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 };
16 changes: 16 additions & 0 deletions apps/web/server/cloudflare/subscription-provider.ts
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions apps/web/server/plugins/poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading