Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ jobs:
HQBASE_STAGING_EMAIL_DOMAIN: ${{ secrets.HQBASE_E2E_EMAIL_DOMAIN }}
HQBASE_STAGING_OWNER_EMAIL: owner@${{ secrets.HQBASE_E2E_EMAIL_DOMAIN }}
HQBASE_STAGING_OWNER_PASSWORD: ${{ secrets.HQBASE_E2E_OWNER_PASSWORD }}
HQBASE_STAGING_OAUTH_CLIENT_ID: ${{ vars.HQBASE_E2E_OAUTH_CLIENT_ID }}
HQBASE_STAGING_SENDER: hello@${{ secrets.HQBASE_E2E_EMAIL_DOMAIN }}
HQBASE_STAGING_URL: https://${{ secrets.HQBASE_E2E_APP_HOSTNAME }}
DEPLOYMENT_NAME: release-${{ github.run_id }}
Expand Down Expand Up @@ -220,6 +221,12 @@ jobs:
run: pnpm test:e2e:staging:lifecycle
- name: Apply the exact signed candidate
run: |
pnpm hqbase oauth \
--name "$DEPLOYMENT_NAME" \
--mode customer \
--auth-url "$HQBASE_STAGING_URL" \
--client-id "$HQBASE_STAGING_OAUTH_CLIENT_ID" \
--skip-deploy
config=".hqbase/deployments/$DEPLOYMENT_NAME/wrangler.jsonc"
HQBASE_AUTH_SECRET="$HQBASE_STAGING_AUTH_SECRET" \
HQBASE_EXPECTED_RELEASE_VERSION="$CANDIDATE_VERSION" \
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/staging-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
HQBASE_STAGING_EMAIL_DOMAIN: ${{ secrets.HQBASE_E2E_EMAIL_DOMAIN }}
HQBASE_STAGING_OWNER_EMAIL: owner@${{ secrets.HQBASE_E2E_EMAIL_DOMAIN }}
HQBASE_STAGING_OWNER_PASSWORD: ${{ secrets.HQBASE_E2E_OWNER_PASSWORD }}
HQBASE_STAGING_OAUTH_CLIENT_ID: ${{ vars.HQBASE_E2E_OAUTH_CLIENT_ID }}
HQBASE_STAGING_SENDER: hello@${{ secrets.HQBASE_E2E_EMAIL_DOMAIN }}
HQBASE_STAGING_URL: https://${{ secrets.HQBASE_E2E_APP_HOSTNAME }}
DEPLOYMENT_NAME: e2e-${{ github.run_id }}
Expand Down Expand Up @@ -60,6 +61,9 @@ jobs:
--domain "$HQBASE_STAGING_EMAIL_DOMAIN"
--no-email
--app-domain "${HQBASE_STAGING_URL#https://}"
--auth-url "$HQBASE_STAGING_URL"
--oauth-mode customer
--oauth-client-id "$HQBASE_STAGING_OAUTH_CLIENT_ID"
--skip-build
--skip-deploy
- name: Deploy reviewed source candidate
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Run `pnpm cf:typegen` after changing `wrangler.jsonc`.
## Operations

