diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce8bc01e3..5851133e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,24 @@ jobs: if: matrix.os == 'ubuntu-latest' run: pnpm exec vite build + control-plane: + name: control-plane check + workerd tests + dry run + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@ff378ebe6b225b0680b81c1ad4498ae0d1d3a5e3 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm control-plane:check + - run: pnpm control-plane:test + - run: pnpm control-plane:dry-run + package-linux: name: package + smoke (Ubuntu 24.04 x64) runs-on: ubuntu-24.04 diff --git a/.gitignore b/.gitignore index d5306ffc6..601c430c2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,9 @@ release .wrangler/ .dev.vars .dev.vars.* +!cloudflare/control-plane/.dev.vars.example cloudflare/composio-broker/worker-configuration.d.ts +cloudflare/control-plane/worker-configuration.d.ts .claude/worktrees/ .vercel/ .pi/ diff --git a/cloudflare/control-plane/.dev.vars.example b/cloudflare/control-plane/.dev.vars.example new file mode 100644 index 000000000..33d9ea7aa --- /dev/null +++ b/cloudflare/control-plane/.dev.vars.example @@ -0,0 +1,5 @@ +# Local-only placeholders. Copy this file to .dev.vars and replace the secret. +BETTER_AUTH_URL=https://auth.openmausbot.test +BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes +EMAIL_FROM=noreply@openmausbot.test +ALLOWED_ORIGINS=https://app.openmausbot.test diff --git a/cloudflare/control-plane/README.md b/cloudflare/control-plane/README.md new file mode 100644 index 000000000..20a65163e --- /dev/null +++ b/cloudflare/control-plane/README.md @@ -0,0 +1,98 @@ +# OpenMausBot control plane + +This directory is an isolated Cloudflare Worker for cloud account identity and +installation ownership. It does **not** store or move local bots, chats, SQLite +state, prompts, tool output, or tunnel configuration. + +## What is included + +- Better Auth 1.7.1 with email OTP, signed bearer sessions, hashed OTP storage, + and D1-backed IP plus recipient rate limits. +- A Cloudflare Email Sending binding that produces both HTML and plain-text OTP + messages. Authentication responses remain generic even when delivery fails; + email addresses, OTPs, secrets, and provider errors are never logged. +- Owner-scoped desktop installations and independently revocable + `omb_install_…` credentials. Account bearer tokens are never accepted as + installation credentials, or vice versa. +- Exact-origin CORS, bounded JSON bodies, redacted errors, and `no-store` on + every response. + +The D1 schema is pinned in `migrations/`. `0001_better_auth_1_7_1.sql` was +generated from the exact Better Auth configuration. `0002_installations.sql` +contains only cloud ownership and credential metadata. `0003` adds a +recipient-scoped OTP limiter whose keys are HMACs rather than email addresses, +plus an authenticated installation-creation limiter. + +## API surface + +| Method | Path | Authentication | +| --- | --- | --- | +| `GET` | `/healthz` | none | +| any | `/api/auth/*` | Better Auth | +| `GET` | `/v1/me` | account bearer | +| `GET`, `POST` | `/v1/installations` | account bearer | +| `POST` | `/v1/installations/:id/credentials/rotate` | owning account bearer | +| `DELETE` | `/v1/installations/:id` | owning account bearer | +| `GET` | `/v1/installations/self` | installation credential | + +Installation registration requires a stable `clientInstanceId`, a display +`name`, and a `platform` of `darwin`, `windows`, or `linux`; `appVersion` is +optional. A client ID is unique among one account's active installations. After +revocation, that account may register the stable ID again. Other accounts may +independently use the same client ID. An account may have at most 100 active +installations, matching the complete management-list limit. Creation is also +limited to 100 attempts per account per hour. + +Raw installation credentials contain a random lookup ID plus 32 random bytes. +Only a SHA-256 digest is stored, and the raw value is returned only when an +installation is created or its credential is rotated. Credentials expire after +90 days even if they are not revoked; the response includes their expiry so a +signed-in desktop can rotate ahead of time. `/v1/installations/self` rejects +expired credentials and records both credential use and installation +`lastSeenAt`. Rotations are serialized with a one-minute cooldown, so concurrent +requests cannot both return credentials while one invalidates the other. + +## Local checks + +Install from the repository root, then run: + +```sh +pnpm control-plane:check +pnpm control-plane:test +pnpm control-plane:dry-run +``` + +For local manual development, copy `.dev.vars.example` to `.dev.vars`, replace +`BETTER_AUTH_SECRET` with at least 32 cryptographically random bytes, apply the +migrations locally, and start Wrangler: + +```sh +pnpm --filter @openmausbot/control-plane exec wrangler d1 migrations apply DB --local --config wrangler.jsonc +pnpm --filter @openmausbot/control-plane exec wrangler dev --config wrangler.jsonc +``` + +Do not commit `.dev.vars`. + +## Production blockers + +The checked-in Wrangler file is intentionally non-deployable production +scaffolding. No remote resource was created or changed while preparing it. +Before a production deployment, an operator must: + +1. Choose and route an HTTPS hostname, then replace `BETTER_AUTH_URL`. The + Worker has `workers_dev` disabled and no production route in this PR. +2. Generate a strong production `BETTER_AUTH_SECRET` and add it with Wrangler's + interactive secret command. The `secrets.required` declaration validates the + binding name and generates its type; it does not contain or upload a value. +3. Create the D1 database, replace the all-zero `database_id`, review the pinned + migrations, and apply them to that database. +4. Complete Cloudflare Email Sending domain onboarding, replace the placeholder + sender in both `EMAIL_FROM` and `allowed_sender_addresses`, and grant the + deployment identity access to the binding. The Cloudflare session used while + preparing this code could not list Email Sending (`2036 Unauthorized`), so no + domain or binding activation was attempted. +5. Replace `ALLOWED_ORIGINS` with a comma-separated allow-list of exact HTTPS + application origins. Wildcards are deliberately unsupported. + +This foundation does not provision a Cloudflare Tunnel and does not collect +marketing consent. Those are separate, explicitly scoped changes. diff --git a/cloudflare/control-plane/migrations/0001_better_auth_1_7_1.sql b/cloudflare/control-plane/migrations/0001_better_auth_1_7_1.sql new file mode 100644 index 000000000..908aaadf8 --- /dev/null +++ b/cloudflare/control-plane/migrations/0001_better_auth_1_7_1.sql @@ -0,0 +1,20 @@ +-- Generated and pinned with `auth@1.7.1 generate` for Better Auth 1.7.1, +-- the Kysely adapter, SQLite dialect, emailOTP(), bearer(), and database-backed +-- rate limiting. Do not edit this migration in place after deployment. +create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" integer not null, "image" text, "createdAt" date not null, "updatedAt" date not null); + +create table "session" ("id" text not null primary key, "expiresAt" date not null, "token" text not null unique, "createdAt" date not null, "updatedAt" date not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id") on delete cascade); + +create table "account" ("id" text not null primary key, "issuer" text not null, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id") on delete cascade, "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" date, "refreshTokenExpiresAt" date, "scope" text, "password" text, "createdAt" date not null, "updatedAt" date not null); + +create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" date not null, "createdAt" date not null, "updatedAt" date not null); + +create table "rateLimit" ("id" text not null primary key, "key" text not null unique, "count" integer not null, "lastRequest" bigint not null); + +create index "session_userId_idx" on "session" ("userId"); + +create index "account_userId_idx" on "account" ("userId"); + +create index "verification_identifier_idx" on "verification" ("identifier"); + +create unique index "account_issuer_accountId_uidx" on "account" ("issuer", "accountId"); diff --git a/cloudflare/control-plane/migrations/0002_installations.sql b/cloudflare/control-plane/migrations/0002_installations.sql new file mode 100644 index 000000000..697ed7298 --- /dev/null +++ b/cloudflare/control-plane/migrations/0002_installations.sql @@ -0,0 +1,82 @@ +-- Local bots, chats, and desktop state deliberately do not belong here. This +-- database records only cloud account ownership and revocable installation +-- credentials. +CREATE TABLE installations ( + id TEXT PRIMARY KEY, + owner_user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + client_instance_id TEXT NOT NULL, + display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 80), + platform TEXT NOT NULL CHECK (platform IN ('darwin', 'windows', 'linux')), + app_version TEXT CHECK (app_version IS NULL OR length(app_version) BETWEEN 1 AND 64), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_seen_at INTEGER, + last_rotation_at INTEGER, + revoked_at INTEGER +); + +CREATE INDEX installations_owner_active_idx + ON installations(owner_user_id, revoked_at, created_at); + +CREATE UNIQUE INDEX installations_owner_client_active_uidx + ON installations(owner_user_id, client_instance_id) + WHERE revoked_at IS NULL; + +-- Keep the unpaginated management surface complete and put a hard ceiling on +-- account abuse. The trigger makes the limit atomic across concurrent creates. +CREATE TRIGGER installations_active_limit_before_insert +BEFORE INSERT ON installations +WHEN NEW.revoked_at IS NULL + AND ( + SELECT COUNT(*) + FROM installations + WHERE owner_user_id = NEW.owner_user_id AND revoked_at IS NULL + ) >= 100 +BEGIN + SELECT RAISE(ABORT, 'active_installation_limit'); +END; + +-- The first rotation is immediate. Later rotations are serialized and limited +-- so concurrent requests never both return credentials while one revokes the +-- other before it reaches the client. +CREATE TRIGGER installations_rotation_cooldown_before_update +BEFORE UPDATE OF last_rotation_at ON installations +WHEN OLD.last_rotation_at IS NOT NULL + AND NEW.last_rotation_at < OLD.last_rotation_at + 60000 +BEGIN + SELECT RAISE(ABORT, 'credential_rotation_rate_limited'); +END; + +CREATE TABLE installation_credentials ( + id TEXT PRIMARY KEY, + installation_id TEXT NOT NULL REFERENCES installations(id) ON DELETE CASCADE, + lookup_id TEXT NOT NULL UNIQUE, + secret_hash TEXT NOT NULL UNIQUE CHECK (length(secret_hash) = 64), + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_used_at INTEGER, + revoked_at INTEGER +); + +CREATE INDEX installation_credentials_installation_idx + ON installation_credentials(installation_id, revoked_at); + +CREATE UNIQUE INDEX installation_credentials_one_active_uidx + ON installation_credentials(installation_id) + WHERE revoked_at IS NULL; + +CREATE TRIGGER installation_credentials_rotation_guard_before_insert +BEFORE INSERT ON installation_credentials +WHEN EXISTS ( + SELECT 1 FROM installation_credentials + WHERE installation_id = NEW.installation_id + ) + AND NOT EXISTS ( + SELECT 1 FROM installations + WHERE id = NEW.installation_id + AND revoked_at IS NULL + AND last_rotation_at = NEW.created_at + ) +BEGIN + SELECT RAISE(ABORT, 'credential_rotation_conflict'); +END; diff --git a/cloudflare/control-plane/migrations/0003_otp_recipient_rate_limits.sql b/cloudflare/control-plane/migrations/0003_otp_recipient_rate_limits.sql new file mode 100644 index 000000000..56111006e --- /dev/null +++ b/cloudflare/control-plane/migrations/0003_otp_recipient_rate_limits.sql @@ -0,0 +1,23 @@ +-- A recipient-scoped limit complements Better Auth's IP limits so distributed +-- callers cannot repeatedly rotate and send codes to one email address. +-- Recipient keys are HMACs, never plaintext addresses. +CREATE TABLE otp_recipient_rate_limits ( + recipient_key TEXT PRIMARY KEY CHECK (length(recipient_key) = 64), + window_started_at INTEGER NOT NULL, + attempts INTEGER NOT NULL CHECK (attempts >= 1), + updated_at INTEGER NOT NULL +); + +CREATE INDEX otp_recipient_rate_limits_updated_idx + ON otp_recipient_rate_limits(updated_at); + +-- Authenticated accounts are still untrusted. Bound installation row churn +-- separately from Better Auth's public endpoint limits. +CREATE TABLE control_action_rate_limits ( + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + action TEXT NOT NULL, + window_started_at INTEGER NOT NULL, + attempts INTEGER NOT NULL CHECK (attempts >= 1), + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, action) +); diff --git a/cloudflare/control-plane/package.json b/cloudflare/control-plane/package.json new file mode 100644 index 000000000..aad66db5c --- /dev/null +++ b/cloudflare/control-plane/package.json @@ -0,0 +1,25 @@ +{ + "name": "@openmausbot/control-plane", + "private": true, + "version": "0.0.0", + "type": "module", + "dependencies": { + "better-auth": "1.7.1", + "zod": "4.4.3" + }, + "devDependencies": { + "@cloudflare/workers-types": "5.20260825.1", + "@cloudflare/vitest-plugin": "1.0.0", + "@types/node": "^26.2.0", + "typescript": "^5.8.3", + "vitest": "^4.1.10", + "wrangler": "4.125.0" + }, + "scripts": { + "check": "pnpm types && tsc -p tsconfig.json", + "test": "vitest run", + "types": "wrangler types --config wrangler.jsonc worker-configuration.d.ts", + "types:check": "wrangler types --check --config wrangler.jsonc worker-configuration.d.ts", + "dry-run": "wrangler deploy --dry-run --config wrangler.jsonc" + } +} diff --git a/cloudflare/control-plane/src/auth.ts b/cloudflare/control-plane/src/auth.ts new file mode 100644 index 000000000..57f98eb18 --- /dev/null +++ b/cloudflare/control-plane/src/auth.ts @@ -0,0 +1,77 @@ +import { betterAuth } from "better-auth"; +import { bearer, emailOTP } from "better-auth/plugins"; + +import type { ControlPlaneConfig } from "./config"; +import { sendOTPEmail } from "./email"; + +export function createAuth( + env: Env, + ctx: ExecutionContext, + config: ControlPlaneConfig, + requestId: string, +) { + return betterAuth({ + appName: "OpenMausBot", + baseURL: config.authBaseURL, + basePath: "/api/auth", + secret: env.BETTER_AUTH_SECRET, + database: env.DB, + trustedOrigins: [...config.allowedOrigins], + logger: { disabled: true }, + rateLimit: { + enabled: true, + storage: "database", + window: 60, + max: 60, + customRules: { + "/email-otp/send-verification-otp": { window: 60, max: 5 }, + "/sign-in/email-otp": { window: 60, max: 10 }, + }, + }, + advanced: { + useSecureCookies: true, + ipAddress: { + // Cloudflare writes this header at the edge. Do not trust a client- + // supplied x-forwarded-for chain for rate limits or session metadata. + ipAddressHeaders: ["cf-connecting-ip"], + }, + database: { generateId: "uuid" }, + backgroundTasks: { + handler(promise) { + ctx.waitUntil(promise); + }, + }, + }, + plugins: [ + emailOTP({ + otpLength: 8, + expiresIn: 10 * 60, + allowedAttempts: 5, + storeOTP: "hashed", + resendStrategy: "rotate", + disableSignUp: false, + rateLimit: { window: 60, max: 5 }, + async sendVerificationOTP(input) { + await sendOTPEmail({ + async send(message) { + await env.EMAIL.send(message); + }, + }, config.emailFrom, input, requestId); + }, + }), + bearer({ requireSignature: true }), + ], + }); +} + +export type ControlPlaneAuth = ReturnType; + +export async function accountSession(request: Request, auth: ControlPlaneAuth) { + const authorization = request.headers.get("authorization"); + const match = authorization?.match(/^Bearer\s+([^\s]+)$/i); + if (!match || match[1].startsWith("omb_install_")) return null; + + return auth.api.getSession({ + headers: new Headers({ authorization: `Bearer ${match[1]}` }), + }); +} diff --git a/cloudflare/control-plane/src/config.ts b/cloudflare/control-plane/src/config.ts new file mode 100644 index 000000000..77066debc --- /dev/null +++ b/cloudflare/control-plane/src/config.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +const MAX_ALLOWED_ORIGINS = 20; +const secretSchema = z.string().min(32); +const emailSchema = z.email().max(254); +const originsSchema = z.string(); + +export interface ControlPlaneConfig { + authBaseURL: string; + allowedOrigins: ReadonlySet; + emailFrom: string; +} + +function exactHTTPSOrigin(value: string, label: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${label} must be a valid HTTPS origin`); + } + if ( + url.protocol !== "https:" + || url.username + || url.password + || url.pathname !== "/" + || url.search + || url.hash + ) { + throw new Error(`${label} must be an exact HTTPS origin`); + } + return url.origin; +} + +export function readConfig(env: Env): ControlPlaneConfig { + if (!secretSchema.safeParse(env.BETTER_AUTH_SECRET).success) { + throw new Error("BETTER_AUTH_SECRET must contain at least 32 characters"); + } + + const emailFrom = emailSchema.safeParse(env.EMAIL_FROM); + if (!emailFrom.success) throw new Error("EMAIL_FROM must be a valid email address"); + + const authBaseURL = exactHTTPSOrigin(env.BETTER_AUTH_URL, "BETTER_AUTH_URL"); + const origins = originsSchema.safeParse(env.ALLOWED_ORIGINS); + if (!origins.success) throw new Error("ALLOWED_ORIGINS must be a comma-separated string"); + const values = origins.data.split(",") + .map((value) => value.trim()) + .filter(Boolean); + if (values.length > MAX_ALLOWED_ORIGINS) { + throw new Error("ALLOWED_ORIGINS contains too many entries"); + } + const allowedOrigins = new Set(values.map((value) => exactHTTPSOrigin(value, "ALLOWED_ORIGINS"))); + allowedOrigins.add(authBaseURL); + return { authBaseURL, allowedOrigins, emailFrom: emailFrom.data }; +} diff --git a/cloudflare/control-plane/src/email.ts b/cloudflare/control-plane/src/email.ts new file mode 100644 index 000000000..91d70e269 --- /dev/null +++ b/cloudflare/control-plane/src/email.ts @@ -0,0 +1,50 @@ +export interface TransactionalEmailSender { + send(message: { + to: string; + from: { email: string; name: string }; + subject: string; + html: string; + text: string; + }): Promise; +} + +export interface OTPEmailInput { + email: string; + otp: string; + type: "sign-in" | "email-verification" | "forget-password" | "change-email"; +} + +const SUBJECTS = { + "sign-in": "Your OpenMausBot sign-in code", + "email-verification": "Verify your OpenMausBot email", + "forget-password": "Reset your OpenMausBot password", + "change-email": "Confirm your OpenMausBot email change", +} as const satisfies Record; + +export function buildOTPEmail(from: string, input: OTPEmailInput) { + const subject = SUBJECTS[input.type]; + const text = `${subject}\n\nYour one-time code is: ${input.otp}\n\nIt expires in 10 minutes. If you did not request this code, you can ignore this email.`; + const html = `

