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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
5 changes: 5 additions & 0 deletions cloudflare/control-plane/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -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
98 changes: 98 additions & 0 deletions cloudflare/control-plane/README.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions cloudflare/control-plane/migrations/0001_better_auth_1_7_1.sql
Original file line number Diff line number Diff line change
@@ -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");
82 changes: 82 additions & 0 deletions cloudflare/control-plane/migrations/0002_installations.sql
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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)
);
25 changes: 25 additions & 0 deletions cloudflare/control-plane/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
77 changes: 77 additions & 0 deletions cloudflare/control-plane/src/auth.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createAuth>;

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]}` }),
});
}
Loading
Loading