- `docs/access-control.md`: roles, mailbox grants, and enforcement.
- `docs/customer-managed-oauth.md`: private Cloudflare OAuth clients for restricted organizations.
- `docs/mcp.md`: MCP discovery, OAuth, scopes, and mailbox authorization.
- `docs/operations.md`: doctor, backup, restore, queues, retention, and incident order.
- `docs/updates.md`: public signed releases, update checks, verification, and recovery.
Expand Down
2 changes: 1 addition & 1 deletion app/features/domains/domain-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,5 +224,5 @@ function readPendingOperation(): PendingCloudflareOperation | null {
function oauthErrorMessage(result: string): string {
if (result === "denied") return "Cloudflare authorization was cancelled.";
if (result === "invalid") return "Cloudflare authorization expired. Please try again.";
return "Cloudflare could not authorize this change. If your organization blocks HQBase, ask a Cloudflare administrator to allow the OAuth application.";
return "Cloudflare could not authorize this change. Ask a Cloudflare administrator to allow HQBase or configure customer-managed OAuth from the deployment guide.";
}
2 changes: 1 addition & 1 deletion app/features/updates/update-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export function UpdateSettings({
function oauthErrorMessage(result: string): string {
if (result === "denied") return "Cloudflare authorization was cancelled.";
if (result === "invalid") return "Cloudflare authorization expired. Please try again.";
return "Cloudflare could not authorize the update. If your organization blocks HQBase, ask a Cloudflare administrator to allow the OAuth application.";
return "Cloudflare could not authorize the update. Ask a Cloudflare administrator to allow HQBase or configure customer-managed OAuth from the deployment guide.";
}

function Version({ label, value }: { label: string; value: string }): React.ReactElement {
Expand Down
5 changes: 5 additions & 0 deletions config/product.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
"cloudflareOAuthClientId": "1c413f324b518b452096929b847e6703",
"cloudflareOAuthRedirectUri": "https://auth.hqbase.io/oauth/callback",
"cloudflareOAuthRelayUrl": "https://auth.hqbase.io",
"cloudflareOAuthScopes": {
"setup": "workers-scripts.write zone.read zone-settings.write email-routing-rule.write email-sending.write",
"domains": "workers-scripts.write zone.read zone-settings.write email-routing-rule.write email-sending.write",
"updates": "workers-scripts.write workers-ci.write zone.read"
},
"releaseManifestUrl": "https://github.com/HQBase/hqbase/releases/latest/download/stable.json",
"releasePublicKey": "MCowBQYDK2VwAyEAsVwKniCvpHDwbbnjTPP0SuIIG97cRL+iFBQvay9OrU4="
}
93 changes: 93 additions & 0 deletions docs/customer-managed-oauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Customer-managed Cloudflare OAuth

HQBase uses the verified public HQBase OAuth client by default. If a Cloudflare administrator
blocks public OAuth applications, the administrator can register a private OAuth client in the
same Cloudflare account as the HQBase deployment. HQBase still uses Authorization Code with PKCE
and never receives a client secret or API token.

## Register the client

In Cloudflare, create an OAuth client with:

- Grant type: `authorization_code`.
- Response type: `code`.
- Token endpoint authentication method: `none`.
- PKCE method: `S256`.
- Visibility: private.

Register these three exact redirect URLs, replacing `https://mail.example.com` with the canonical
HTTPS origin used to open HQBase:

```text
https://mail.example.com/api/setup/cloudflare/oauth/callback
https://mail.example.com/api/domains/cloudflare/oauth/callback
https://mail.example.com/api/updates/cloudflare/oauth/callback
```

Allow this union of HQBase operation scopes:

```text
workers-scripts.write
workers-ci.write
zone.read
zone-settings.write
email-routing-rule.write
email-sending.write
```

HQBase requests only the subset required by the current setup, domain, or update operation.

## Configure a new deployment

The client ID is public configuration. Do not create or provide a client secret.

```sh
pnpm hqbase install \
--name production \
--app-domain mail.example.com \
--auth-url https://mail.example.com \
--oauth-mode customer \
--oauth-client-id YOUR_CLIENT_ID
```

The generated deployment manifest records the client ID and mode for recovery. The generated
Wrangler configuration stores them as non-secret variables in the customer account.

## Switch an existing deployment

Use the named deployment operator so the manifest, generated Wrangler configuration, and deployed
Worker remain aligned:

```sh
pnpm hqbase oauth \
--name production \
--mode customer \
--auth-url https://mail.example.com \
--client-id YOUR_CLIENT_ID
```

Validate without writing or deploying:

```sh
pnpm hqbase oauth \
--name production \
--mode customer \
--auth-url https://mail.example.com \
--client-id YOUR_CLIENT_ID \
--dry-run
```

Return to the verified public HQBase client:

```sh
pnpm hqbase oauth --name production --mode official
```

## Security properties

- The configured canonical origin, not the incoming request Host header, determines every direct
callback.
- The customer Worker generates the PKCE verifier, exchanges the code, encrypts the temporary
grant in an HTTP-only cookie, and revokes it after the operation.
- OAuth callback invocation URLs are not persisted in Workers Logs.
- HQBase never stores the Cloudflare access grant in D1 or R2.
3 changes: 3 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ already exist. Operators may supply it explicitly for controlled automation.

Cloudflare OAuth client configuration is compiled public product configuration. Temporary OAuth
grants used for setup or updates are encrypted, scoped to the operation, and revoked after use.
Organizations that block public OAuth applications can use a private customer-managed client
without providing an API token or client secret. See
[customer-managed-oauth.md](customer-managed-oauth.md).

## Default resources

Expand Down
6 changes: 4 additions & 2 deletions docs/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ commands. Both rollbacks remain deliberate operator actions because a database r
writes made after the bookmark.

Starting an update from the app requires a short-lived Cloudflare OAuth grant. HQBase revokes the
grant after use. The browser talks directly to Cloudflare, and the verified release comes directly
from the canonical public repository.
grant after use. The verified public client uses the public HQBase relay; customer-managed clients
redirect directly between the customer Worker and Cloudflare. In both modes the customer Worker
performs the token exchange, and the verified release comes directly from the canonical public
repository.

Release artifacts, manifests, checksums, and release notes are public GitHub Release assets owned
by the canonical repository.
Expand Down
14 changes: 14 additions & 0 deletions scripts/hqbase/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { updateDeployButton } from "./button.mjs";
import { destroy } from "./destroy.mjs";
import { doctor } from "./doctor.mjs";
import { install } from "./install.mjs";
import { configureOAuth } from "./oauth.mjs";
import { printPostDeploy } from "./postdeploy.mjs";
import { reset } from "./reset.mjs";
import { restore } from "./restore.mjs";
Expand All @@ -26,6 +27,9 @@ try {
case "doctor":
doctor(flags);
break;
case "oauth":
configureOAuth(flags);
break;
case "backup":
backup(flags);
break;
Expand Down Expand Up @@ -59,6 +63,7 @@ function printHelp() {
Usage:
pnpm hqbase button --repo-url https://github.com/OWNER/REPO
pnpm hqbase install --name dev-01 [--domain example.com]
pnpm hqbase oauth --name dev-01 --mode official|customer
pnpm hqbase doctor --name dev-01
pnpm hqbase backup --name dev-01 [--output backup.json]
pnpm hqbase restore --name dev-01 --backup backup.json --yes
Expand All @@ -76,10 +81,19 @@ Install options:
--no-sending Skip Email Sending enablement.
--app-domain <host> Attach a custom Worker domain in the generated config.
--auth-url <origin> Set BETTER_AUTH_URL explicitly. Usually unnecessary.
--oauth-mode <mode> Use official (default) or customer-managed OAuth.
--oauth-client-id <id> Customer OAuth client ID. Requires --oauth-mode customer and --auth-url.
HQBASE_AUTH_SECRET Preserve an existing Better Auth secret without exposing it in argv.
--auth-secret <value> Compatibility fallback. Prefer HQBASE_AUTH_SECRET.
--skip-build Skip pnpm build.
--skip-deploy Create resources/config/migrations without deploying Worker.
--dry-run Print commands without mutating Cloudflare.

OAuth options:
--mode <mode> Use official or customer-managed OAuth.
--client-id <id> Customer OAuth client ID. Required for customer mode.
--auth-url <origin> Exact canonical HTTPS HQBase origin. Required for customer mode.
--skip-deploy Validate and write local deployment configuration without deploying.
--dry-run Validate without writing or deploying.
`);
}
19 changes: 16 additions & 3 deletions scripts/hqbase/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ export function writeWranglerConfig(manifest, options = {}) {
return;
}

const config = createWranglerConfig(manifest);
fs.writeFileSync(configPath(manifest.name), `${JSON.stringify(config, null, 2)}\n`);
}

export function createWranglerConfig(manifest) {
const cloudflareOAuth = manifest.cloudflareOAuth ?? { mode: "official" };
const config = {
$schema: `${rootFromDeployment}/node_modules/wrangler/config-schema.json`,
name: manifest.worker.name,
main: `${rootFromDeployment}/worker/index.ts`,
compatibility_date: "2026-07-11",
compatibility_date: "2026-07-28",
compatibility_flags: ["nodejs_compat"],
assets: {
directory: `${rootFromDeployment}/dist`,
Expand All @@ -24,7 +30,10 @@ export function writeWranglerConfig(manifest, options = {}) {
},
observability: {
enabled: true,
head_sampling_rate: 1
logs: {
enabled: true,
invocation_logs: false
}
},
secrets: {
required: ["BETTER_AUTH_SECRET"]
Expand Down Expand Up @@ -64,14 +73,18 @@ export function writeWranglerConfig(manifest, options = {}) {
};

config.vars = {
CLOUDFLARE_OAUTH_MODE: cloudflareOAuth.mode,
HQBASE_APP_VERSION: appVersion,
HQBASE_WORKER_NAME: manifest.worker.name,
...(cloudflareOAuth.mode === "customer"
? { CLOUDFLARE_OAUTH_CLIENT_ID: cloudflareOAuth.clientId }
: {}),
...(manifest.authUrl ? { BETTER_AUTH_URL: manifest.authUrl } : {})
};
const customDomains = [manifest.appDomain].filter(Boolean);
if (customDomains.length > 0) {
config.routes = customDomains.map((pattern) => ({ pattern, custom_domain: true }));
}

fs.writeFileSync(configPath(manifest.name), `${JSON.stringify(config, null, 2)}\n`);
return config;
}
4 changes: 2 additions & 2 deletions scripts/hqbase/destroy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,9 @@ export function destroy(flags) {
}

function assertDestroyManifest(manifest, targets) {
if (manifest?.version !== 1) {
if (manifest?.version !== 1 && manifest?.version !== 2) {
throw new Error(
`Refusing to destroy: manifest field "version" must be the supported value 1. Migrate or repair the manifest from verified deployment records before retrying.`
`Refusing to destroy: manifest field "version" must be a supported value. Migrate or repair the manifest from verified deployment records before retrying.`
);
}

Expand Down
50 changes: 49 additions & 1 deletion scripts/hqbase/install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export function install(flags) {
const manifest = createManifest(name, {
appDomain: optionalString(flags, "app-domain"),
authUrl: optionalString(flags, "auth-url"),
oauthClientId: optionalString(flags, "oauth-client-id"),
oauthMode: optionalString(flags, "oauth-mode"),
domain,
workerName: optionalString(flags, "worker-name"),
d1Name: optionalString(flags, "d1-name"),
Expand Down Expand Up @@ -91,9 +93,14 @@ export function createManifest(name, input) {
const queueName = input.queueName ?? `hqbase-${name}-jobs`;

validateBucketName(r2Bucket);
const cloudflareOAuth = cloudflareOAuthConfig({
authUrl: input.authUrl,
clientId: input.oauthClientId,
mode: input.oauthMode
});

return {
version: 1,
version: 2,
name,
createdAt: new Date().toISOString(),
worker: { name: workerName, deployed: false },
Expand All @@ -111,6 +118,7 @@ export function createManifest(name, input) {
queue: { name: queueName, deadLetterName: `${queueName}-dlq`, created: false },
appDomain: input.appDomain,
authUrl: input.authUrl,
cloudflareOAuth,
email: input.domain
? {
domain: input.domain,
Expand All @@ -123,6 +131,46 @@ export function createManifest(name, input) {
};
}

export function cloudflareOAuthConfig({ authUrl, clientId, mode }) {
const resolvedMode = mode ?? "official";
if (resolvedMode !== "official" && resolvedMode !== "customer") {
throw new Error('--oauth-mode must be "official" or "customer".');
}
if (resolvedMode === "official") {
if (clientId) {
throw new Error("--oauth-client-id requires --oauth-mode customer.");
}
return { mode: "official" };
}
if (!clientId || clientId.length > 256) {
throw new Error("Customer-managed OAuth requires --oauth-client-id.");
}
validateCanonicalHttpsOrigin(authUrl);
return { clientId, mode: "customer" };
}

function validateCanonicalHttpsOrigin(value) {
if (!value) {
throw new Error("Customer-managed OAuth requires --auth-url.");
}
let url;
try {
url = new URL(value);
} catch {
throw new Error("--auth-url must be a valid canonical HTTPS origin.");
}
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash
) {
throw new Error("--auth-url must be a canonical HTTPS origin without a path.");
}
}

function configureEmail(manifest, options) {
const { domain } = manifest.email;
run("pnpm", ["exec", "wrangler", "email", "routing", "enable", domain], options);
Expand Down
Loading
Loading