diff --git a/app/features/setup/setup-domain-screen.tsx b/app/features/setup/setup-domain-screen.tsx
index c6ad01d8..5d5ebc88 100644
--- a/app/features/setup/setup-domain-screen.tsx
+++ b/app/features/setup/setup-domain-screen.tsx
@@ -27,6 +27,7 @@ export function DomainStep(props: {
appHostname: string;
appSubdomain: string;
connectionError: string | null;
+ enableSending: boolean;
errors: DomainErrors;
isLoading: boolean;
onBack: () => void;
@@ -38,6 +39,7 @@ export function DomainStep(props: {
selectedZoneIds: string[];
selectedZones: CloudflareZone[];
setAppSubdomain: (value: string) => void;
+ setEnableSending: (value: boolean) => void;
setPortalZoneId: (value: string) => void;
zones: CloudflareZone[];
}): React.ReactElement {
@@ -118,6 +120,26 @@ export function DomainStep(props: {
onChange={props.setAppSubdomain}
onDomainChange={props.setPortalZoneId}
/>
+
+
+
+
);
}
@@ -250,7 +272,7 @@ function describeReadinessFailure(status: CloudflareConfigureResult["status"]):
if (!status.catchAll.enabled || !status.catchAll.configuredForWorker) {
issues.push(status.catchAll.error ?? "Catch-all is not routing to this HQBase Worker.");
}
- if (!status.sending.enabled) {
+ if (status.sendingRequired && !status.sending.enabled) {
issues.push(status.sending.error ?? "Email Sending is not enabled.");
}
return issues.join(" ") || "Cloudflare has not reported this domain as ready yet.";
diff --git a/app/features/setup/setup-preview-fixtures.tsx b/app/features/setup/setup-preview-fixtures.tsx
index 53834cb8..1b936bd6 100644
--- a/app/features/setup/setup-preview-fixtures.tsx
+++ b/app/features/setup/setup-preview-fixtures.tsx
@@ -115,6 +115,7 @@ export function renderPreviewFixture(input: FixtureInput): React.ReactNode {
connectionError={
readinessError ? "Cloudflare needs attention on one or more checks below." : null
}
+ enableSending={true}
errors={{}}
isLoading={false}
onBack={() => undefined}
@@ -130,6 +131,7 @@ export function renderPreviewFixture(input: FixtureInput): React.ReactNode {
selectedZoneIds={input.selectedZoneIds}
selectedZones={input.selectedZones}
setAppSubdomain={input.setAppSubdomain}
+ setEnableSending={() => undefined}
setPortalZoneId={input.setPortalZoneId}
zones={zones}
/>
@@ -245,6 +247,7 @@ function readinessFailureFixture(): ConfiguredDomain[] {
subdomains: [zone.name],
error: null
},
+ sendingRequired: true,
ready: false
}
}
diff --git a/app/features/setup/types.ts b/app/features/setup/types.ts
index dbd0ff12..530e18db 100644
--- a/app/features/setup/types.ts
+++ b/app/features/setup/types.ts
@@ -59,6 +59,7 @@ export type CloudflareDomainStatus = {
subdomains: string[];
error: string | null;
};
+ sendingRequired: boolean;
ready: boolean;
};
diff --git a/app/features/setup/use-setup-cloudflare.ts b/app/features/setup/use-setup-cloudflare.ts
index 2586b91e..cdfe0240 100644
--- a/app/features/setup/use-setup-cloudflare.ts
+++ b/app/features/setup/use-setup-cloudflare.ts
@@ -23,6 +23,9 @@ export function useSetupCloudflare(callbacks: {
const [portalZoneId, setPortalZoneId] = React.useState("");
const workerName = React.useMemo(() => inferWorkerName(), []);
const [appSubdomain, setAppSubdomain] = React.useState("hqbase");
+ // Cloudflare Email Sending requires a Workers Paid plan. Operators on the free
+ // plan can still run a receive-only workspace by turning this off.
+ const [enableSending, setEnableSending] = React.useState(true);
const [domainAttempted, setDomainAttempted] = React.useState(false);
const [connectionError, setConnectionError] = React.useState(null);
const [results, setResults] = React.useState([]);
@@ -37,7 +40,8 @@ export function useSetupCloudflare(callbacks: {
...selectedZoneIds.slice().sort(),
portalZoneId,
appHostname,
- workerName
+ workerName,
+ String(enableSending)
].join(":");
const domainConnected = Boolean(
configuredKey === currentConnectionKey &&
@@ -96,7 +100,7 @@ export function useSetupCloudflare(callbacks: {
const result = await configureCloudflareDomain({
...(isPortal ? { appHostname } : {}),
attachCustomDomain: isPortal,
- enableSending: true,
+ enableSending,
workerName: workerName.trim(),
zoneId: zone.id
});
@@ -156,6 +160,7 @@ export function useSetupCloudflare(callbacks: {
appHostname,
appSubdomain,
connectionError,
+ enableSending,
errors: domainErrors,
isLoading,
portalZone,
@@ -167,6 +172,7 @@ export function useSetupCloudflare(callbacks: {
onConnect: () => void handleDomainConnect(),
onToggleZone: toggleZone,
setAppSubdomain: (value: string) => update(() => setAppSubdomain(value)),
+ setEnableSending: (value: boolean) => update(() => setEnableSending(value)),
setPortalZoneId: (value: string) => update(() => setPortalZoneId(value))
},
domainConnected,
diff --git a/worker/features/setup/cloudflare.ts b/worker/features/setup/cloudflare.ts
index 61a9d6d2..1f94ef89 100644
--- a/worker/features/setup/cloudflare.ts
+++ b/worker/features/setup/cloudflare.ts
@@ -18,6 +18,10 @@ type CloudflareInput = { apiToken: string };
type CloudflareZoneInput = CloudflareInput & {
zoneId: string;
workerName?: string | undefined;
+ // Outbound sending needs a Workers Paid plan. A receive-only workspace is a
+ // valid configuration, so readiness must not demand sending when the operator
+ // deliberately skipped it.
+ requireSending?: boolean | undefined;
};
type CloudflareConfigureInput = CloudflareZoneInput & {
@@ -124,19 +128,21 @@ export async function inspectCloudflareDomain(
inspectSending(input.apiToken, zone.id)
]);
+ const sendingRequired = input.requireSending ?? true;
const ready =
zone.status === "active" &&
routing.enabled &&
routing.dnsReady &&
catchAll.enabled &&
catchAll.configuredForWorker &&
- sending.enabled;
+ (!sendingRequired || sending.enabled);
return {
catchAll,
ready,
routing,
sending,
+ sendingRequired,
workerName,
zone
};
@@ -229,7 +235,7 @@ export async function configureCloudflareDomain(
steps.push({
id: "sending",
label: "Enable Email Sending",
- message: "Skipped by setup option.",
+ message: "Skipped. This workspace receives mail but cannot send it.",
status: "skipped"
});
}
@@ -237,6 +243,7 @@ export async function configureCloudflareDomain(
return {
status: await inspectCloudflareDomain({
apiToken: input.apiToken,
+ requireSending: input.enableSending,
workerName,
zoneId: zone.id
}),
diff --git a/worker/features/setup/routes.ts b/worker/features/setup/routes.ts
index e4536975..159c67d1 100644
--- a/worker/features/setup/routes.ts
+++ b/worker/features/setup/routes.ts
@@ -2,6 +2,7 @@ import { Hono } from "hono";
import type { HonoApp } from "../../lib/env";
import { readJson } from "../../lib/json";
import { parseWith } from "../../lib/validation";
+import { enforceRateLimit } from "../../security/rate-limit";
import {
clearRuntimeCloudflareGrantCookie,
finishRuntimeCloudflareOAuth,
@@ -88,6 +89,13 @@ setupRoutes.post("/cloudflare/configure", async (c) => {
});
setupRoutes.post("/bootstrap", async (c) => {
+ const ip = c.req.header("cf-connecting-ip") ?? "unknown";
+ await enforceRateLimit(c.env.DB, c.env.BETTER_AUTH_SECRET, {
+ scope: "setup.bootstrap.ip",
+ subject: ip,
+ limit: 5,
+ windowSeconds: 15 * 60
+ });
const input = parseWith(bootstrapSetupSchema, await readJson(c.req.raw));
const grant = await resolveRuntimeCloudflareGrant(c.req.raw, c.env);
const result = await bootstrapSetup(c.env, c.req.raw, input);
diff --git a/worker/features/setup/service.ts b/worker/features/setup/service.ts
index b8d72b13..214ce759 100644
--- a/worker/features/setup/service.ts
+++ b/worker/features/setup/service.ts
@@ -1,4 +1,5 @@
import { signUpOwnerUser } from "../../auth/user-actions";
+import { nowIso } from "../../db/client";
import type { WorkerEnv } from "../../lib/env";
import { AppError } from "../../lib/errors";
import { assertLoginEmailOutsideDomains } from "../../security/login-email";
@@ -50,64 +51,90 @@ export async function bootstrapSetup(
throw new AppError("SETUP_OWNER_EXISTS", "An owner user already exists.", 409);
}
- const domains = input.emailDomains ?? [{ name: input.primaryDomain ?? "" }];
- if (!domains[0]?.name) throw new AppError("DOMAIN_REQUIRED", "Choose an email domain.", 400);
- assertLoginEmailOutsideDomains(
- input.ownerEmail,
- domains.map((domain) => domain.name)
- );
-
- for (const domain of domains) {
- await upsertMailDomain(env.DB, {
- ...domain,
- receivingStatus: "ready",
- sendingStatus: "ready",
- dnsStatus: "ready"
- });
+ // The userCount check above is check-then-act and not atomic by itself: two
+ // concurrent bootstrap calls against a fresh, unauthenticated instance could
+ // both observe userCount === 0 and each create an independent owner. Claim a
+ // singleton lock row first; the PRIMARY KEY constraint makes only one caller
+ // win regardless of concurrency, so the loser is rejected before it can touch
+ // the user table.
+ const lockTimestamp = nowIso();
+ const claim = await env.DB.prepare(
+ `INSERT INTO app_settings (key, value_json, created_at, updated_at)
+ VALUES ('setup_bootstrap_lock', 'true', ?, ?)
+ ON CONFLICT(key) DO NOTHING`
+ )
+ .bind(lockTimestamp, lockTimestamp)
+ .run();
+ if (!claim.meta.changes) {
+ throw new AppError("SETUP_IN_PROGRESS", "Setup is already being completed.", 409);
}
- const owner = await signUpOwnerUser(env, request, {
- email: input.ownerEmail,
- name: input.ownerName,
- password: input.ownerPassword,
- role: "owner"
- });
+ try {
+ const domains = input.emailDomains ?? [{ name: input.primaryDomain ?? "" }];
+ if (!domains[0]?.name) throw new AppError("DOMAIN_REQUIRED", "Choose an email domain.", 400);
+ assertLoginEmailOutsideDomains(
+ input.ownerEmail,
+ domains.map((domain) => domain.name)
+ );
+
+ for (const domain of domains) {
+ await upsertMailDomain(env.DB, {
+ ...domain,
+ receivingStatus: "ready",
+ sendingStatus: "ready",
+ dnsStatus: "ready"
+ });
+ }
- await setPrimaryDomain(env.DB, domains[0].name);
- if (input.portalHostname) {
- await upsertWorkspaceHost(env.DB, {
- hostname: input.portalHostname,
- zoneId:
- domains.find((domain) => input.portalHostname?.endsWith(`.${domain.name}`))?.zoneId ?? null,
- kind: "portal",
- canonical: true
+ const owner = await signUpOwnerUser(env, request, {
+ email: input.ownerEmail,
+ name: input.ownerName,
+ password: input.ownerPassword,
+ role: "owner"
});
- }
- await setChecklistAcknowledged(env.DB, input.checklistAcknowledged);
- const mailboxes: Mailbox[] = [];
- for (const mailbox of input.mailboxes) {
- mailboxes.push(await createMailbox(env.DB, mailbox));
- }
- const defaultFromMailbox = mailboxes.find(
- (mailbox) => mailbox.address === input.defaultFromMailboxAddress
- );
- if (!defaultFromMailbox) {
- throw new AppError(
- "DEFAULT_FROM_MAILBOX_REQUIRED",
- "Choose one of the setup mailboxes as the default From mailbox.",
- 400
+ await setPrimaryDomain(env.DB, domains[0].name);
+ if (input.portalHostname) {
+ await upsertWorkspaceHost(env.DB, {
+ hostname: input.portalHostname,
+ zoneId:
+ domains.find((domain) => input.portalHostname?.endsWith(`.${domain.name}`))?.zoneId ??
+ null,
+ kind: "portal",
+ canonical: true
+ });
+ }
+ await setChecklistAcknowledged(env.DB, input.checklistAcknowledged);
+
+ const mailboxes: Mailbox[] = [];
+ for (const mailbox of input.mailboxes) {
+ mailboxes.push(await createMailbox(env.DB, mailbox));
+ }
+ const defaultFromMailbox = mailboxes.find(
+ (mailbox) => mailbox.address === input.defaultFromMailboxAddress
);
- }
- await setDefaultFromMailboxId(env.DB, owner.id, defaultFromMailbox.id);
+ if (!defaultFromMailbox) {
+ throw new AppError(
+ "DEFAULT_FROM_MAILBOX_REQUIRED",
+ "Choose one of the setup mailboxes as the default From mailbox.",
+ 400
+ );
+ }
+ await setDefaultFromMailboxId(env.DB, owner.id, defaultFromMailbox.id);
- await completeSetupIfReady(env.DB);
+ await completeSetupIfReady(env.DB);
- return {
- owner,
- mailboxes,
- setup: await getSetupStatus(env.DB)
- };
+ return {
+ owner,
+ mailboxes,
+ setup: await getSetupStatus(env.DB)
+ };
+ } finally {
+ // userCount > 0 guards re-entry permanently once an owner exists; the lock
+ // only needs to live for the duration of one bootstrap attempt so a failed
+ // attempt (validation error, etc.) can be retried.
+ await env.DB.prepare(`DELETE FROM app_settings WHERE key = 'setup_bootstrap_lock'`).run();
+ }
}
export async function completeSetupIfReady(db: D1Database): Promise {
diff --git a/worker/features/setup/types.ts b/worker/features/setup/types.ts
index 57642552..b45ff71d 100644
--- a/worker/features/setup/types.ts
+++ b/worker/features/setup/types.ts
@@ -73,6 +73,7 @@ export type CloudflareDomainStatus = {
routing: CloudflareRoutingStatus;
catchAll: CloudflareCatchAllStatus;
sending: CloudflareSendingStatus;
+ sendingRequired: boolean;
ready: boolean;
};
diff --git a/worker/features/setup/validation.ts b/worker/features/setup/validation.ts
index 7f7127cb..4a9c50dc 100644
--- a/worker/features/setup/validation.ts
+++ b/worker/features/setup/validation.ts
@@ -89,6 +89,7 @@ export const verifyCloudflareAccessSchema = z.object({}).strict();
export const listCloudflareZonesSchema = z.object({}).strict();
export const inspectCloudflareDomainSchema = z.object({
+ requireSending: z.boolean().optional(),
workerName: z.string().trim().min(1).max(63).optional(),
zoneId: z.string().trim().min(1).max(64)
});