${subject}

Your one-time code is:

${input.otp}

It expires in 10 minutes. If you did not request this code, you can ignore this email.

`; + return { + to: input.email, + from: { email: from, name: "OpenMausBot" }, + subject, + html, + text, + }; +} + +export async function sendOTPEmail( + sender: TransactionalEmailSender, + from: string, + input: OTPEmailInput, + requestId: string, +): Promise { + try { + await sender.send(buildOTPEmail(from, input)); + } catch { + // Authentication responses stay enumeration-safe. Do not log the address, + // code, provider error, message object, or any other credential material. + console.error(JSON.stringify({ message: "transactional email send failed", requestId })); + } +} diff --git a/cloudflare/control-plane/src/http.ts b/cloudflare/control-plane/src/http.ts new file mode 100644 index 000000000..66750bce1 --- /dev/null +++ b/cloudflare/control-plane/src/http.ts @@ -0,0 +1,165 @@ +import { z } from "zod"; + +import type { ControlPlaneConfig } from "./config"; + +export const JSON_HEADERS = { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", +} as const; + +const jsonValueSchema = z.json(); +export type JSONValue = z.infer; + +const MAX_API_BODY_BYTES = 16 * 1024; +const BODYLESS_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); +const ALLOWED_CORS_METHODS = new Set(["GET", "POST", "DELETE"]); +const ALLOWED_CORS_HEADERS = new Set(["authorization", "content-type"]); + +export class HTTPError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + ) { + super(code); + } +} + +export function json(value: JSONValue, status = 200): Response { + return new Response(JSON.stringify(value), { status, headers: JSON_HEADERS }); +} + +export function errorResponse(status: number, code: string): Response { + return json({ error: code }, status); +} + +function validateDeclaredBodyLength(request: Request) { + const contentLength = request.headers.get("content-length"); + if (contentLength !== null) { + const declared = Number(contentLength); + if (!Number.isSafeInteger(declared) || declared < 0) throw new HTTPError(400, "invalid_request"); + if (declared > MAX_API_BODY_BYTES) throw new HTTPError(413, "request_too_large"); + } +} + +async function readBoundedBody(request: Request): Promise> { + validateDeclaredBodyLength(request); + if (!request.body) return new Uint8Array(); + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > MAX_API_BODY_BYTES) { + await reader.cancel(); + throw new HTTPError(413, "request_too_large"); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +export async function withBoundedRequestBody(request: Request): Promise { + if (BODYLESS_METHODS.has(request.method.toUpperCase())) return request; + const bytes = await readBoundedBody(request); + if (!request.body) return request; + + const headers = new Headers(request.headers); + headers.delete("content-length"); + // Mutating methods are the only path here; safe methods returned above. + // oxlint-disable-next-line unicorn/no-invalid-fetch-options + return new Request(request, { body: bytes.buffer, headers }); +} + +export async function readBoundedJSON(request: Request): Promise { + const mediaType = request.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase(); + if (mediaType !== "application/json") throw new HTTPError(415, "unsupported_media_type"); + if (!request.body) throw new HTTPError(400, "invalid_request"); + + const bytes = await readBoundedBody(request); + try { + return jsonValueSchema.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))); + } catch { + throw new HTTPError(400, "invalid_request"); + } +} + +function appendVary(headers: Headers, name: string) { + const current = headers.get("vary")?.split(",").map((value) => value.trim()).filter(Boolean) ?? []; + if (!current.some((value) => value.toLowerCase() === name.toLowerCase())) current.push(name); + headers.set("vary", current.join(", ")); +} + +function requestOriginAllowed(request: Request, config: ControlPlaneConfig): string | null { + const origin = request.headers.get("origin"); + if (!origin) return null; + return config.allowedOrigins.has(origin) ? origin : null; +} + +export function preflight(request: Request, config: ControlPlaneConfig): Response { + const origin = requestOriginAllowed(request, config); + if (!origin) return errorResponse(403, "origin_not_allowed"); + + const requestedMethod = request.headers.get("access-control-request-method")?.toUpperCase(); + if (!requestedMethod || !ALLOWED_CORS_METHODS.has(requestedMethod)) { + return errorResponse(403, "origin_not_allowed"); + } + const requestedHeaders = (request.headers.get("access-control-request-headers") ?? "") + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + if (requestedHeaders.some((name) => !ALLOWED_CORS_HEADERS.has(name))) { + return errorResponse(403, "origin_not_allowed"); + } + + return new Response(null, { + status: 204, + headers: { + "cache-control": "no-store", + "access-control-allow-origin": origin, + "access-control-allow-methods": "GET, POST, DELETE", + "access-control-allow-headers": "authorization, content-type", + "vary": "Origin", + }, + }); +} + +export function secureResponse( + response: Response, + request: Request, + config: ControlPlaneConfig | null, + requestId: string, +): Response { + const headers = new Headers(response.headers); + headers.set("cache-control", "no-store"); + headers.set("x-content-type-options", "nosniff"); + headers.set("referrer-policy", "no-referrer"); + headers.set("x-request-id", requestId); + headers.delete("access-control-allow-origin"); + + if (config) { + const origin = requestOriginAllowed(request, config); + if (origin) { + headers.set("access-control-allow-origin", origin); + appendVary(headers, "Origin"); + } + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} diff --git a/cloudflare/control-plane/src/index.ts b/cloudflare/control-plane/src/index.ts new file mode 100644 index 000000000..2a6618433 --- /dev/null +++ b/cloudflare/control-plane/src/index.ts @@ -0,0 +1,91 @@ +import { accountSession, createAuth } from "./auth"; +import { readConfig, type ControlPlaneConfig } from "./config"; +import { errorResponse, HTTPError, json, preflight, secureResponse, withBoundedRequestBody } from "./http"; +import { limitedOTPResponse } from "./otp-rate-limit"; +import { + createInstallation, + installationSelf, + listInstallations, + revokeInstallation, + rotateInstallationCredential, +} from "./installations"; + +const ROTATE_ROUTE = /^\/v1\/installations\/([^/]+)\/credentials\/rotate$/; +const INSTALLATION_ROUTE = /^\/v1\/installations\/([^/]+)$/; + +async function route(request: Request, env: Env, ctx: ExecutionContext, config: ControlPlaneConfig, requestId: string) { + const url = new URL(request.url); + if (request.method === "OPTIONS") return preflight(request, config); + + if (url.pathname.startsWith("/api/auth/")) { + const limited = await limitedOTPResponse(request, env); + if (limited) return limited; + const response = await createAuth(env, ctx, config, requestId).handler(request); + return response.status >= 500 ? errorResponse(500, "internal_error") : response; + } + + const auth = createAuth(env, ctx, config, requestId); + if (request.method === "GET" && url.pathname === "/v1/me") { + const session = await accountSession(request, auth); + if (!session) throw new HTTPError(401, "unauthorized"); + return json({ + user: { + id: session.user.id, + email: session.user.email, + name: session.user.name, + emailVerified: session.user.emailVerified, + }, + }); + } + if (request.method === "GET" && url.pathname === "/v1/installations") { + return listInstallations(request, env, auth); + } + if (request.method === "POST" && url.pathname === "/v1/installations") { + return createInstallation(request, env, auth); + } + if (request.method === "GET" && url.pathname === "/v1/installations/self") { + return installationSelf(request, env); + } + + const rotate = url.pathname.match(ROTATE_ROUTE); + if (request.method === "POST" && rotate) { + return rotateInstallationCredential(request, rotate[1], env, auth); + } + const installation = url.pathname.match(INSTALLATION_ROUTE); + if (request.method === "DELETE" && installation) { + return revokeInstallation(request, installation[1], env, auth); + } + return errorResponse(404, "not_found"); +} + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const requestId = crypto.randomUUID(); + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/healthz") { + try { + readConfig(env); + } catch { + return secureResponse(errorResponse(503, "misconfigured"), request, null, requestId); + } + return secureResponse(json({ ok: true, service: "openmausbot-control-plane" }), request, null, requestId); + } + + let config: ControlPlaneConfig | null = null; + try { + config = readConfig(env); + const origin = request.headers.get("origin"); + if (origin && !config.allowedOrigins.has(origin)) { + return secureResponse(errorResponse(403, "origin_not_allowed"), request, config, requestId); + } + const boundedRequest = await withBoundedRequestBody(request); + return secureResponse(await route(boundedRequest, env, ctx, config, requestId), request, config, requestId); + } catch (error) { + if (error instanceof HTTPError) { + return secureResponse(errorResponse(error.status, error.code), request, config, requestId); + } + console.error(JSON.stringify({ message: "request failed", requestId })); + return secureResponse(errorResponse(500, "internal_error"), request, config, requestId); + } + }, +} satisfies ExportedHandler; diff --git a/cloudflare/control-plane/src/installations.ts b/cloudflare/control-plane/src/installations.ts new file mode 100644 index 000000000..70c688a96 --- /dev/null +++ b/cloudflare/control-plane/src/installations.ts @@ -0,0 +1,356 @@ +import { z } from "zod"; +import { timingSafeEqual } from "node:crypto"; + +import type { ControlPlaneAuth } from "./auth"; +import { accountSession } from "./auth"; +import { HTTPError, json, readBoundedJSON } from "./http"; + +interface InstallationRow { + id: string; + client_instance_id: string; + display_name: string; + platform: "darwin" | "windows" | "linux"; + app_version: string | null; + created_at: number; + updated_at: number; + last_seen_at: number | null; +} + +interface InstallationCredentialRow { + installation_id: string; + lookup_id: string; + secret_hash: string; + display_name: string; + client_instance_id: string; + platform: "darwin" | "windows" | "linux"; + app_version: string | null; + created_at: number; + updated_at: number; + last_seen_at: number | null; + expires_at: number; +} + +function printableString(maxLength: number) { + return z.string().trim().min(1).max(maxLength).refine((value) => { + for (const character of value) { + const point = character.codePointAt(0); + if (point === undefined || point < 32 || point === 127) return false; + } + return true; + }); +} + +const printableName = printableString(80); +const printableVersion = printableString(64); + +const createInstallationSchema = z.strictObject({ + name: printableName, + clientInstanceId: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/), + platform: z.enum(["darwin", "windows", "linux"]), + appVersion: printableVersion.optional(), +}); + +const INSTALLATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const INSTALLATION_CREDENTIAL = /^omb_install_([A-Za-z0-9_-]{22})\.([A-Za-z0-9_-]{43})$/; +const INSTALLATION_CREDENTIAL_TTL_MS = 90 * 24 * 60 * 60 * 1_000; +const CREATION_RATE_WINDOW_MS = 60 * 60 * 1_000; +const CREATION_RATE_MAX_ATTEMPTS = 100; + +function installationJSON(row: InstallationRow) { + return { + id: row.id, + clientInstanceId: row.client_instance_id, + name: row.display_name, + platform: row.platform, + appVersion: row.app_version, + createdAt: row.created_at, + updatedAt: row.updated_at, + lastSeenAt: row.last_seen_at, + }; +} + +function base64URL(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); +} + +function randomBytes(size: number): Uint8Array { + const bytes = new Uint8Array(size); + crypto.getRandomValues(bytes); + return bytes; +} + +function hex(bytes: ArrayBuffer): string { + return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function fromHex(value: string): Uint8Array | null { + if (!/^[0-9a-f]{64}$/.test(value)) return null; + const bytes = new Uint8Array(32); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +export async function sha256(value: string): Promise { + return hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))); +} + +async function newCredential(createdAt: number) { + const lookupId = base64URL(randomBytes(16)); + const secret = base64URL(randomBytes(32)); + const raw = `omb_install_${lookupId}.${secret}`; + return { + lookupId, + raw, + secretHash: await sha256(raw), + expiresAt: createdAt + INSTALLATION_CREDENTIAL_TTL_MS, + }; +} + +async function requireAccount(request: Request, auth: ControlPlaneAuth) { + const session = await accountSession(request, auth); + if (!session) throw new HTTPError(401, "unauthorized"); + return session; +} + +async function enforceCreationRateLimit(ownerUserId: string, env: Env): Promise { + const now = Date.now(); + const cutoff = now - CREATION_RATE_WINDOW_MS; + const result = await env.DB.prepare( + `INSERT INTO control_action_rate_limits + (user_id, action, window_started_at, attempts, updated_at) + VALUES (?, 'create_installation', ?, 1, ?) + ON CONFLICT(user_id, action) DO UPDATE SET + window_started_at = CASE + WHEN window_started_at <= ? THEN excluded.window_started_at + ELSE window_started_at + END, + attempts = CASE + WHEN window_started_at <= ? THEN 1 + ELSE attempts + 1 + END, + updated_at = excluded.updated_at + WHERE window_started_at <= ? OR attempts < ?`, + ).bind( + ownerUserId, + now, + now, + cutoff, + cutoff, + cutoff, + CREATION_RATE_MAX_ATTEMPTS, + ).run(); + if (result.meta.changes === 0) throw new HTTPError(429, "rate_limited"); +} + +export async function listInstallations(request: Request, env: Env, auth: ControlPlaneAuth): Promise { + const session = await requireAccount(request, auth); + const result = await env.DB.prepare( + `SELECT id, client_instance_id, display_name, platform, app_version, + created_at, updated_at, last_seen_at + FROM installations + WHERE owner_user_id = ? AND revoked_at IS NULL + ORDER BY created_at ASC, id ASC + LIMIT 100`, + ).bind(session.user.id).all(); + return json({ installations: result.results.map(installationJSON) }); +} + +export async function createInstallation(request: Request, env: Env, auth: ControlPlaneAuth): Promise { + const session = await requireAccount(request, auth); + await enforceCreationRateLimit(session.user.id, env); + const parsed = createInstallationSchema.safeParse(await readBoundedJSON(request)); + if (!parsed.success) throw new HTTPError(400, "invalid_request"); + + const installationId = crypto.randomUUID(); + const credentialId = crypto.randomUUID(); + const now = Date.now(); + const credential = await newCredential(now); + try { + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO installations + (id, owner_user_id, client_instance_id, display_name, platform, app_version, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + installationId, + session.user.id, + parsed.data.clientInstanceId, + parsed.data.name, + parsed.data.platform, + parsed.data.appVersion ?? null, + now, + now, + ), + env.DB.prepare( + `INSERT INTO installation_credentials + (id, installation_id, lookup_id, secret_hash, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ).bind(credentialId, installationId, credential.lookupId, credential.secretHash, now, credential.expiresAt), + ]); + } catch (error) { + if (error instanceof Error && /active_installation_limit/i.test(error.message)) { + throw new HTTPError(409, "installation_limit_reached"); + } + if (error instanceof Error && /UNIQUE constraint failed/i.test(error.message)) { + throw new HTTPError(409, "installation_exists"); + } + throw error; + } + + return json({ + installation: { + id: installationId, + clientInstanceId: parsed.data.clientInstanceId, + name: parsed.data.name, + platform: parsed.data.platform, + appVersion: parsed.data.appVersion ?? null, + createdAt: now, + updatedAt: now, + lastSeenAt: null, + }, + credential: credential.raw, + credentialExpiresAt: credential.expiresAt, + }, 201); +} + +async function ownedActiveInstallation(id: string, ownerUserId: string, env: Env) { + if (!INSTALLATION_ID.test(id)) return null; + return env.DB.prepare( + `SELECT id, client_instance_id, display_name, platform, app_version, + created_at, updated_at, last_seen_at + FROM installations + WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL`, + ).bind(id, ownerUserId).first(); +} + +export async function rotateInstallationCredential( + request: Request, + installationId: string, + env: Env, + auth: ControlPlaneAuth, +): Promise { + const session = await requireAccount(request, auth); + const installation = await ownedActiveInstallation(installationId, session.user.id, env); + if (!installation) throw new HTTPError(404, "not_found"); + + const now = Date.now(); + const credential = await newCredential(now); + try { + await env.DB.batch([ + env.DB.prepare( + `UPDATE installations + SET updated_at = ?, last_rotation_at = ? + WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL`, + ).bind(now, now, installationId, session.user.id), + env.DB.prepare( + `UPDATE installation_credentials + SET revoked_at = ? + WHERE installation_id = ? AND revoked_at IS NULL`, + ).bind(now, installationId), + env.DB.prepare( + `INSERT INTO installation_credentials + (id, installation_id, lookup_id, secret_hash, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ).bind( + crypto.randomUUID(), + installationId, + credential.lookupId, + credential.secretHash, + now, + credential.expiresAt, + ), + ]); + } catch (error) { + if (error instanceof Error && /credential_rotation_rate_limited/i.test(error.message)) { + throw new HTTPError(429, "credential_rotation_rate_limited"); + } + if (error instanceof Error && /credential_rotation_conflict/i.test(error.message)) { + throw new HTTPError(409, "credential_rotation_conflict"); + } + throw error; + } + return json({ credential: credential.raw, createdAt: now, credentialExpiresAt: credential.expiresAt }, 201); +} + +export async function revokeInstallation( + request: Request, + installationId: string, + env: Env, + auth: ControlPlaneAuth, +): Promise { + const session = await requireAccount(request, auth); + const installation = await ownedActiveInstallation(installationId, session.user.id, env); + if (!installation) throw new HTTPError(404, "not_found"); + + const now = Date.now(); + await env.DB.batch([ + env.DB.prepare("UPDATE installations SET revoked_at = ?, updated_at = ? WHERE id = ?") + .bind(now, now, installationId), + env.DB.prepare( + "UPDATE installation_credentials SET revoked_at = ? WHERE installation_id = ? AND revoked_at IS NULL", + ).bind(now, installationId), + ]); + return new Response(null, { status: 204, headers: { "cache-control": "no-store" } }); +} + +async function authenticateInstallation(request: Request, env: Env): Promise { + const authorization = request.headers.get("authorization"); + const bearer = authorization?.match(/^Bearer\s+([^\s]+)$/i)?.[1]; + const parsed = bearer?.match(INSTALLATION_CREDENTIAL); + if (!bearer || !parsed) return null; + + const row = await env.DB.prepare( + `SELECT c.installation_id, c.lookup_id, c.secret_hash, c.expires_at, + i.display_name, i.client_instance_id, i.platform, i.app_version, + i.created_at, i.updated_at, i.last_seen_at + FROM installation_credentials c + JOIN installations i ON i.id = c.installation_id + WHERE c.lookup_id = ? + AND c.revoked_at IS NULL + AND c.expires_at > ? + AND i.revoked_at IS NULL`, + ).bind(parsed[1], Date.now()).first(); + if (!row) return null; + + const expected = fromHex(row.secret_hash); + if (!expected) return null; + const actual = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(bearer))); + if (!timingSafeEqual(actual, expected)) return null; + return row; +} + +export async function installationSelf(request: Request, env: Env): Promise { + const installation = await authenticateInstallation(request, env); + if (!installation) throw new HTTPError(401, "unauthorized"); + + const now = Date.now(); + await env.DB.batch([ + env.DB.prepare( + `UPDATE installation_credentials + SET last_used_at = ? + WHERE lookup_id = ? AND revoked_at IS NULL`, + ).bind(now, installation.lookup_id), + env.DB.prepare( + `UPDATE installations + SET last_seen_at = ? + WHERE id = ? AND revoked_at IS NULL`, + ).bind(now, installation.installation_id), + ]); + return json({ + installation: { + id: installation.installation_id, + clientInstanceId: installation.client_instance_id, + name: installation.display_name, + platform: installation.platform, + appVersion: installation.app_version, + createdAt: installation.created_at, + updatedAt: installation.updated_at, + lastSeenAt: now, + }, + credentialExpiresAt: installation.expires_at, + }); +} diff --git a/cloudflare/control-plane/src/otp-rate-limit.ts b/cloudflare/control-plane/src/otp-rate-limit.ts new file mode 100644 index 000000000..9b12de897 --- /dev/null +++ b/cloudflare/control-plane/src/otp-rate-limit.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +import { json } from "./http"; + +const OTP_SEND_PATH = "/api/auth/email-otp/send-verification-otp"; +const RECIPIENT_WINDOW_MS = 15 * 60 * 1_000; +const RECIPIENT_MAX_ATTEMPTS = 3; +const RETENTION_MS = 24 * 60 * 60 * 1_000; +const recipientSchema = z.object({ + email: z.string().trim().min(1).max(254).toLowerCase(), +}); + +function hex(bytes: ArrayBuffer): string { + return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function recipientKey(email: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return hex(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(email))); +} + +async function normalizedRecipient(request: Request): Promise { + try { + const parsed = recipientSchema.safeParse(await request.clone().json()); + return parsed.success ? parsed.data.email : null; + } catch { + return null; + } +} + +/** + * Returns Better Auth's generic success response only when the recipient limit + * is exhausted. Invalid requests keep flowing to Better Auth for validation. + */ +export async function limitedOTPResponse(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (request.method !== "POST" || url.pathname !== OTP_SEND_PATH) return null; + + const email = await normalizedRecipient(request); + if (!email) return null; + + const now = Date.now(); + const windowCutoff = now - RECIPIENT_WINDOW_MS; + const key = await recipientKey(email, env.BETTER_AUTH_SECRET); + const result = await env.DB.prepare( + `INSERT INTO otp_recipient_rate_limits + (recipient_key, window_started_at, attempts, updated_at) + VALUES (?, ?, 1, ?) + ON CONFLICT(recipient_key) DO UPDATE SET + window_started_at = CASE + WHEN window_started_at <= ? THEN excluded.window_started_at + ELSE window_started_at + END, + attempts = CASE + WHEN window_started_at <= ? THEN 1 + ELSE attempts + 1 + END, + updated_at = excluded.updated_at + WHERE window_started_at <= ? OR attempts < ?`, + ).bind(key, now, now, windowCutoff, windowCutoff, windowCutoff, RECIPIENT_MAX_ATTEMPTS).run(); + + await env.DB.prepare( + "DELETE FROM otp_recipient_rate_limits WHERE updated_at < ?", + ).bind(now - RETENTION_MS).run(); + + return result.meta.changes === 0 ? json({ success: true }) : null; +} diff --git a/cloudflare/control-plane/test/control-plane.test.ts b/cloudflare/control-plane/test/control-plane.test.ts new file mode 100644 index 000000000..13545680c --- /dev/null +++ b/cloudflare/control-plane/test/control-plane.test.ts @@ -0,0 +1,616 @@ +import { env } from "cloudflare:workers"; +import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; +import { describe, expect, it, vi } from "vitest"; + +import { createAuth } from "../src/auth"; +import { readConfig } from "../src/config"; +import { buildOTPEmail, sendOTPEmail } from "../src/email"; +import worker from "../src/index"; +import { sha256 } from "../src/installations"; + +const BASE_URL = "https://auth.openmausbot.test"; + +interface CallOptions { + method?: string; + token?: string; + body?: unknown; + rawBody?: string; + bodyChunks?: string[]; + headers?: Record; + origin?: string; +} + +async function call(path: string, options: CallOptions = {}) { + const headers = new Headers(options.headers); + if (options.token) headers.set("authorization", `Bearer ${options.token}`); + if (options.origin) headers.set("origin", options.origin); + let body: BodyInit | undefined; + if (options.bodyChunks) { + const encoder = new TextEncoder(); + body = new ReadableStream({ + start(controller) { + for (const chunk of options.bodyChunks ?? []) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }); + } else if (options.rawBody !== undefined) body = options.rawBody; + else if (options.body !== undefined) body = JSON.stringify(options.body); + if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json"); + const request = new Request(`${BASE_URL}${path}`, { + method: options.method ?? "GET", + headers, + body, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return response; +} + +async function signIn(email: string) { + const ctx = createExecutionContext(); + const auth = createAuth(env, ctx, readConfig(env), crypto.randomUUID()); + const otp = await auth.api.createVerificationOTP({ body: { email, type: "sign-in" } }); + await waitOnExecutionContext(ctx); + const response = await call("/api/auth/sign-in/email-otp", { + method: "POST", + body: { email, otp, name: email.split("@", 1)[0] }, + }); + expect(response.status).toBe(200); + const result = await response.json<{ token: string; user: { id: string; email: string } }>(); + const token = response.headers.get("set-auth-token"); + expect(token).toBeTruthy(); + if (!token) throw new Error("Better Auth did not return a signed bearer token"); + expect(result.user.email).toBe(email); + return { token, rawToken: result.token, userId: result.user.id }; +} + +async function createInstall( + token: string, + clientInstanceId: string = crypto.randomUUID(), + name = "Milind's Mac", + platform: "darwin" | "windows" | "linux" = "darwin", + appVersion: string | undefined = "0.1.0", +) { + const response = await call("/v1/installations", { + method: "POST", + token, + body: { clientInstanceId, name, platform, appVersion }, + }); + expect(response.status).toBe(201); + return response.json<{ + installation: { + id: string; + clientInstanceId: string; + name: string; + platform: "darwin" | "windows" | "linux"; + appVersion: string | null; + lastSeenAt: number | null; + }; + credential: string; + credentialExpiresAt: number; + }>(); +} + +describe("control-plane migrations and health", () => { + it("applies the pinned Better Auth and installation schemas in workerd", async () => { + const rows = await env.DB.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", + ).all<{ name: string }>(); + expect(rows.results.map((row) => row.name)).toEqual(expect.arrayContaining([ + "account", + "control_action_rate_limits", + "installation_credentials", + "installations", + "otp_recipient_rate_limits", + "rateLimit", + "session", + "user", + "verification", + ])); + + const trigger = await env.DB.prepare( + "SELECT name FROM sqlite_master WHERE type = 'trigger' AND name = ?", + ).bind("installations_active_limit_before_insert").first<{ name: string }>(); + expect(trigger?.name).toBe("installations_active_limit_before_insert"); + + const rotationTriggers = await env.DB.prepare( + "SELECT name FROM sqlite_master WHERE type = 'trigger' AND name LIKE ? ORDER BY name", + ).bind("%rotation%").all<{ name: string }>(); + expect(rotationTriggers.results.map((row) => row.name)).toEqual([ + "installation_credentials_rotation_guard_before_insert", + "installations_rotation_cooldown_before_update", + ]); + + const fk = await env.DB.prepare("PRAGMA foreign_key_list(installation_credentials)").all<{ table: string }>(); + expect(fk.results.some((row) => row.table === "installations")).toBe(true); + }); + + it("serves a no-store health response without CORS wildcards", async () => { + const response = await call("/healthz"); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true, service: "openmausbot-control-plane" }); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); + + it("reports an unhealthy deployment without exposing invalid configuration", async () => { + const misconfiguredEnv: Env = { + DB: env.DB, + EMAIL: env.EMAIL, + BETTER_AUTH_URL: env.BETTER_AUTH_URL, + EMAIL_FROM: env.EMAIL_FROM, + ALLOWED_ORIGINS: env.ALLOWED_ORIGINS, + BETTER_AUTH_SECRET: "too-short", + }; + const request = new Request(`${BASE_URL}/healthz`); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, misconfiguredEnv, ctx); + await waitOnExecutionContext(ctx); + expect(response.status).toBe(503); + const body = await response.text(); + expect(body).toBe('{"error":"misconfigured"}'); + expect(body).not.toContain("too-short"); + }); +}); + +describe("Better Auth email OTP and bearer boundary", () => { + it("sends enumeration-safe OTP responses and stores only a hash", async () => { + const response = await call("/api/auth/email-otp/send-verification-otp", { + method: "POST", + body: { email: "new-user@example.com", type: "sign-in" }, + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ success: true }); + expect(response.headers.get("cache-control")).toBe("no-store"); + + const verification = await env.DB.prepare( + 'SELECT value FROM "verification" WHERE identifier = ?', + ).bind("sign-in-otp-new-user@example.com").first<{ value: string }>(); + expect(verification?.value).toMatch(/^[A-Za-z0-9_-]{43}:0$/); + expect(verification?.value).not.toMatch(/^\d{8}$/); + const rateLimits = await env.DB.prepare('SELECT COUNT(*) AS count FROM "rateLimit"').first<{ count: number }>(); + expect(rateLimits?.count).toBeGreaterThan(0); + + await signIn("known-user@example.com"); + const knownResponse = await call("/api/auth/email-otp/send-verification-otp", { + method: "POST", + body: { email: "known-user@example.com", type: "sign-in" }, + }); + expect(knownResponse.status).toBe(response.status); + await expect(knownResponse.json()).resolves.toEqual({ success: true }); + }); + + it("limits OTP sends per recipient even when callers change addresses", async () => { + const email = "recipient-limit@example.com"; + for (let index = 0; index < 3; index += 1) { + const response = await call("/api/auth/email-otp/send-verification-otp", { + method: "POST", + headers: { "cf-connecting-ip": `198.51.100.${index + 1}` }, + body: { email, type: "sign-in" }, + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ success: true }); + } + + const before = await env.DB.prepare( + 'SELECT value FROM "verification" WHERE identifier = ?', + ).bind(`sign-in-otp-${email}`).first<{ value: string }>(); + expect(before?.value).toBeTruthy(); + + const limited = await call("/api/auth/email-otp/send-verification-otp", { + method: "POST", + headers: { "cf-connecting-ip": "203.0.113.44" }, + body: { email, type: "sign-in" }, + }); + expect(limited.status).toBe(200); + await expect(limited.json()).resolves.toEqual({ success: true }); + const after = await env.DB.prepare( + 'SELECT value FROM "verification" WHERE identifier = ?', + ).bind(`sign-in-otp-${email}`).first<{ value: string }>(); + expect(after?.value).toBe(before?.value); + + const rateLimit = await env.DB.prepare( + "SELECT attempts FROM otp_recipient_rate_limits", + ).first<{ attempts: number }>(); + expect(rateLimit?.attempts).toBe(3); + }); + + it("completes email OTP registration once and authenticates a bearer", async () => { + const account = await signIn("ada@example.com"); + const me = await call("/v1/me", { token: account.token }); + expect(me.status).toBe(200); + await expect(me.json()).resolves.toMatchObject({ + user: { id: account.userId, email: "ada@example.com", emailVerified: true }, + }); + expect((await call("/v1/me", { token: account.rawToken })).status).toBe(401); + + const accountAgain = await signIn("ada@example.com"); + expect(accountAgain.userId).toBe(account.userId); + const count = await env.DB.prepare('SELECT COUNT(*) AS count FROM "user" WHERE email = ?') + .bind("ada@example.com").first<{ count: number }>(); + expect(count?.count).toBe(1); + }); + + it("rejects invalid, expired, and replayed OTPs and invalidates signed-out bearers", async () => { + const invalidEmail = "invalid-otp@example.com"; + const invalidContext = createExecutionContext(); + const invalidAuth = createAuth(env, invalidContext, readConfig(env), crypto.randomUUID()); + const validOTP = await invalidAuth.api.createVerificationOTP({ + body: { email: invalidEmail, type: "sign-in" }, + }); + await waitOnExecutionContext(invalidContext); + + const invalid = await call("/api/auth/sign-in/email-otp", { + method: "POST", + body: { email: invalidEmail, otp: "00000000", name: "Invalid" }, + }); + expect(invalid.status).toBe(400); + + const accepted = await call("/api/auth/sign-in/email-otp", { + method: "POST", + body: { email: invalidEmail, otp: validOTP, name: "Valid" }, + }); + expect(accepted.status).toBe(200); + const acceptedBody = await accepted.json<{ token: string }>(); + + const replayed = await call("/api/auth/sign-in/email-otp", { + method: "POST", + body: { email: invalidEmail, otp: validOTP, name: "Replay" }, + }); + expect(replayed.status).toBe(400); + + const expiredEmail = "expired-otp@example.com"; + const expiredContext = createExecutionContext(); + const expiredAuth = createAuth(env, expiredContext, readConfig(env), crypto.randomUUID()); + const expiredOTP = await expiredAuth.api.createVerificationOTP({ + body: { email: expiredEmail, type: "sign-in" }, + }); + await waitOnExecutionContext(expiredContext); + await env.DB.prepare( + 'UPDATE "verification" SET "expiresAt" = ? WHERE "identifier" = ?', + ).bind(Date.now() - 1, `sign-in-otp-${expiredEmail}`).run(); + const expired = await call("/api/auth/sign-in/email-otp", { + method: "POST", + body: { email: expiredEmail, otp: expiredOTP, name: "Expired" }, + }); + expect(expired.status).toBe(400); + + const signedOut = await call("/api/auth/sign-out", { + method: "POST", + token: acceptedBody.token, + }); + expect(signedOut.status).toBe(200); + expect((await call("/v1/me", { token: acceptedBody.token })).status).toBe(401); + }); + + it("builds both plain-text and HTML OTP mail and redacts send failures", async () => { + const message = buildOTPEmail("noreply@example.com", { + email: "recipient@example.com", + otp: "12345678", + type: "sign-in", + }); + expect(message.text).toContain("12345678"); + expect(message.html).toContain("12345678"); + + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + await sendOTPEmail({ send: async () => { throw new Error("recipient@example.com 12345678"); } }, + "noreply@example.com", + { email: "recipient@example.com", otp: "12345678", type: "sign-in" }, + "request-safe"); + const logged = error.mock.calls.flat().join(" "); + expect(logged).toContain("request-safe"); + expect(logged).not.toContain("recipient@example.com"); + expect(logged).not.toContain("12345678"); + error.mockRestore(); + }); + + it("requires account bearers and never confuses installation credentials", async () => { + expect((await call("/v1/me")).status).toBe(401); + expect((await call("/v1/me", { token: "not-a-signed-session" })).status).toBe(401); + expect((await call("/v1/me", { token: "omb_install_AAAAAAAAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" })).status).toBe(401); + + const account = await signIn("auth-boundary@example.com"); + expect((await call("/v1/installations/self", { token: account.token })).status).toBe(401); + }); +}); + +describe("installation lifecycle", () => { + it("registers once, stores no raw credential, and serves installation self", async () => { + const account = await signIn("owner@example.com"); + const created = await createInstall(account.token, "mac-stable-1"); + expect(created.credential).toMatch(/^omb_install_[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}$/); + expect(created.credentialExpiresAt).toBeGreaterThan(Date.now() + 89 * 24 * 60 * 60 * 1_000); + + const stored = await env.DB.prepare( + "SELECT lookup_id, secret_hash FROM installation_credentials WHERE installation_id = ?", + ).bind(created.installation.id).first<{ lookup_id: string; secret_hash: string }>(); + expect(stored?.secret_hash).toBe(await sha256(created.credential)); + expect(JSON.stringify(stored)).not.toContain(created.credential); + const entireRow = await env.DB.prepare( + "SELECT * FROM installation_credentials WHERE installation_id = ?", + ).bind(created.installation.id).first<{ + id: string; + installation_id: string; + lookup_id: string; + secret_hash: string; + created_at: number; + expires_at: number; + last_used_at: number | null; + revoked_at: number | null; + }>(); + expect(JSON.stringify(entireRow)).not.toContain(created.credential); + + const self = await call("/v1/installations/self", { token: created.credential }); + expect(self.status).toBe(200); + const selfPayload = await self.json<{ + installation: { id: string; clientInstanceId: string; platform: string; appVersion: string; lastSeenAt: number }; + credentialExpiresAt: number; + }>(); + expect(selfPayload).toMatchObject({ + installation: { + id: created.installation.id, + clientInstanceId: "mac-stable-1", + platform: "darwin", + appVersion: "0.1.0", + lastSeenAt: expect.any(Number), + }, + }); + expect(selfPayload.credentialExpiresAt).toBe(created.credentialExpiresAt); + const used = await env.DB.prepare( + "SELECT last_used_at FROM installation_credentials WHERE installation_id = ?", + ).bind(created.installation.id).first<{ last_used_at: number | null }>(); + expect(used?.last_used_at).toEqual(expect.any(Number)); + const seen = await env.DB.prepare( + "SELECT last_seen_at FROM installations WHERE id = ?", + ).bind(created.installation.id).first<{ last_seen_at: number | null }>(); + expect(seen?.last_seen_at).toEqual(expect.any(Number)); + }); + + it("enforces active client uniqueness per owner and permits re-registration after revocation", async () => { + const first = await signIn("first@example.com"); + const second = await signIn("second@example.com"); + await createInstall(first.token, "stable-client"); + + const duplicate = await call("/v1/installations", { + method: "POST", + token: first.token, + body: { clientInstanceId: "stable-client", name: "Again", platform: "darwin" }, + }); + expect(duplicate.status).toBe(409); + await expect(duplicate.json()).resolves.toEqual({ error: "installation_exists" }); + + const crossAccount = await call("/v1/installations", { + method: "POST", + token: second.token, + body: { clientInstanceId: "stable-client", name: "Independent", platform: "linux" }, + }); + expect(crossAccount.status).toBe(201); + await expect(crossAccount.json()).resolves.toMatchObject({ + installation: { clientInstanceId: "stable-client", platform: "linux", appVersion: null }, + }); + + const firstList = await call("/v1/installations", { token: first.token }); + const firstInstallation = (await firstList.json<{ + installations: Array<{ id: string; clientInstanceId: string }>; + }>()).installations[0]; + expect((await call(`/v1/installations/${firstInstallation.id}`, { + method: "DELETE", + token: first.token, + })).status).toBe(204); + const registeredAgain = await createInstall(first.token, "stable-client", "Replacement Mac"); + expect(registeredAgain.installation.id).not.toBe(firstInstallation.id); + }); + + it("rejects expired installation credentials", async () => { + const owner = await signIn("expiry@example.com"); + const created = await createInstall(owner.token); + await env.DB.prepare( + "UPDATE installation_credentials SET expires_at = ? WHERE installation_id = ?", + ).bind(Date.now() - 1, created.installation.id).run(); + expect((await call("/v1/installations/self", { token: created.credential })).status).toBe(401); + }); + + it("caps active installations while allowing a slot to be reused after revocation", async () => { + const owner = await signIn("installation-cap@example.com"); + const first = await createInstall(owner.token, "cap-client-0"); + const now = Date.now(); + await env.DB.batch(Array.from({ length: 99 }, (_, index) => env.DB.prepare( + `INSERT INTO installations + (id, owner_user_id, client_instance_id, display_name, platform, created_at, updated_at) + VALUES (?, ?, ?, ?, 'darwin', ?, ?)`, + ).bind( + crypto.randomUUID(), + owner.userId, + `cap-client-${index + 1}`, + `Cap Mac ${index + 1}`, + now, + now, + ))); + + const limited = await call("/v1/installations", { + method: "POST", + token: owner.token, + body: { clientInstanceId: "cap-client-overflow", name: "Overflow", platform: "darwin" }, + }); + expect(limited.status).toBe(409); + await expect(limited.json()).resolves.toEqual({ error: "installation_limit_reached" }); + + expect((await call(`/v1/installations/${first.installation.id}`, { + method: "DELETE", + token: owner.token, + })).status).toBe(204); + expect((await call("/v1/installations", { + method: "POST", + token: owner.token, + body: { clientInstanceId: "cap-client-replacement", name: "Replacement", platform: "darwin" }, + })).status).toBe(201); + }); + + it("rate-limits installation row creation for authenticated accounts", async () => { + const owner = await signIn("creation-rate-limit@example.com"); + const now = Date.now(); + await env.DB.prepare( + `INSERT INTO control_action_rate_limits + (user_id, action, window_started_at, attempts, updated_at) + VALUES (?, 'create_installation', ?, 100, ?)`, + ).bind(owner.userId, now, now).run(); + + const limited = await call("/v1/installations", { + method: "POST", + token: owner.token, + body: { clientInstanceId: "rate-limited", name: "Limited", platform: "darwin" }, + }); + expect(limited.status).toBe(429); + await expect(limited.json()).resolves.toEqual({ error: "rate_limited" }); + const count = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM installations WHERE owner_user_id = ?", + ).bind(owner.userId).first<{ count: number }>(); + expect(count?.count).toBe(0); + }); + + it("isolates every owner-scoped lookup", async () => { + const owner = await signIn("owner-isolation@example.com"); + const other = await signIn("other-isolation@example.com"); + const created = await createInstall(owner.token); + + const list = await call("/v1/installations", { token: other.token }); + await expect(list.json()).resolves.toEqual({ installations: [] }); + expect((await call(`/v1/installations/${created.installation.id}/credentials/rotate`, { + method: "POST", + token: other.token, + })).status).toBe(404); + expect((await call(`/v1/installations/${created.installation.id}`, { + method: "DELETE", + token: other.token, + })).status).toBe(404); + expect((await call("/v1/installations/self", { token: created.credential })).status).toBe(200); + }); + + it("rotates then revokes credentials", async () => { + const owner = await signIn("rotate@example.com"); + const created = await createInstall(owner.token); + const rotated = await call(`/v1/installations/${created.installation.id}/credentials/rotate`, { + method: "POST", + token: owner.token, + }); + expect(rotated.status).toBe(201); + const next = await rotated.json<{ credential: string; credentialExpiresAt: number }>(); + expect(next.credential).not.toBe(created.credential); + expect(next.credentialExpiresAt).toBeGreaterThan(Date.now() + 89 * 24 * 60 * 60 * 1_000); + expect((await call("/v1/installations/self", { token: created.credential })).status).toBe(401); + expect((await call("/v1/installations/self", { token: next.credential })).status).toBe(200); + + const revoked = await call(`/v1/installations/${created.installation.id}`, { + method: "DELETE", + token: owner.token, + }); + expect(revoked.status).toBe(204); + expect((await call("/v1/installations/self", { token: next.credential })).status).toBe(401); + expect((await call(`/v1/installations/${created.installation.id}`, { + method: "DELETE", + token: owner.token, + })).status).toBe(404); + }); + + it("serializes concurrent credential rotations", async () => { + const owner = await signIn("concurrent-rotation@example.com"); + const created = await createInstall(owner.token); + const rotatePath = `/v1/installations/${created.installation.id}/credentials/rotate`; + const responses = await Promise.all([ + call(rotatePath, { method: "POST", token: owner.token }), + call(rotatePath, { method: "POST", token: owner.token }), + ]); + expect(responses.map((response) => response.status).sort()).toEqual([201, 429]); + + const successful = responses.find((response) => response.status === 201); + if (!successful) throw new Error("one credential rotation must succeed"); + const payload = await successful.json<{ credential: string }>(); + expect((await call("/v1/installations/self", { token: payload.credential })).status).toBe(200); + }); +}); + +describe("HTTP boundary hardening", () => { + it("rejects malformed, extra, unsupported, and oversized bodies", async () => { + const owner = await signIn("validation@example.com"); + expect((await call("/v1/installations", { + method: "POST", + token: owner.token, + rawBody: "not-json", + })).status).toBe(400); + expect((await call("/v1/installations", { + method: "POST", + token: owner.token, + rawBody: "{}", + headers: { "content-type": "text/plain" }, + })).status).toBe(415); + expect((await call("/v1/installations", { + method: "POST", + token: owner.token, + body: { clientInstanceId: "valid", name: "Mac", platform: "darwin", unexpected: true }, + })).status).toBe(400); + expect((await call("/v1/installations", { + method: "POST", + token: owner.token, + body: { clientInstanceId: "valid", name: "bad\nname", platform: "darwin" }, + })).status).toBe(400); + expect((await call("/v1/installations", { + method: "POST", + token: owner.token, + body: { clientInstanceId: "valid", name: "Mac", platform: "ios" }, + })).status).toBe(400); + expect((await call("/v1/installations", { + method: "POST", + token: owner.token, + body: { clientInstanceId: "valid", name: "Mac", platform: "darwin", appVersion: "x".repeat(65) }, + })).status).toBe(400); + const oversized = await call("/v1/installations", { + method: "POST", + token: owner.token, + rawBody: JSON.stringify({ clientInstanceId: "valid", name: "x".repeat(17 * 1024) }), + }); + expect(oversized.status).toBe(413); + await expect(oversized.json()).resolves.toEqual({ error: "request_too_large" }); + + const chunkedAuthBody = [ + JSON.stringify({ email: "oversized@example.com", type: "sign-in", padding: "" }).slice(0, -2), + "x".repeat(17 * 1024), + '"}', + ]; + const oversizedAuth = await call("/api/auth/email-otp/send-verification-otp", { + method: "POST", + bodyChunks: chunkedAuthBody, + }); + expect(oversizedAuth.status).toBe(413); + await expect(oversizedAuth.json()).resolves.toEqual({ error: "request_too_large" }); + const oversizedVerification = await env.DB.prepare( + 'SELECT COUNT(*) AS count FROM "verification" WHERE identifier LIKE ?', + ).bind("%oversized@example.com%").first<{ count: number }>(); + expect(oversizedVerification?.count).toBe(0); + }); + + it("defaults CORS to deny and never emits a wildcard", async () => { + const blocked = await call("/v1/me", { origin: "https://attacker.example" }); + expect(blocked.status).toBe(403); + expect(blocked.headers.get("access-control-allow-origin")).toBeNull(); + let serializedHeaders = ""; + blocked.headers.forEach((value, name) => { serializedHeaders += `${name}: ${value}\n`; }); + expect(serializedHeaders).not.toContain("*"); + + const allowed = await call("/v1/me", { origin: "https://app.openmausbot.test" }); + expect(allowed.status).toBe(401); + expect(allowed.headers.get("access-control-allow-origin")).toBe("https://app.openmausbot.test"); + expect(allowed.headers.get("cache-control")).toBe("no-store"); + + const deniedPreflight = await call("/v1/installations", { + method: "OPTIONS", + origin: "https://app.openmausbot.test", + headers: { + "access-control-request-method": "POST", + "access-control-request-headers": "authorization, x-unexpected", + }, + }); + expect(deniedPreflight.status).toBe(403); + expect(deniedPreflight.headers.get("access-control-allow-origin")).toBe("https://app.openmausbot.test"); + }); +}); diff --git a/cloudflare/control-plane/test/setup.ts b/cloudflare/control-plane/test/setup.ts new file mode 100644 index 000000000..eb82bb6dd --- /dev/null +++ b/cloudflare/control-plane/test/setup.ts @@ -0,0 +1,29 @@ +import { env } from "cloudflare:workers"; +import { applyD1Migrations, type D1Migration } from "cloudflare:test"; +import { afterEach, beforeAll } from "vitest"; + +declare global { + namespace Cloudflare { + interface Env { + TEST_MIGRATIONS: D1Migration[]; + } + } +} + +beforeAll(async () => { + await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); +}); + +afterEach(async () => { + await env.DB.batch([ + env.DB.prepare("DELETE FROM otp_recipient_rate_limits"), + env.DB.prepare("DELETE FROM control_action_rate_limits"), + env.DB.prepare("DELETE FROM installation_credentials"), + env.DB.prepare("DELETE FROM installations"), + env.DB.prepare('DELETE FROM "session"'), + env.DB.prepare('DELETE FROM "account"'), + env.DB.prepare('DELETE FROM "verification"'), + env.DB.prepare('DELETE FROM "rateLimit"'), + env.DB.prepare('DELETE FROM "user"'), + ]); +}); diff --git a/cloudflare/control-plane/tsconfig.json b/cloudflare/control-plane/tsconfig.json new file mode 100644 index 000000000..f0d414ae1 --- /dev/null +++ b/cloudflare/control-plane/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2024", "WebWorker"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["./worker-configuration.d.ts", "@cloudflare/vitest-plugin/types", "node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "worker-configuration.d.ts"] +} diff --git a/cloudflare/control-plane/vitest.config.ts b/cloudflare/control-plane/vitest.config.ts new file mode 100644 index 000000000..1386e3c83 --- /dev/null +++ b/cloudflare/control-plane/vitest.config.ts @@ -0,0 +1,26 @@ +import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-plugin"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const root = fileURLToPath(new URL(".", import.meta.url)); +const TEST_AUTH_SECRET = "test-only-better-auth-secret-with-more-than-32-characters"; +process.env.BETTER_AUTH_SECRET ??= TEST_AUTH_SECRET; + +export default defineConfig({ + plugins: [ + cloudflareTest(async () => ({ + wrangler: { configPath: fileURLToPath(new URL("./wrangler.jsonc", import.meta.url)) }, + miniflare: { + bindings: { + BETTER_AUTH_SECRET: TEST_AUTH_SECRET, + ALLOWED_ORIGINS: "https://app.openmausbot.test", + TEST_MIGRATIONS: await readD1Migrations(`${root}migrations`), + }, + }, + })), + ], + test: { + include: ["test/**/*.test.ts"], + setupFiles: ["./test/setup.ts"], + }, +}); diff --git a/cloudflare/control-plane/wrangler.jsonc b/cloudflare/control-plane/wrangler.jsonc new file mode 100644 index 000000000..ad78da258 --- /dev/null +++ b/cloudflare/control-plane/wrangler.jsonc @@ -0,0 +1,35 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "openmausbot-control-plane", + "main": "src/index.ts", + "compatibility_date": "2026-08-25", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": false, + "vars": { + "BETTER_AUTH_URL": "https://auth.openmausbot.test", + "EMAIL_FROM": "noreply@openmausbot.test", + "ALLOWED_ORIGINS": "" + }, + "secrets": { + "required": ["BETTER_AUTH_SECRET"] + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "openmausbot-control-plane", + "database_id": "00000000-0000-0000-0000-000000000000", + "migrations_dir": "migrations" + } + ], + "send_email": [ + { + "name": "EMAIL", + "allowed_sender_addresses": ["noreply@openmausbot.test"] + } + ], + "observability": { + "enabled": true, + "logs": { "enabled": true, "head_sampling_rate": 1 }, + "traces": { "enabled": true, "head_sampling_rate": 0.05 } + } +} diff --git a/package.json b/package.json index 4235bbca8..7c02e16d7 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,11 @@ "broker:types": "wrangler types --config cloudflare/composio-broker/wrangler.jsonc cloudflare/composio-broker/worker-configuration.d.ts", "broker:check": "pnpm broker:types && tsc -p cloudflare/composio-broker/tsconfig.json", "broker:test": "vitest run --config cloudflare/composio-broker/vitest.config.ts", - "broker:deploy": "wrangler deploy --config cloudflare/composio-broker/wrangler.jsonc" + "broker:deploy": "wrangler deploy --config cloudflare/composio-broker/wrangler.jsonc", + "control-plane:types": "pnpm --filter @openmausbot/control-plane types", + "control-plane:check": "pnpm --filter @openmausbot/control-plane check", + "control-plane:test": "pnpm --filter @openmausbot/control-plane test", + "control-plane:dry-run": "pnpm --filter @openmausbot/control-plane dry-run" }, "dependencies": { "@trycua/cua-driver": "0.20.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d9d4feee..128f179c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,7 +95,7 @@ importers: version: 4.1.10(@types/node@26.2.0)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.16.9)(yaml@2.9.0)) wrangler: specifier: 4.123.0 - version: 4.123.0(@cloudflare/workers-types@5.20260818.1) + version: 4.123.0(@cloudflare/workers-types@5.20260825.1) apps/docs: dependencies: @@ -152,6 +152,34 @@ importers: specifier: ^6.0.3 version: 6.0.3 + cloudflare/control-plane: + dependencies: + better-auth: + specifier: 1.7.1 + version: 1.7.1(@cloudflare/workers-types@5.20260825.1)(next@16.3.2(@babel/core@7.29.7)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@26.2.0)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.16.9)(yaml@2.9.0))) + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@cloudflare/vitest-plugin': + specifier: 1.0.0 + version: 1.0.0(@cloudflare/workers-types@5.20260825.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10(@types/node@26.2.0)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.16.9)(yaml@2.9.0))) + '@cloudflare/workers-types': + specifier: 5.20260825.1 + version: 5.20260825.1 + '@types/node': + specifier: ^26.2.0 + version: 26.2.0 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.2.0)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.16.9)(yaml@2.9.0)) + wrangler: + specifier: 4.125.0 + version: 4.125.0(@cloudflare/workers-types@5.20260825.1) + packages: '@alloc/quick-lru@5.2.0': @@ -272,6 +300,88 @@ packages: '@types/react': optional: true + '@better-auth/core@1.7.1': + resolution: {integrity: sha512-eZ9lqcnVLMZ3QtUByRo4VZqkB1ESyRddd9NfWjBdDPgh+jcwLScoIUAqhtHLR8zaSUJZah8OLGlkzObyPdUH7A==} + peerDependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@cloudflare/workers-types': '>=4' + '@opentelemetry/api': ^1.9.0 + better-call: 1.4.0 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.7.1': + resolution: {integrity: sha512-qlqNyg5V9bXHSP68/vtlsiZayhR4hgvEGiS/E3SIj8bCpWWFGmyQkxJbQCqpBmC7vT30wE/kNtJMHIgnV3rkiw==} + peerDependencies: + '@better-auth/core': ^1.7.1 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.7.1': + resolution: {integrity: sha512-yWCpE1cZpMUj37nD6JFDK+GDR8zS37L5WI73il3qbU9TXtWsxUQKc/5c3IHsHizWQsmcQI8uv2pAFKxsRDa+AQ==} + peerDependencies: + '@better-auth/core': ^1.7.1 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.7.1': + resolution: {integrity: sha512-6NX1yv88DeqdoG7owYFqKwlrDGaIPhsC52JUGrUgeGVKyOq8a/6hHlHsqG1C2FwT23SHQiKVYCEAG9N6aH5OvQ==} + peerDependencies: + '@better-auth/core': ^1.7.1 + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.1': + resolution: {integrity: sha512-9ILTcNqhG37QK//qR4UhYLyKzNqq6w6zVTf5KX6xkiTjNcV7Oh1yS31lkIJEVTqRNcy9AoV6FZMW6Bbsm8IMDA==} + peerDependencies: + '@better-auth/core': ^1.7.1 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.7.1': + resolution: {integrity: sha512-ZiUcafQ85InAofcUjyGgCPjKLfQjXr9SvDmMjuFUW8oEbreA6C6GaFAEA77VuV2doZUQlzvQQ4gCoTmS28W92A==} + peerDependencies: + '@better-auth/core': ^1.7.1 + '@better-auth/utils': 0.4.2 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.7.1': + resolution: {integrity: sha512-kLKjMfFlTbyt49DGeI9okHAsn0MtBZcMoQYKaEdgR0H3BHzqqyzePcQz/hxAmRgjB4p/6inise3zJwhX0sgXrQ==} + peerDependencies: + '@better-auth/core': ^1.7.1 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + + '@better-auth/utils@0.5.0': + resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} + + '@better-fetch/fetch@1.3.1': + resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -285,38 +395,75 @@ packages: workerd: optional: true + '@cloudflare/vitest-plugin@1.0.0': + resolution: {integrity: sha512-AhOD/JysC15kYw25uwdkcpbsbN86jjiv0crHobViU3U/34ImWHXIiTnwqr3ZU0gXO+dIC30LUWS6bL/N+BaDbQ==} + peerDependencies: + '@vitest/runner': ^4.1.0 + '@vitest/snapshot': ^4.1.0 + vitest: ^4.1.0 + '@cloudflare/workerd-darwin-64@1.20260811.1': resolution: {integrity: sha512-i5jqz+ywtOefr0AJbiAc8qxBLfSim/B0WJG7aW3B+pWnoVfMJdUQvi+BWcFKZJ0MoCci3KadTx6g31VfuEEqpQ==} engines: {node: '>=16'} cpu: [x64] os: [darwin] + '@cloudflare/workerd-darwin-64@1.20260820.1': + resolution: {integrity: sha512-5F2/t7SVnugG3rscSe9da1LoHst+GiuVGPaE9BP6j5AonlFpiYi0eoJrl3wof9zDBIIgJZ87NjRj9TKjbbYgHg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260811.1': resolution: {integrity: sha512-NoOUM/nvaDdm2Onlnz33FikWjtatzulNtvwvy4xs0IrHaTCHwC0c8NwIt6s+AI13FkDs02/vm2I3GTPLCT9+hQ==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260820.1': + resolution: {integrity: sha512-jFnG7715+r9FXRZPpuWWe5Ayd9v/IJKDODARg56ffFJWWtte6bFNi5VY3GazBM04hEmxny6OjXQBh3j2E/wNZw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + '@cloudflare/workerd-linux-64@1.20260811.1': resolution: {integrity: sha512-sdYq2jL1AD1supa3fsi5O4zTB28wSjvTHj7Migh6/ts8EROPdvrSwv+rdGHhv8HJNAz/wbIAY3wZsi1Rw4uUIg==} engines: {node: '>=16'} cpu: [x64] os: [linux] + '@cloudflare/workerd-linux-64@1.20260820.1': + resolution: {integrity: sha512-TbTYaCBht0OaOWmnVpg43hXVYtIti/Kg6lvO819H2DwbxPpK1BH0z2C+Y7ySrVJLlxZQeic5eN7/noqbP80hJg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260811.1': resolution: {integrity: sha512-RIRv4shbu1kg05sD+DHTpSFCNnb5Dl2SkPDMUykqZa508tkPqe7VVw7gO0Q5msTBGyL0FfFrLuRxwwfA8u5Sow==} engines: {node: '>=16'} cpu: [arm64] os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260820.1': + resolution: {integrity: sha512-FQmri1UF7hBnnpeyC5SZJCAnsSRs3+Ykn9wlO5zxmE2qIgKfbJZ+toJ6qrQyugWlxE65juT33HU6aYLtutLJHw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + '@cloudflare/workerd-windows-64@1.20260811.1': resolution: {integrity: sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw==} engines: {node: '>=16'} cpu: [x64] os: [win32] - '@cloudflare/workers-types@5.20260818.1': - resolution: {integrity: sha512-a89taQDbqb7Ni+xAVSsiOSd5wQPcbBJBnZgIG3EujVdDcdQkGVwab3xa2e9z29uqtMQb/P2gYDeDcNXcBRSWQQ==} + '@cloudflare/workerd-windows-64@1.20260820.1': + resolution: {integrity: sha512-BPvCuMxIQfA47wtYsGbBG6Bcar57Qs7yHqU2jWkT1+sRnL/741/ZbQGP9kVRMQ5fwBMuqNFmQRzAu8gSm7ri/w==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@5.20260825.1': + resolution: {integrity: sha512-/XZntbK+BlJWC5jxkaDNhnDLr2Bf2627sZ6VMrxqKrnc84pc6gNu5NdAyaTkZn1LzQVFIa3SL7V/7veLF59P7w==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -1156,6 +1303,10 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@2.3.0': + resolution: {integrity: sha512-Clu/xdfgVTf9o7ngLOURaxePwR0j8sjclKEtVij10/jGulwFsPWCvvRgG/XjUVf8Nei+jLG6uwyXzUTGY1DQrw==} + engines: {node: '>= 20.19.0'} + '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -1164,6 +1315,10 @@ packages: resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} engines: {node: '>= 20.19.0'} + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@oxlint/binding-android-arm-eabi@1.78.0': resolution: {integrity: sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1971,6 +2126,76 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + better-auth@1.7.1: + resolution: {integrity: sha512-g8WlTQijxXWJjPVZfFu1+EJg9cwwHrKDmIkcYMzx8CzYA+tDxl6NI7qQbKkbgw5UtHILsT5VH+RMzFzwnVJqAg==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.4.0: + resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} @@ -2068,6 +2293,9 @@ packages: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} + cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2178,6 +2406,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2715,6 +2946,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2754,6 +2988,10 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + kysely@0.29.5: + resolution: {integrity: sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==} + engines: {node: '>=22.0.0'} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -3064,6 +3302,10 @@ packages: resolution: {integrity: sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA==} engines: {node: '>=22.0.0'} + miniflare@5.20260820.0-alpha: + resolution: {integrity: sha512-Bv1j2kcKKNwXLWuCx+j0xGt7z318mqQkJmEN6ellM9sCESbPBDTM9ofZMbKqx47jSnoGA3CiaUkAmzGVXUa/wQ==} + engines: {node: '>=22.0.0'} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -3119,6 +3361,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.5.2: + resolution: {integrity: sha512-B0UbxzK1s0CN8Xht6r+7iT5+xV8PTaRERR1nATeplRv1Rw5YLWfVAid0hkqY3EceqpG4RjTk8GAwIxQY39Rnwg==} + engines: {node: ^20.0.0 || >=22.0.0} + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -3468,6 +3714,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -3509,6 +3758,9 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + sharp@0.35.2: resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} engines: {node: '>=20.9.0'} @@ -3916,6 +4168,11 @@ packages: engines: {node: '>=16'} hasBin: true + workerd@1.20260820.1: + resolution: {integrity: sha512-/wk4rFNHH6IVMXFe6aPEZsT5YWpWtfKTUMt7+rFkyeGbXcGCy4yxODmJatbqYj0jmqHETdz0+m2mT+vNjXnF7w==} + engines: {node: '>=16'} + hasBin: true + wrangler@4.123.0: resolution: {integrity: sha512-VXo2I1oa0x9aGAKIFPRSQPqTh0RBY5Ktl44YOhNmsJQFUdJKDA2vVTU6Xj+FC2koll6orJqWZN8jbXVIk9O67Q==} engines: {node: '>=22.0.0'} @@ -3926,6 +4183,16 @@ packages: '@cloudflare/workers-types': optional: true + wrangler@4.125.0: + resolution: {integrity: sha512-yFpvggu+xk1Hdm/Uxwaqa19bb7GArME4CrCS3Vov68a2TZq2MPO+wLocKbbnIC9K0oLowcdau7/ycxbbNHKCEg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260820.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4143,6 +4410,63 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 + '@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2)': + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.4.0(zod@4.4.3) + jose: 6.2.10 + kysely: 0.29.5 + nanostores: 1.5.2 + zod: 4.4.3 + optionalDependencies: + '@cloudflare/workers-types': 5.20260825.1 + + '@better-auth/drizzle-adapter@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/utils': 0.4.2 + + '@better-auth/kysely-adapter@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(kysely@0.29.5)': + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/utils': 0.4.2 + optionalDependencies: + kysely: 0.29.5 + + '@better-auth/memory-adapter@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/utils': 0.4.2 + + '@better-auth/prisma-adapter@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/utils': 0.4.2 + + '@better-auth/telemetry@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.3.0 + + '@better-auth/utils@0.5.0': + dependencies: + '@noble/hashes': 2.3.0 + + '@better-fetch/fetch@1.3.1': {} + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260811.1)': @@ -4151,24 +4475,59 @@ snapshots: optionalDependencies: workerd: 1.20260811.1 + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260820.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260820.1 + + '@cloudflare/vitest-plugin@1.0.0(@cloudflare/workers-types@5.20260825.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10(@types/node@26.2.0)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.16.9)(yaml@2.9.0)))': + dependencies: + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.1 + miniflare: 5.20260820.0-alpha + vitest: 4.1.10(@types/node@26.2.0)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.16.9)(yaml@2.9.0)) + wrangler: 4.125.0(@cloudflare/workers-types@5.20260825.1) + zod: 4.4.3 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - bufferutil + - utf-8-validate + '@cloudflare/workerd-darwin-64@1.20260811.1': optional: true + '@cloudflare/workerd-darwin-64@1.20260820.1': + optional: true + '@cloudflare/workerd-darwin-arm64@1.20260811.1': optional: true + '@cloudflare/workerd-darwin-arm64@1.20260820.1': + optional: true + '@cloudflare/workerd-linux-64@1.20260811.1': optional: true + '@cloudflare/workerd-linux-64@1.20260820.1': + optional: true + '@cloudflare/workerd-linux-arm64@1.20260811.1': optional: true + '@cloudflare/workerd-linux-arm64@1.20260820.1': + optional: true + '@cloudflare/workerd-windows-64@1.20260811.1': optional: true - '@cloudflare/workers-types@5.20260818.1': + '@cloudflare/workerd-windows-64@1.20260820.1': optional: true + '@cloudflare/workers-types@5.20260825.1': {} + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -4801,10 +5160,14 @@ snapshots: '@next/swc-win32-x64-msvc@16.3.2': optional: true + '@noble/ciphers@2.3.0': {} + '@noble/hashes@1.4.0': {} '@noble/hashes@2.3.0': {} + '@opentelemetry/semantic-conventions@1.43.0': {} + '@oxlint/binding-android-arm-eabi@1.78.0': optional: true @@ -5477,6 +5840,43 @@ snapshots: baseline-browser-mapping@2.11.13: {} + better-auth@1.7.1(@cloudflare/workers-types@5.20260825.1)(next@16.3.2(@babel/core@7.29.7)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@26.2.0)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.16.9)(yaml@2.9.0))): + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/drizzle-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(kysely@0.29.5) + '@better-auth/memory-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.3.0 + '@noble/hashes': 2.3.0 + better-call: 1.4.0(zod@4.4.3) + defu: 6.1.7 + jose: 6.2.10 + kysely: 0.29.5 + nanostores: 1.5.2 + zod: 4.4.3 + optionalDependencies: + next: 16.3.2(@babel/core@7.29.7)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + vitest: 4.1.10(@types/node@26.2.0)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.16.9)(yaml@2.9.0)) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-call@1.4.0(zod@4.4.3): + dependencies: + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.1 + rou3: 0.9.2 + set-cookie-parser: 3.1.2 + optionalDependencies: + zod: 4.4.3 + blake3-wasm@2.1.5: {} bluebird@3.7.2: {} @@ -5583,6 +5983,8 @@ snapshots: ci-info@4.4.0: {} + cjs-module-lexer@1.2.3: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -5678,6 +6080,8 @@ snapshots: object-keys: 1.1.1 optional: true + defu@6.1.7: {} + delayed-stream@1.0.0: {} dequal@2.0.3: {} @@ -6382,6 +6786,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.10: {} + js-tokens@4.0.0: {} js-yaml@4.3.1: @@ -6415,6 +6821,8 @@ snapshots: kleur@4.1.5: {} + kysely@0.29.5: {} + lazy-val@1.0.5: {} lightningcss-android-arm64@1.32.0: @@ -6962,6 +7370,18 @@ snapshots: - bufferutil - utf-8-validate + miniflare@5.20260820.0-alpha: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.29.0 + workerd: 1.20260820.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -7008,6 +7428,8 @@ snapshots: nanoid@3.3.18: {} + nanostores@1.5.2: {} + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 @@ -7470,6 +7892,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.4 fsevents: 2.3.3 + rou3@0.9.2: {} + safe-buffer@5.1.2: {} sanitize-filename@1.6.4: @@ -7500,6 +7924,8 @@ snapshots: type-fest: 0.13.1 optional: true + set-cookie-parser@3.1.2: {} + sharp@0.35.2: dependencies: '@img/colour': 1.1.0 @@ -7923,7 +8349,15 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260811.1 '@cloudflare/workerd-windows-64': 1.20260811.1 - wrangler@4.123.0(@cloudflare/workers-types@5.20260818.1): + workerd@1.20260820.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260820.1 + '@cloudflare/workerd-darwin-arm64': 1.20260820.1 + '@cloudflare/workerd-linux-64': 1.20260820.1 + '@cloudflare/workerd-linux-arm64': 1.20260820.1 + '@cloudflare/workerd-windows-64': 1.20260820.1 + + wrangler@4.123.0(@cloudflare/workers-types@5.20260825.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260811.1) @@ -7934,7 +8368,24 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260811.1 optionalDependencies: - '@cloudflare/workers-types': 5.20260818.1 + '@cloudflare/workers-types': 5.20260825.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + wrangler@4.125.0(@cloudflare/workers-types@5.20260825.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260820.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260820.0-alpha + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260820.1 + optionalDependencies: + '@cloudflare/workers-types': 5.20260825.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 013ee40ef..e2dd683a3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - "." - "apps/docs" + - "cloudflare/control-plane" allowBuilds: electron: true # postinstall downloads the Electron dist esbuild: true # postinstall validates the platform binary