-
Notifications
You must be signed in to change notification settings - Fork 33
fix: setup bootstrap race and free-plan sending block #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9699a53
ac57b10
ec43fd6
19f638e
be621ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| }); | ||
| } | ||
|
Comment on lines
+80
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Trace the Email Sending flag from setup input to domain persistence.
set -euo pipefail
rg -nP -C4 'sendingStatus|sendingRequired|emailSending|sendingEnabled' --glob '!**/node_modules/**'Repository: HQBase/hqbase Length of output: 151 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- repository guidance ---'
find .. -name AGENTS.md -o -name CONTRIBUTING.md 2>/dev/null | sort
printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(worker|setup|service|package\.json|wrangler|README|.*migration.*)' | head -200
printf '%s\n' '--- setup symbols ---'
rg -n -C5 'BootstrapInput|upsertMailDomain|receivingStatus|sendingStatus|mail domain|mailDomain' worker . --glob '!**/node_modules/**' --glob '!**/dist/**' | head -400Repository: HQBase/hqbase Length of output: 34478 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- AGENTS.md ---'
cat -n AGENTS.md
printf '%s\n' '--- CONTRIBUTING.md ---'
cat -n CONTRIBUTING.md
printf '%s\n' '--- worker setup files ---'
for f in worker/features/setup/service.ts worker/features/setup/types.ts worker/features/setup/routes.ts worker/features/setup/validation.ts worker/features/setup/queries.ts; do
printf '\n--- %s ---\n' "$f"
wc -l "$f"
cat -n "$f"
done
printf '%s\n' '--- app setup references to sending ---'
rg -n -C8 -i 'sending|enableSending|send' app/features/setup worker/features/setup worker/features/domains test/unit/worker/features/setup test/integration/worker/setup-queries.test.tsRepository: HQBase/hqbase Length of output: 50371 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- bootstrap payload construction ---'
rg -n -C12 'bootstrapSetup|/api/setup/bootstrap|emailDomains|defaultFromMailboxAddress|mailboxes' \
app/features/setup worker/features/setup test/unit/worker/features/setup test/integration/worker/setup-queries.test.ts \
| head -300
printf '%s\n' '--- Cloudflare sending semantics ---'
rg -n -C10 'sendingRequired|enableSending|requireSending|sendingStatus|sending\.enabled|status\.ready' \
worker/features/setup/cloudflare.ts worker/features/setup/cloudflare-api.ts \
worker/features/domains/routes.ts app/features/setup/use-setup-cloudflare.ts \
app/features/setup/setup-domain-screen.tsx | head -400
printf '%s\n' '--- focused source verifier ---'
python3 - <<'PY'
from pathlib import Path
import re
service = Path("worker/features/setup/service.ts").read_text()
validation = Path("worker/features/setup/validation.ts").read_text()
flow = Path("app/features/setup/use-setup-cloudflare.ts").read_text()
checks = {
"BootstrapInput declares enableSending": bool(
re.search(r"type BootstrapInput.*?enableSending", service, re.S)
),
"bootstrap schema accepts enableSending": bool(
re.search(r"bootstrapSetupSchema.*?enableSending", validation, re.S)
),
"frontend configure call passes enableSending": bool(
re.search(r"configureCloudflareDomain\(\{.*?enableSending", flow, re.S)
),
"frontend bootstrap call passes enableSending": bool(
re.search(r"bootstrapSetup\(\{.*?enableSending", flow, re.S)
),
"bootstrap writes sending ready": bool(
re.search(r"sendingStatus:\s*[\"']ready[\"']", service)
),
}
for name, result in checks.items():
print(f"{name}: {result}")
PYRepository: HQBase/hqbase Length of output: 50369 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- domain sending-status consumers ---'
rg -n -C8 'sendingStatus|sendEnabled|DOMAIN.*SEND|SENDING|outbound|send' \
worker/features/send worker/features/mailboxes worker/features/domains test/unit/worker/features/send test/unit/worker/features/mailboxes \
| head -350
printf '%s\n' '--- exact bootstrap type and payload ---'
sed -n '20,40p' worker/features/setup/service.ts
sed -n '18,42p' worker/features/setup/validation.ts
sed -n '120,142p' app/features/setup/use-setup-flow.ts
sed -n '1,90p' app/features/setup/types.ts
printf '%s\n' '--- deterministic data-flow verifier ---'
python3 - <<'PY'
from pathlib import Path
import re
service = Path("worker/features/setup/service.ts").read_text()
schema = Path("worker/features/setup/validation.ts").read_text()
flow = Path("app/features/setup/use-setup-flow.ts").read_text()
cloudflare = Path("app/features/setup/use-setup-cloudflare.ts").read_text()
def has(pattern, text):
return bool(re.search(pattern, text, re.S))
checks = [
("UI state enableSending exists", has(r"useState\(true\)", cloudflare)),
("UI sends enableSending to Cloudflare configure", has(r"configureCloudflareDomain\(\{.*?enableSending", cloudflare)),
("UI includes enableSending in bootstrap payload", has(r"const input: BootstrapSetupInput = \{.*?enableSending", flow)),
("bootstrap schema includes enableSending", has(r"bootstrapSetupSchema.*?enableSending", schema)),
("bootstrap input includes enableSending", has(r"type BootstrapInput = \{.*?enableSending", service)),
("bootstrap unconditionally writes ready sending status", has(r"sendingStatus:\s*[\"']ready[\"']", service)),
]
for label, result in checks:
print(f"{label}: {'yes' if result else 'no'}")
PYRepository: HQBase/hqbase Length of output: 27507 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- exact sending gate ---'
sed -n '128,148p' worker/features/mailboxes/queries.ts
rg -n -C6 'sendingStatus\s*:' worker worker/features app/features test --glob '!**/node_modules/**' | head -220
printf '%s\n' '--- corrected deterministic verifier ---'
python3 - <<'PY'
from pathlib import Path
import re
service = Path("worker/features/setup/service.ts").read_text()
validation = Path("worker/features/setup/validation.ts").read_text()
flow = Path("app/features/setup/use-setup-flow.ts").read_text()
client_types = Path("app/features/setup/types.ts").read_text()
bootstrap_type = re.search(r"type BootstrapInput = \{(.*?)\n\};", service, re.S).group(1)
bootstrap_schema = re.search(
r"export const bootstrapSetupSchema = z\s*\n\s*\.object\(\{(.*?)\n\s*\}\)",
validation,
re.S,
).group(1)
bootstrap_payload = re.search(
r"const input: BootstrapSetupInput = \{(.*?)\n\s*\};",
flow,
re.S,
).group(1)
client_bootstrap_type = re.search(
r"export type BootstrapSetupInput = \{(.*?)\n\};",
client_types,
re.S,
).group(1)
checks = [
("BootstrapInput has enableSending", "enableSending" in bootstrap_type),
("bootstrapSetupSchema has enableSending", "enableSending" in bootstrap_schema),
("bootstrap payload has enableSending", "enableSending" in bootstrap_payload),
("client BootstrapSetupInput has enableSending", "enableSending" in client_bootstrap_type),
("bootstrap writes sendingStatus ready", bool(re.search(
r"for \(const domain of domains\).*?sendingStatus:\s*[\"']ready[\"']",
service, re.S
))),
]
for label, result in checks:
print(f"{label}: {'yes' if result else 'no'}")
PYRepository: HQBase/hqbase Length of output: 13038 Pass the Email Sending choice to domain persistence.
🤖 Prompt for AI Agents |
||
|
|
||
| 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); | ||
|
Comment on lines
+109
to
+123
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Validate The check at Line 116 runs after Move the check to input validation, before the first write. The comment at Lines 133-135 states that a failed attempt can be retried. That statement is only true for failures that happen before 🐛 Proposed fix: validate the default From address up front try {
const domains = input.emailDomains ?? [{ name: input.primaryDomain ?? "" }];
if (!domains[0]?.name) throw new AppError("DOMAIN_REQUIRED", "Choose an email domain.", 400);
+ if (!input.mailboxes.some((mailbox) => mailbox.address === input.defaultFromMailboxAddress)) {
+ throw new AppError(
+ "DEFAULT_FROM_MAILBOX_REQUIRED",
+ "Choose one of the setup mailboxes as the default From mailbox.",
+ 400
+ );
+ }
assertLoginEmailOutsideDomains(Keep the lookup at Lines 113-122 to resolve the mailbox id, but the error path is then unreachable. 🤖 Prompt for AI Agents |
||
|
|
||
| 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<SetupStatus> { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
The lock row has no expiry, so a crash blocks setup permanently.
The
finallyblock at Line 136 releases the lock only if the isolate completes the request. If the Worker crashes, hits the CPU limit, or the isolate is evicted duringbootstrapSetup, the row stays. Every later bootstrap request then fails withSETUP_IN_PROGRESSand there is no API path to clear it.lockTimestampis stored but never read. Use it to treat an old lock as stale and take it over.🛡️ Proposed fix: allow takeover of a stale lock
ISO 8601 strings from
nowIso()compare correctly with<because the format is fixed-width UTC.📝 Committable suggestion
🤖 Prompt for AI Agents