diff --git a/configuration/oauth-capabilities.ts b/configuration/oauth-capabilities.ts
index c374d56..0ce81c0 100644
--- a/configuration/oauth-capabilities.ts
+++ b/configuration/oauth-capabilities.ts
@@ -11,7 +11,12 @@ export const DEPLOYMENT_CAPABILITIES = [
"container-rollout",
"model-gateway",
] as const;
-export type DeploymentCapability = (typeof DEPLOYMENT_CAPABILITIES)[number];
+// Optional capabilities must be explicitly catalog-reviewed before domain setup.
+// Existing installations and sign-ins do not acquire extra permissions by default.
+export const DOMAIN_CAPABILITIES = ["domain-zones", "domain-routing"] as const;
+export type DeploymentCapability =
+ | (typeof DEPLOYMENT_CAPABILITIES)[number]
+ | (typeof DOMAIN_CAPABILITIES)[number];
const field = "FLAREBOT_CONTROL_PLANE.oauthCapabilities";
function invalid(): never {
throw new ConfigurationError(
@@ -83,7 +88,12 @@ export function parseCapabilities(value: unknown) {
!capabilities.length ||
capabilities.some(
(item) =>
- !(DEPLOYMENT_CAPABILITIES as readonly string[]).includes(item),
+ !(
+ [
+ ...DEPLOYMENT_CAPABILITIES,
+ ...DOMAIN_CAPABILITIES,
+ ] as readonly string[]
+ ).includes(item),
)
)
invalid();
diff --git a/control-plane/bridge.ts b/control-plane/bridge.ts
index a3d638d..9bbd3cf 100644
--- a/control-plane/bridge.ts
+++ b/control-plane/bridge.ts
@@ -12,8 +12,10 @@ import {
opaque,
LOGIN_PURPOSE,
HEALTH_PURPOSE,
+ DOMAIN_HEALTH_PURPOSE,
type BridgeClaims,
} from "../shared/bridge.ts";
+import { customDomainOrigin } from "../shared/domain-origin.ts";
import type { Env } from "./config.ts";
import { OAuthError } from "./errors.ts";
import { authenticatedPrincipal, vault } from "./session.ts";
@@ -21,6 +23,7 @@ import { registryName } from "./installation-registry.ts";
import { unwrap, type Installation } from "./installation-metadata.ts";
import type { BridgeRequest } from "./vault.ts";
import { beginOAuth, form, privateResponse } from "./http.ts";
+import { domainJson } from "./domain-api.ts";
const denied = () => new OAuthError("forbidden");
const registry = (env: Env, subject: string) =>
@@ -32,6 +35,63 @@ async function owned(env: Env, subject: string, id: string) {
throw denied();
return record;
}
+async function allowedAudience(
+ env: Env,
+ subject: string,
+ id: string,
+ requested?: string,
+) {
+ const record = await owned(env, subject, id);
+ const management = record.resources.runtimeOrigin!;
+ const audience = requested ?? management;
+ if (audience === management) return { record, audience };
+ if (customDomainOrigin(audience) !== audience) throw denied();
+ const domain = unwrap(await registry(env, subject).getDomain(subject, id));
+ if (!domain || domain.status !== "active" || domain.origin !== audience)
+ throw denied();
+ return { record, audience };
+}
+
+export async function healthDomain(
+ env: Env,
+ record: Installation,
+ origin: string,
+ network: typeof fetch = fetch,
+) {
+ if (customDomainOrigin(origin) !== origin) throw denied();
+ const state = random();
+ const challenge = random();
+ const assertion = await signBridgeAssertion(env, {
+ aud: origin,
+ sub: record.ownerSubject,
+ installationId: record.installationId,
+ purpose: DOMAIN_HEALTH_PURPOSE,
+ state,
+ challenge,
+ });
+ const response = await network(
+ new Request(new URL("/auth/domain-health", origin), {
+ method: "POST",
+ redirect: "manual",
+ signal: AbortSignal.timeout(10_000),
+ headers: { Authorization: `Bearer ${assertion}` },
+ }),
+ );
+ if (!response.ok || Number(response.headers.get("Content-Length")) > 4096)
+ throw denied();
+ const result = (await domainJson(response)) as Record;
+ const expected = {
+ installationId: record.installationId,
+ origin,
+ state,
+ challenge,
+ };
+ if (
+ Object.keys(result).sort().join() !== Object.keys(expected).sort().join() ||
+ Object.entries(expected).some(([key, value]) => result[key] !== value)
+ )
+ throw denied();
+}
export async function signBridgeAssertion(
env: Env,
claims: Omit,
@@ -92,15 +152,20 @@ export async function signBridgeAssertion(
}
async function issueCode(env: Env, subject: string, input: BridgeRequest) {
if (input.expiresAt <= Date.now()) throw denied();
- const record = await owned(env, subject, input.installationId);
+ const { audience } = await allowedAudience(
+ env,
+ subject,
+ input.installationId,
+ input.audience,
+ );
const code = random();
await vault(env, "code", await hash(code)).createCode({
...input,
subject,
- audience: record.resources.runtimeOrigin!,
+ audience,
expiresAt: Date.now() + 60_000,
});
- const callback = new URL("/auth/callback", record.resources.runtimeOrigin!);
+ const callback = new URL("/auth/callback", audience);
callback.search = new URLSearchParams({
code,
state: input.state,
@@ -144,11 +209,10 @@ export async function handleBridge(
request.headers.get("Sec-Fetch-Mode") !== "navigate")
)
throw denied();
- const input = exact(url.searchParams, [
- "installationId",
- "state",
- "challenge",
- ]);
+ const names = url.searchParams.has("audience")
+ ? ["installationId", "state", "challenge", "audience"]
+ : ["installationId", "state", "challenge"];
+ const input = exact(url.searchParams, names);
if (
!/^[a-f0-9]{32}$/.test(input.installationId) ||
!opaque(input.state) ||
@@ -213,8 +277,12 @@ export async function handleBridge(
challenge,
});
if (!code) throw denied();
- const record = await owned(env, code.subject, code.installationId);
- if (record.resources.runtimeOrigin !== code.audience) throw denied();
+ await allowedAudience(
+ env,
+ code.subject,
+ code.installationId,
+ code.audience,
+ );
const assertion = await signBridgeAssertion(env, {
aud: code.audience,
sub: code.subject,
diff --git a/control-plane/domain-api.ts b/control-plane/domain-api.ts
new file mode 100644
index 0000000..1d6e0ba
--- /dev/null
+++ b/control-plane/domain-api.ts
@@ -0,0 +1,172 @@
+import type { Installation } from "./installation-metadata.ts";
+import { domainFail, type DomainRecord } from "./domain-metadata.ts";
+
+const identifier = (value: unknown): value is string =>
+ typeof value === "string" && /^[a-f0-9]{32}$/.test(value);
+export async function domainJson(response: Response): Promise {
+ const reader = response.body?.getReader();
+ if (!reader) domainFail("temporarily_unavailable");
+ let text = "";
+ let size = 0;
+ const decoder = new TextDecoder();
+ while (true) {
+ const chunk = await reader.read();
+ if (chunk.done) break;
+ size += chunk.value.byteLength;
+ if (size > 128 * 1024) {
+ await reader.cancel();
+ domainFail("temporarily_unavailable");
+ }
+ text += decoder.decode(chunk.value, { stream: true });
+ }
+ try {
+ return JSON.parse(text + decoder.decode());
+ } catch {
+ return domainFail("temporarily_unavailable");
+ }
+}
+
+// Fixed provider endpoints; the token never leaves this operation's call stack.
+export class DomainAPI {
+ constructor(
+ private readonly token: string,
+ private readonly record: Installation,
+ private readonly network: typeof fetch = fetch,
+ ) {}
+ private async request(
+ path: string,
+ method = "GET",
+ body?: unknown,
+ missing = false,
+ ) {
+ const network = this.network;
+ let response;
+ try {
+ response = await network(`https://api.cloudflare.com/client/v4/${path}`, {
+ method,
+ redirect: "manual",
+ signal: AbortSignal.timeout(15_000),
+ headers: {
+ Authorization: `Bearer ${this.token}`,
+ ...(body === undefined ? {} : { "Content-Type": "application/json" }),
+ },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+ } catch {
+ return domainFail("temporarily_unavailable");
+ }
+ if (response.status === 401) domainFail("reauthorization_required");
+ if (response.status === 403) domainFail("account_denied");
+ if (response.status === 404 && missing) return null;
+ if ([400, 409].includes(response.status)) domainFail("resource_conflict");
+ if (!response.ok) domainFail("temporarily_unavailable");
+ const envelope = await domainJson(response);
+ if (envelope?.success !== true || !("result" in envelope))
+ domainFail("temporarily_unavailable");
+ return envelope;
+ }
+ async zones() {
+ const zones: { id: string; name: string }[] = [];
+ for (let page = 1; page <= 20; page++) {
+ const result = await this.request(
+ `zones?account.id=${this.record.accountId}&status=active&per_page=50&page=${page}`,
+ );
+ if (!Array.isArray(result.result)) domainFail("temporarily_unavailable");
+ for (const zone of result.result) {
+ if (
+ !identifier(zone.id) ||
+ typeof zone.name !== "string" ||
+ zone.account?.id !== this.record.accountId ||
+ zone.status !== "active"
+ )
+ domainFail("account_denied");
+ zones.push({ id: zone.id, name: zone.name });
+ }
+ if (!result.result_info || result.result_info.total_pages <= page)
+ return zones;
+ }
+ return domainFail("temporarily_unavailable");
+ }
+ async zone(zoneId: string, hostname: string) {
+ const { result: zone } = await this.request(`zones/${zoneId}`);
+ if (
+ zone.id !== zoneId ||
+ zone.account?.id !== this.record.accountId ||
+ zone.status !== "active"
+ )
+ domainFail("account_denied");
+ if (
+ typeof zone.name !== "string" ||
+ !(hostname === zone.name || hostname.endsWith(`.${zone.name}`))
+ )
+ domainFail("invalid_hostname");
+ }
+ private get domainsPath() {
+ return `accounts/${this.record.accountId}/workers/domains`;
+ }
+ private verify(value: any, domain: DomainRecord) {
+ if (
+ !identifier(value?.id) ||
+ value.hostname !== domain.hostname ||
+ value.zone_id !== domain.zoneId ||
+ value.service !== this.record.resources.workerName ||
+ (value.environment && value.environment !== "production")
+ )
+ domainFail("resource_conflict");
+ return value.id as string;
+ }
+ async find(domain: DomainRecord): Promise {
+ const envelope = await this.request(
+ `${this.domainsPath}?hostname=${encodeURIComponent(domain.hostname)}`,
+ );
+ if (
+ !Array.isArray(envelope.result) ||
+ envelope.result.length > 1 ||
+ (envelope.result_info?.total_pages ?? 1) > 1
+ )
+ domainFail("resource_conflict");
+ if (!envelope.result.length) return null;
+ return this.verify(envelope.result[0], domain);
+ }
+ async preflight(domain: DomainRecord) {
+ await this.zone(domain.zoneId, domain.hostname);
+ if (await this.find(domain)) domainFail("resource_conflict");
+ const { result } = await this.request(
+ `zones/${domain.zoneId}/dns_records?name=${encodeURIComponent(domain.hostname)}&per_page=1`,
+ );
+ if (!Array.isArray(result) || result.length)
+ domainFail("resource_conflict");
+ }
+ async attach(domain: DomainRecord) {
+ if (!domain.writeIntent || domain.domainId) domainFail("resource_conflict");
+ // Wrangler's native records interface provides commit-time conflict guards.
+ // Preserve domains outside this request, and NEVER opt into DNS/Worker takeover.
+ await this.request(
+ `accounts/${this.record.accountId}/workers/scripts/${this.record.resources.workerName}/domains/records`,
+ "PUT",
+ {
+ override_scope: false,
+ override_existing_origin: false,
+ override_existing_dns_record: false,
+ origins: [{ hostname: domain.hostname, zone_id: domain.zoneId }],
+ },
+ );
+ }
+ async remove(domain: DomainRecord) {
+ // No write was attempted: abandoning a failed preflight must not detach
+ // someone else's pre-existing hostname, even if it targets this Worker.
+ if (!domain.writeIntent) return;
+ if (!domain.domainId) domainFail("attachment_outcome_unknown");
+ const found = await this.find(domain);
+ if (!found) return;
+ if (domain.domainId && found !== domain.domainId)
+ domainFail("resource_conflict");
+ await this.request(
+ `${this.domainsPath}/${found}`,
+ "DELETE",
+ undefined,
+ true,
+ );
+ if (await this.find(domain)) domainFail("temporarily_unavailable");
+ }
+}
diff --git a/control-plane/domain-metadata.ts b/control-plane/domain-metadata.ts
new file mode 100644
index 0000000..7c7b2b3
--- /dev/null
+++ b/control-plane/domain-metadata.ts
@@ -0,0 +1,63 @@
+import { z } from "zod";
+import { customDomainOrigin } from "../shared/domain-origin.ts";
+import { installationId } from "./installation-metadata.ts";
+
+export const domainErrors = z.enum([
+ "resource_conflict",
+ "reauthorization_required",
+ "temporarily_unavailable",
+ "setup_required",
+ "account_denied",
+ "invalid_hostname",
+ "https_pending",
+ "attachment_outcome_unknown",
+]);
+export type DomainErrorCode = z.infer;
+export class DomainError extends Error {
+ constructor(readonly code: DomainErrorCode) {
+ super(code);
+ }
+}
+export function domainFail(code: DomainErrorCode): never {
+ throw new DomainError(code);
+}
+export const domainOrigin = z
+ .string()
+ .refine((value) => customDomainOrigin(value) === value);
+export const domainRecord = z
+ .strictObject({
+ revision: z.number().int().positive(),
+ deadline: z.number().int().positive(),
+ origin: domainOrigin.nullable(),
+ hostname: z
+ .string()
+ .refine((value) => customDomainOrigin(`https://${value}`) !== null),
+ zoneId: installationId,
+ domainId: z
+ .string()
+ .regex(/^[a-f0-9]{32}$/)
+ .nullable(),
+ operationId: installationId,
+ status: z.enum(["connecting", "active", "removing", "removed", "failed"]),
+ action: z.enum(["attach", "remove"]),
+ errorCode: domainErrors.nullable(),
+ // At most one PUT may follow this commit. An uncertain outcome permits
+ // observation only, never another PUT or an absence-based removal.
+ writeIntent: z.boolean(),
+ })
+ .refine(
+ (value) =>
+ value.origin ===
+ (value.action === "attach" ? `https://${value.hostname}` : null),
+ );
+export type DomainRecord = z.infer;
+export const domainStart = z.discriminatedUnion("action", [
+ z.strictObject({
+ action: z.literal("attach"),
+ zoneId: installationId,
+ origin: domainOrigin,
+ }),
+ z.strictObject({ action: z.literal("remove") }),
+ z.strictObject({ action: z.literal("retry") }),
+]);
+export type DomainStart = z.infer;
diff --git a/control-plane/domain-setup.ts b/control-plane/domain-setup.ts
new file mode 100644
index 0000000..a668a82
--- /dev/null
+++ b/control-plane/domain-setup.ts
@@ -0,0 +1,228 @@
+import { customDomainOrigin } from "../shared/domain-origin.ts";
+import { DOMAIN_CONFIGURATION_PURPOSE } from "../shared/bridge.ts";
+import { configuration, type Env } from "./config.ts";
+import { DomainAPI, domainJson } from "./domain-api.ts";
+import {
+ domainFail,
+ type DomainStart,
+ type DomainRecord,
+} from "./domain-metadata.ts";
+import {
+ authenticatedPrincipal,
+ authorizedGrant,
+ grantedAccounts,
+ vault,
+} from "./session.ts";
+import { ownedInstallation, installationRegistry } from "./installations.ts";
+import {
+ installationId,
+ parse,
+ unwrap,
+ InstallationError,
+ type Installation,
+} from "./installation-metadata.ts";
+import { form, privateResponse } from "./http.ts";
+import { signBridgeAssertion } from "./bridge.ts";
+
+export function requireDomainCapability(env: Env, scopes: string[]) {
+ const manifest = configuration(env).oauthCapabilities!;
+ for (const capability of ["domain-zones", "domain-routing"] as const) {
+ const required = manifest.scopes.filter((scope) =>
+ scope.capabilities.includes(capability),
+ );
+ if (!required.length) domainFail("setup_required");
+ if (required.some((scope) => !scopes.includes(scope.id)))
+ domainFail("reauthorization_required");
+ }
+}
+export async function configureRuntimeDomain(
+ env: Env,
+ record: Installation,
+ domain: DomainRecord,
+ network: typeof fetch = fetch,
+) {
+ const assertion = await signBridgeAssertion(env, {
+ aud: record.resources.runtimeOrigin!,
+ sub: record.ownerSubject,
+ installationId: record.installationId,
+ purpose: DOMAIN_CONFIGURATION_PURPOSE,
+ state: String(domain.revision),
+ challenge: domain.origin ?? "",
+ });
+ const response = await network(
+ new URL("/auth/domain-configuration", record.resources.runtimeOrigin!),
+ {
+ method: "POST",
+ redirect: "manual",
+ signal: AbortSignal.timeout(15_000),
+ headers: { Authorization: `Bearer ${assertion}` },
+ },
+ );
+ if (!response.ok)
+ domainFail(
+ response.status === 404 ? "setup_required" : "temporarily_unavailable",
+ );
+ const result = await domainJson(response);
+ if (
+ result?.revision !== domain.revision ||
+ result?.origin !== domain.origin ||
+ Object.keys(result).sort().join() !== "origin,revision"
+ )
+ domainFail("temporarily_unavailable");
+}
+
+// Called only after the installation router's exact-Origin mutation guard.
+export async function setupDomain(
+ request: Request,
+ env: Env,
+ id: string,
+ action: string | undefined,
+ network: typeof fetch,
+) {
+ const record = await ownedInstallation(request, env, id);
+ if (!record.installedRelease || !record.resources.runtimeOrigin)
+ domainFail("setup_required");
+ const principal = await authenticatedPrincipal(request, env);
+ const registry = installationRegistry(env, principal.subject);
+ const json = (data: unknown, status = 200) =>
+ privateResponse(Response.json(data, { status }));
+ if (request.method === "GET" && !action)
+ return json({
+ installationId: id,
+ accountId: record.accountId,
+ runtimeOrigin: record.resources.runtimeOrigin,
+ domain: unwrap(await registry.getDomain(principal.subject, id)),
+ });
+ const grant = await authorizedGrant(env, principal);
+ requireDomainCapability(env, grant.scopes);
+ if (
+ !(await grantedAccounts(env, principal, network)).some(
+ (account) => account.id === record.accountId,
+ )
+ )
+ domainFail("account_denied");
+ const api = new DomainAPI(grant.accessToken, record, network);
+ if (request.method === "GET" && action === "zones")
+ return json({ zones: await api.zones() });
+ if (
+ request.method !== "POST" ||
+ (action && !["remove", "retry"].includes(action))
+ )
+ throw new InstallationError("invalid_metadata");
+ const input = await form(request);
+ const keys = action ? ["requestId"] : ["requestId", "zoneId", "hostname"];
+ if (
+ [...input.keys()].length !== keys.length ||
+ keys.some((key) => input.getAll(key).length !== 1)
+ )
+ throw new InstallationError("invalid_metadata");
+ const requestId = parse(installationId, input.get("requestId"));
+ let intent: DomainStart;
+ if (action) intent = { action: action as "remove" | "retry" };
+ else {
+ const hostname = input.get("hostname")!.trim().toLowerCase();
+ const origin = customDomainOrigin(`https://${hostname}`);
+ if (!origin) domainFail("invalid_hostname");
+ const zoneId = parse(installationId, input.get("zoneId"));
+ await api.zone(zoneId, hostname);
+ intent = { action: "attach", origin, zoneId };
+ }
+ if (grant.expiresAt < Date.now() + 120_000)
+ domainFail("reauthorization_required");
+ if (
+ action &&
+ !unwrap(await registry.domainReplay(principal.subject, id, requestId))
+ ) {
+ const previous = unwrap(await registry.getDomain(principal.subject, id));
+ if (
+ action === "remove" &&
+ previous?.action === "attach" &&
+ (previous.status === "connecting" ||
+ (previous.writeIntent && !previous.domainId))
+ )
+ domainFail("attachment_outcome_unknown");
+ if (previous && ["connecting", "removing"].includes(previous.status)) {
+ // Stop the old execution before a new intent can supersede it. Remote
+ // writes already in flight are reconciled using the preserved write intent.
+ try {
+ const workflow = await env.INSTALLATION_WORKFLOW.get(
+ previous.operationId,
+ );
+ await workflow.terminate();
+ if ((await workflow.status()).status !== "terminated")
+ domainFail("temporarily_unavailable");
+ } catch (error) {
+ if (
+ ![
+ "instance.not_found",
+ "Error: instance.not_found",
+ "(instance.not_found) Instance not found",
+ ].includes((error as Error)?.message)
+ )
+ domainFail("temporarily_unavailable");
+ }
+ const current = unwrap(await registry.getDomain(principal.subject, id));
+ if (current?.operationId !== previous.operationId)
+ throw new InstallationError("installation_conflict");
+ if (["connecting", "removing"].includes(current.status))
+ unwrap(
+ await registry.updateDomain(
+ principal.subject,
+ id,
+ current.operationId,
+ current.revision,
+ {
+ status: "failed",
+ errorCode: "temporarily_unavailable",
+ domainId: current.domainId,
+ writeIntent: current.writeIntent,
+ },
+ ),
+ );
+ else if (
+ action === "retry" &&
+ ["active", "removed"].includes(current.status)
+ )
+ return json({ domain: current });
+ }
+ }
+ const domain = unwrap(
+ await registry.startDomain(
+ principal.subject,
+ id,
+ requestId,
+ intent,
+ Math.min(Date.now() + 20 * 60_000, grant.expiresAt - 30_000),
+ ),
+ );
+ if (!["connecting", "removing"].includes(domain.status))
+ return json({ domain });
+ // The same reservation repairs ambiguous vault or Workflow creation replies.
+ await vault(env, "operation", domain.operationId).createOperation({
+ subject: principal.subject,
+ accountId: record.accountId,
+ installationId: id,
+ operationId: domain.operationId,
+ grantRef: principal.grantRef,
+ expiresAt: domain.deadline,
+ bootstrapSecret: null,
+ });
+ try {
+ await env.INSTALLATION_WORKFLOW.create({
+ id: domain.operationId,
+ params: {
+ ownerSubject: principal.subject,
+ installationId: id,
+ operationId: domain.operationId,
+ kind: "domain",
+ },
+ });
+ } catch {
+ try {
+ await (await env.INSTALLATION_WORKFLOW.get(domain.operationId)).status();
+ } catch {
+ domainFail("temporarily_unavailable");
+ }
+ }
+ return json({ domain }, 202);
+}
diff --git a/control-plane/domain-workflow.ts b/control-plane/domain-workflow.ts
new file mode 100644
index 0000000..fe2fc68
--- /dev/null
+++ b/control-plane/domain-workflow.ts
@@ -0,0 +1,224 @@
+import type { WorkflowStep } from "cloudflare:workers";
+import type { Env } from "./config.ts";
+import type { InstallationParams } from "./installation-workflow.ts";
+import { installationRegistry } from "./installations.ts";
+import { unwrap } from "./installation-metadata.ts";
+import { DomainAPI } from "./domain-api.ts";
+import {
+ DomainError,
+ domainFail,
+ type DomainRecord,
+ type DomainErrorCode,
+} from "./domain-metadata.ts";
+import {
+ configureRuntimeDomain,
+ requireDomainCapability,
+} from "./domain-setup.ts";
+import { healthDomain } from "./bridge.ts";
+import { vault } from "./session.ts";
+
+export async function runDomainWorkflow(
+ env: Env,
+ params: InstallationParams,
+ step: WorkflowStep,
+ network: typeof fetch = fetch,
+) {
+ const registry = installationRegistry(env, params.ownerSubject);
+ async function context() {
+ const record = unwrap(
+ await registry.get(params.ownerSubject, params.installationId),
+ );
+ const domain = unwrap(
+ await registry.getDomain(params.ownerSubject, params.installationId),
+ );
+ if (
+ !record ||
+ !domain ||
+ domain.operationId !== params.operationId ||
+ !["connecting", "removing"].includes(domain.status)
+ )
+ domainFail("resource_conflict");
+ const authorization = await vault(
+ env,
+ "operation",
+ params.operationId,
+ ).operation({
+ subject: params.ownerSubject,
+ accountId: record.accountId,
+ installationId: params.installationId,
+ operationId: params.operationId,
+ });
+ if (!authorization || authorization.expiresAt <= Date.now() + 15_000)
+ domainFail("reauthorization_required");
+ const grant = await vault(env, "grant", authorization.grantRef).grant(
+ params.ownerSubject,
+ );
+ if (!grant) domainFail("reauthorization_required");
+ requireDomainCapability(env, grant.scopes);
+ return {
+ record,
+ domain,
+ api: new DomainAPI(grant.accessToken, record, network),
+ };
+ }
+ async function save(
+ domain: DomainRecord,
+ changes: Partial<
+ Pick
+ >,
+ ) {
+ return unwrap(
+ await registry.updateDomain(
+ params.ownerSubject,
+ params.installationId,
+ params.operationId,
+ domain.revision,
+ {
+ status: domain.status,
+ errorCode: domain.errorCode,
+ domainId: domain.domainId,
+ writeIntent: domain.writeIntent,
+ ...changes,
+ },
+ ),
+ );
+ }
+ async function execute(
+ name: string,
+ action: (value: Awaited>) => Promise,
+ ) {
+ const result = await step.do(
+ name,
+ {
+ retries: { limit: 3, delay: "5 seconds", backoff: "exponential" },
+ timeout: "2 minutes",
+ },
+ async () => {
+ try {
+ await action(await context());
+ return { error: null };
+ } catch (error) {
+ const code =
+ error instanceof DomainError
+ ? error.code
+ : "temporarily_unavailable";
+ if (code === "temporarily_unavailable") throw new Error(code);
+ return { error: code };
+ }
+ },
+ );
+ if (result.error) domainFail(result.error as DomainErrorCode);
+ }
+ try {
+ const initial = unwrap(
+ await registry.getDomain(params.ownerSubject, params.installationId),
+ );
+ if (!initial || initial.operationId !== params.operationId) return;
+ if (["active", "removed"].includes(initial.status)) return;
+ if (initial.action === "attach") {
+ await execute("attach custom domain", async ({ domain: saved, api }) => {
+ let domain = saved;
+ if (!domain.writeIntent) {
+ await api.preflight(domain);
+ domain = await save(domain, { writeIntent: true });
+ try {
+ await api.attach(domain);
+ } catch (error) {
+ // A definitive provider rejection proves no write was accepted.
+ // Timeouts, 5xx and lost replies NEVER reopen permission to PUT.
+ if (
+ error instanceof DomainError &&
+ [
+ "resource_conflict",
+ "account_denied",
+ "reauthorization_required",
+ ].includes(error.code)
+ )
+ await save(domain, {
+ writeIntent: false,
+ status: "failed",
+ errorCode: error.code,
+ });
+ throw error;
+ }
+ }
+ const domainId = await api.find(domain);
+ if (!domainId) domainFail("attachment_outcome_unknown");
+ if (domain.domainId && domain.domainId !== domainId)
+ domainFail("resource_conflict");
+ if (domain.domainId !== domainId) await save(domain, { domainId });
+ });
+ await execute(
+ "authorize domain in runtime",
+ async ({ record, domain }) => {
+ await configureRuntimeDomain(env, record, domain, network);
+ },
+ );
+ // TLS readiness is observed, not inferred from attachment or a DNS record.
+ let ready = false;
+ for (let attempt = 0; attempt < 30; attempt++) {
+ const observation = await step.do(
+ `check domain HTTPS ${attempt}`,
+ { retries: { limit: 0, delay: "1 second" }, timeout: "30 seconds" },
+ async () => {
+ try {
+ const { record, domain, api } = await context();
+ if ((await api.find(domain)) !== domain.domainId)
+ domainFail("resource_conflict");
+ try {
+ await healthDomain(env, record, domain.origin!, network);
+ return { ready: true, error: null };
+ } catch {
+ return { ready: false, error: null };
+ }
+ } catch (error) {
+ return {
+ ready: false,
+ error:
+ error instanceof DomainError
+ ? error.code
+ : ("temporarily_unavailable" as const),
+ };
+ }
+ },
+ );
+ if (observation.error) domainFail(observation.error);
+ ready = observation.ready;
+ if (ready) break;
+ await step.sleep(`wait for domain HTTPS ${attempt}`, "30 seconds");
+ }
+ if (!ready) domainFail("https_pending");
+ await execute("activate custom domain", async ({ domain, api }) => {
+ if ((await api.find(domain)) !== domain.domainId)
+ domainFail("resource_conflict");
+ await save(domain, { status: "active" });
+ });
+ } else {
+ await execute("revoke runtime domain", async ({ record, domain }) => {
+ await configureRuntimeDomain(env, record, domain, network);
+ });
+ await execute("detach custom domain", async ({ domain, api }) => {
+ await api.remove(domain);
+ });
+ await execute("finish domain removal", async ({ domain }) => {
+ await save(domain, { status: "removed" });
+ });
+ }
+ } catch (error) {
+ const code =
+ error instanceof DomainError ? error.code : "temporarily_unavailable";
+ await step.do("record domain failure", async () => {
+ const domain = unwrap(
+ await registry.getDomain(params.ownerSubject, params.installationId),
+ );
+ if (
+ domain?.operationId === params.operationId &&
+ ["connecting", "removing"].includes(domain.status)
+ )
+ await save(domain, { status: "failed", errorCode: code });
+ });
+ }
+ await step.do("retire domain authorization", async () => {
+ await vault(env, "operation", params.operationId).destroy();
+ });
+}
diff --git a/control-plane/http.ts b/control-plane/http.ts
index 3808755..0319724 100644
--- a/control-plane/http.ts
+++ b/control-plane/http.ts
@@ -142,18 +142,30 @@ export async function handleOAuth(
if (url.pathname === "/auth/start" && request.method === "POST") {
const input = await form(request);
const providerInstallationId = input.get("providerInstallationId");
+ const domainInstallationId = input.get("domainInstallationId");
// Only a fixed local setup route may survive reconnect; never accept an
// arbitrary return URL or a caller-supplied account/owner.
if (
[...input.keys()].some(
- (key) => !["returnTo", "providerInstallationId"].includes(key),
+ (key) =>
+ ![
+ "returnTo",
+ "providerInstallationId",
+ "domainInstallationId",
+ ].includes(key),
) ||
input.getAll("returnTo").length > 1 ||
(input.has("returnTo") && input.get("returnTo") !== "/connect") ||
input.getAll("providerInstallationId").length > 1 ||
+ input.getAll("domainInstallationId").length > 1 ||
(providerInstallationId !== null &&
(!/^[a-f0-9]{32}$/.test(providerInstallationId) ||
- input.has("returnTo")))
+ input.has("returnTo") ||
+ input.has("domainInstallationId"))) ||
+ (domainInstallationId !== null &&
+ (!/^[a-f0-9]{32}$/.test(domainInstallationId) ||
+ input.has("returnTo") ||
+ input.has("providerInstallationId")))
)
throw new OAuthError("invalid_request");
return beginOAuth(
@@ -161,6 +173,7 @@ export async function handleOAuth(
env,
undefined,
providerInstallationId ?? undefined,
+ domainInstallationId ?? undefined,
);
}
if (url.pathname === "/auth/callback" && request.method === "GET") {
@@ -268,7 +281,9 @@ export async function handleOAuth(
)
: transaction.providerInstallationId
? `/connect?enableProvider=openrouter&installationId=${transaction.providerInstallationId}`
- : transaction.returnTo;
+ : transaction.domainInstallationId
+ ? `/connect?configureDomain=${transaction.domainInstallationId}`
+ : transaction.returnTo;
return redirect(destination, [
cookie(SESSION_COOKIE, sessionRef, 8 * 60 * 60),
cookie(TRANSACTION_COOKIE, "", 0),
@@ -340,6 +355,7 @@ export async function beginOAuth(
env: Env,
bridge?: BridgeRequest,
providerInstallationId?: string,
+ domainInstallationId?: string,
) {
const config = configuration(env);
const state = random();
@@ -357,6 +373,7 @@ export async function beginOAuth(
previousSession: readCookie(request, SESSION_COOKIE),
returnTo: "/connect",
...(providerInstallationId ? { providerInstallationId } : {}),
+ ...(domainInstallationId ? { domainInstallationId } : {}),
...(continuationRef ? { bridgeContinuation: continuationRef } : {}),
expiresAt: Date.now() + 10 * 60_000,
});
diff --git a/control-plane/installation-registry.ts b/control-plane/installation-registry.ts
index d9a4268..4b1a087 100644
--- a/control-plane/installation-registry.ts
+++ b/control-plane/installation-registry.ts
@@ -1,6 +1,12 @@
import { DurableObject } from "cloudflare:workers";
import { z } from "zod";
import type { Env } from "./config.ts";
+import {
+ domainRecord,
+ domainStart,
+ type DomainStart,
+ type DomainRecord,
+} from "./domain-metadata.ts";
import {
installationChanges,
installationId,
@@ -118,6 +124,201 @@ export class InstallationRegistry extends DurableObject {
return value === undefined ? null : this.#record(value, subject, id);
});
}
+ getDomain(subject: string, id: string) {
+ return registryResult(async () => {
+ this.#owner(subject);
+ parse(installationId, id);
+ this.#record(await this.ctx.storage.get(rowKey(id)), subject, id);
+ const value = await this.ctx.storage.get(`domain:${id}`);
+ return value === undefined ? null : parse(domainRecord, value);
+ });
+ }
+ domainReplay(subject: string, id: string, requestId: string) {
+ return registryResult(async () => {
+ this.#owner(subject);
+ parse(installationId, id);
+ parse(installationId, requestId);
+ this.#record(await this.ctx.storage.get(rowKey(id)), subject, id);
+ const value = await this.ctx.storage.get(
+ `domain-request:${id}:${requestId}`,
+ );
+ return value === undefined
+ ? null
+ : parse(
+ z.strictObject({
+ intent: domainStart,
+ operationId: installationId,
+ }),
+ value,
+ ).operationId;
+ });
+ }
+ startDomain(
+ subject: string,
+ id: string,
+ requestId: string,
+ input: DomainStart,
+ deadline = Date.now() + 20 * 60_000,
+ ) {
+ return registryResult(async () => {
+ this.#owner(subject);
+ parse(installationId, id);
+ parse(installationId, requestId);
+ const intent = parse(domainStart, input);
+ parse(
+ z
+ .number()
+ .int()
+ .min(Date.now() + 30_000)
+ .max(Date.now() + 30 * 60_000),
+ deadline,
+ );
+ return this.ctx.storage.transaction(async (tx) => {
+ const installation = this.#record(
+ await tx.get(rowKey(id)),
+ subject,
+ id,
+ );
+ if (!installation.installedRelease || installation.status !== "ready")
+ reject("installation_conflict");
+ const key = `domain:${id}`;
+ const saved = await tx.get(key);
+ const current = saved === undefined ? null : parse(domainRecord, saved);
+ const replayKey = `domain-request:${id}:${requestId}`;
+ const replay = await tx.get<{
+ intent: DomainStart;
+ operationId: string;
+ }>(replayKey);
+ if (replay) {
+ if (
+ JSON.stringify(replay.intent) !== JSON.stringify(intent) ||
+ current?.operationId !== replay.operationId
+ )
+ reject("installation_conflict");
+ return current!;
+ }
+ if (current && ["connecting", "removing"].includes(current.status))
+ reject("installation_conflict");
+ if (
+ intent.action === "attach" &&
+ current &&
+ current.status !== "removed"
+ )
+ reject("installation_conflict");
+ if (intent.action === "retry" && current?.status !== "failed")
+ reject("installation_conflict");
+ if (
+ intent.action === "remove" &&
+ (!current ||
+ current.status === "removed" ||
+ (current.writeIntent && !current.domainId))
+ )
+ reject("installation_conflict");
+ const action =
+ intent.action === "retry" ? current!.action : intent.action;
+ const value = parse(domainRecord, {
+ revision: (current?.revision ?? 0) + 1,
+ deadline,
+ origin:
+ action === "remove"
+ ? null
+ : intent.action === "attach"
+ ? intent.origin
+ : current!.origin,
+ hostname:
+ intent.action === "attach"
+ ? new URL(intent.origin).hostname
+ : current!.hostname,
+ zoneId: intent.action === "attach" ? intent.zoneId : current!.zoneId,
+ domainId: intent.action === "attach" ? null : current!.domainId,
+ operationId: crypto.randomUUID().replaceAll("-", ""),
+ status: action === "attach" ? "connecting" : "removing",
+ action,
+ errorCode: null,
+ writeIntent:
+ intent.action === "attach" ? false : current!.writeIntent,
+ });
+ await tx.put({
+ [key]: value,
+ [replayKey]: { intent, operationId: value.operationId },
+ });
+ return value;
+ });
+ });
+ }
+ updateDomain(
+ subject: string,
+ id: string,
+ operationId: string,
+ revision: number,
+ changes: Pick<
+ DomainRecord,
+ "status" | "errorCode" | "domainId" | "writeIntent"
+ >,
+ ) {
+ return registryResult(async () => {
+ this.#owner(subject);
+ parse(installationId, id);
+ parse(installationId, operationId);
+ return this.ctx.storage.transaction(async (tx) => {
+ this.#record(await tx.get(rowKey(id)), subject, id);
+ const key = `domain:${id}`;
+ const current = parse(domainRecord, await tx.get(key));
+ if (
+ current.operationId !== operationId ||
+ current.revision !== revision ||
+ !["connecting", "removing"].includes(current.status)
+ )
+ reject("installation_conflict");
+ const allowed = parse(
+ z.strictObject({
+ status: z.enum([
+ "connecting",
+ "active",
+ "removing",
+ "removed",
+ "failed",
+ ]),
+ errorCode: domainRecord.shape.errorCode,
+ domainId: z
+ .string()
+ .regex(/^[a-f0-9]{32}$/)
+ .nullable(),
+ writeIntent: z.boolean(),
+ }),
+ changes,
+ );
+ if (
+ (current.writeIntent &&
+ !allowed.writeIntent &&
+ !(
+ current.domainId === null &&
+ allowed.status === "failed" &&
+ [
+ "resource_conflict",
+ "account_denied",
+ "reauthorization_required",
+ ].includes(allowed.errorCode ?? "")
+ )) ||
+ (current.domainId && current.domainId !== allowed.domainId) ||
+ (current.action === "attach" &&
+ ["removing", "removed"].includes(allowed.status)) ||
+ (current.action === "remove" &&
+ ["connecting", "active"].includes(allowed.status)) ||
+ (allowed.status === "active" &&
+ (!allowed.domainId || !allowed.writeIntent))
+ )
+ reject("installation_conflict");
+ const next = parse(domainRecord, {
+ ...current,
+ ...allowed,
+ revision: current.revision + 1,
+ });
+ await tx.put(key, next);
+ return next;
+ });
+ });
+ }
list(subject: string, cursor: string | null = null) {
return registryResult(async (): Promise => {
this.#owner(subject);
@@ -181,6 +382,14 @@ export class InstallationRegistry extends DurableObject {
return this.ctx.storage.transaction(async (tx) => {
const current = this.#record(await tx.get(rowKey(id)), subject, id);
if (current.accountId !== accountId) reject("installation_conflict");
+ const domain = await tx.get(`domain:${id}`);
+ if (
+ domain &&
+ ["connecting", "removing"].includes(
+ parse(domainRecord, domain).status,
+ )
+ )
+ reject("installation_conflict");
const replayKey = `operation-request:${requestId}`;
const replayValue = await tx.get(replayKey);
const replay =
diff --git a/control-plane/installation-workflow.ts b/control-plane/installation-workflow.ts
index ab62e62..5484f68 100644
--- a/control-plane/installation-workflow.ts
+++ b/control-plane/installation-workflow.ts
@@ -25,8 +25,10 @@ import { vault } from "./session.ts";
import { verifyUpgradeIdentity } from "./artifact.ts";
import type { Artifact } from "./artifact-types.ts";
import { healthInstallation } from "./bridge.ts";
+import { runDomainWorkflow } from "./domain-workflow.ts";
export interface InstallationParams {
+ kind?: "domain";
ownerSubject: string;
installationId: string;
operationId: string;
@@ -57,6 +59,8 @@ export class InstallationWorkflow extends WorkflowEntrypoint<
}
async run(event: WorkflowEvent, step: WorkflowStep) {
const params = event.payload;
+ if (params.kind === "domain")
+ return runDomainWorkflow(this.env, params, step, this.network());
const registry = installationRegistry(this.env, params.ownerSubject);
const binding = (record: Installation) => ({
subject: record.ownerSubject,
diff --git a/control-plane/installations.ts b/control-plane/installations.ts
index efdb3d1..4eaacec 100644
--- a/control-plane/installations.ts
+++ b/control-plane/installations.ts
@@ -18,6 +18,8 @@ import { startInstallation } from "./start-installation.ts";
import { DeploymentError } from "./deployment-errors.ts";
import { customerArtifact } from "./catalog.ts";
import { setupOpenRouter } from "./provider-setup.ts";
+import { setupDomain } from "./domain-setup.ts";
+import { DomainError } from "./domain-metadata.ts";
export const installationRegistry = (env: Env, subject: string) =>
env.INSTALLATIONS.get(env.INSTALLATIONS.idFromName(registryName(subject)));
@@ -74,6 +76,14 @@ export async function handleInstallations(
headers: { "Cache-Control": "public, max-age=60" },
});
}
+ const domain =
+ /^\/api\/installations\/([a-f0-9]{32})\/domain(?:\/(zones|remove|retry))?$/.exec(
+ url.pathname,
+ );
+ if (domain) {
+ if (url.search) throw new InstallationError("invalid_metadata");
+ return await setupDomain(request, env, domain[1], domain[2], network);
+ }
const provider =
/^\/api\/installations\/([a-f0-9]{32})\/providers\/openrouter$/.exec(
url.pathname,
@@ -188,6 +198,21 @@ export async function handleInstallations(
}
return json({ error: "not_found" }, 404);
} catch (error) {
+ if (error instanceof DomainError)
+ return json(
+ { error: error.code },
+ error.code === "reauthorization_required"
+ ? 401
+ : error.code === "account_denied"
+ ? 403
+ : error.code === "invalid_hostname"
+ ? 400
+ : ["resource_conflict", "attachment_outcome_unknown"].includes(
+ error.code,
+ )
+ ? 409
+ : 503,
+ );
if (error instanceof DeploymentError)
return json(
{ error: error.code },
diff --git a/control-plane/provider-setup.ts b/control-plane/provider-setup.ts
index 05b6b8d..a67eae7 100644
--- a/control-plane/provider-setup.ts
+++ b/control-plane/provider-setup.ts
@@ -4,7 +4,8 @@ import { configuration, type Env } from "./config.ts";
import { DeploymentAPI } from "./deployment-api.ts";
import { fail } from "./deployment-errors.ts";
import { form, privateResponse } from "./http.ts";
-import { ownedInstallation } from "./installations.ts";
+import { ownedInstallation, installationRegistry } from "./installations.ts";
+import { unwrap } from "./installation-metadata.ts";
import {
authenticatedPrincipal,
authorizedGrant,
@@ -21,12 +22,22 @@ export async function setupOpenRouter(
const record = await ownedInstallation(request, env, installationId);
if (!record.installedRelease || !record.resources.runtimeOrigin)
fail("setup_required");
+ const domain = unwrap(
+ await installationRegistry(env, record.ownerSubject).getDomain(
+ record.ownerSubject,
+ installationId,
+ ),
+ );
+ const publicOrigin =
+ domain?.status === "active"
+ ? domain.origin!
+ : record.resources.runtimeOrigin;
if (request.method === "GET")
return privateResponse(
Response.json({
installationId: record.installationId,
accountId: record.accountId,
- runtimeOrigin: record.resources.runtimeOrigin,
+ runtimeOrigin: publicOrigin,
}),
);
if ([...(await form(request)).keys()].length) fail("setup_required");
@@ -91,7 +102,7 @@ export async function setupOpenRouter(
}
return privateResponse(
Response.json({
- returnTo: new URL("/settings", record.resources.runtimeOrigin).href,
+ returnTo: new URL("/settings", publicOrigin).href,
}),
);
}
diff --git a/control-plane/ui/DomainSetup.tsx b/control-plane/ui/DomainSetup.tsx
new file mode 100644
index 0000000..c563e0f
--- /dev/null
+++ b/control-plane/ui/DomainSetup.tsx
@@ -0,0 +1,487 @@
+import { useEffect, useRef, useState } from "octane";
+import { Button } from "octane-kumo/components/button";
+import { LayerCard } from "octane-kumo/components/layer-card";
+
+type Domain = {
+ revision: number;
+ origin: string | null;
+ hostname: string;
+ zoneId: string;
+ domainId: string | null;
+ operationId: string;
+ status: "connecting" | "active" | "removing" | "removed" | "failed";
+ errorCode: string | null;
+ action: "attach" | "remove";
+};
+type Details = {
+ installationId: string;
+ accountId: string;
+ runtimeOrigin: string;
+ domain: Domain | null;
+};
+type Zone = { id: string; name: string };
+
+const errors: Record = {
+ resource_conflict:
+ "That hostname conflicts with an existing Cloudflare resource. Remove the conflict or choose another hostname, then retry.",
+ reauthorization_required: "Reconnect Cloudflare to continue domain setup.",
+ temporarily_unavailable:
+ "Domain setup is temporarily unavailable. Try again.",
+ setup_required:
+ "Custom-domain authorization is not available yet. The publisher must review the required Cloudflare OAuth scopes.",
+ account_denied:
+ "Your Cloudflare account does not allow this domain operation.",
+ invalid_hostname: "Enter a valid hostname within the selected zone.",
+ https_pending:
+ "Cloudflare is still preparing HTTPS for this hostname. Try again shortly.",
+ attachment_outcome_unknown:
+ "Cloudflare has not confirmed the attachment outcome. Retry connection to check the same hostname. It cannot be marked removed while a write may be pending; your workers.dev address remains available.",
+};
+
+function id() {
+ return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) =>
+ byte.toString(16).padStart(2, "0"),
+ ).join("");
+}
+function validOrigin(value: unknown) {
+ try {
+ if (typeof value !== "string") return false;
+ const url = new URL(value);
+ const local = ["localhost", "127.0.0.1"].includes(url.hostname);
+ return (
+ !url.username &&
+ !url.password &&
+ url.pathname === "/" &&
+ !url.search &&
+ !url.hash &&
+ url.origin === value &&
+ (url.protocol === "https:" || (url.protocol === "http:" && local))
+ );
+ } catch {
+ return false;
+ }
+}
+async function json(response: Response) {
+ try {
+ return (await response.json()) as Record;
+ } catch {
+ return {};
+ }
+}
+
+export function DomainSetup({ installationId }: { installationId: string }) {
+ const endpoint = `/api/installations/${installationId}/domain`;
+ const [details, setDetails] = useState(null);
+ const [zones, setZones] = useState(null);
+ const [zoneId, setZoneId] = useState("");
+ const [hostname, setHostname] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [busy, setBusy] = useState(false);
+ const [reconnect, setReconnect] = useState(false);
+ const [confirmRemove, setConfirmRemove] = useState(false);
+ const [error, setError] = useState("");
+ const mounted = useRef(true);
+ const request = useRef<{
+ path: "" | "/remove" | "/retry";
+ values: Record;
+ requestId: string;
+ } | null>(null);
+ const replayKey = `flarebot-domain-request:${installationId}`;
+ const [uncertain, setUncertain] = useState(false);
+
+ function clearRequest() {
+ request.current = null;
+ setUncertain(false);
+ try {
+ sessionStorage.removeItem(replayKey);
+ } catch {
+ /* Storage may be disabled. */
+ }
+ }
+
+ function showError(
+ code: unknown,
+ fallback = "Could not load domain setup. Try again.",
+ ) {
+ setError(
+ typeof code === "string" && errors[code] ? errors[code] : fallback,
+ );
+ }
+ async function load() {
+ try {
+ const response = await fetch(endpoint, {
+ credentials: "same-origin",
+ cache: "no-store",
+ });
+ const data = await json(response);
+ if (!mounted.current) return;
+ if (response.status === 401) return setReconnect(true);
+ if (!response.ok) return showError(data.error);
+ if (
+ data.installationId !== installationId ||
+ !/^[a-f0-9]{32}$/.test(data.accountId) ||
+ !validOrigin(data.runtimeOrigin) ||
+ (data.domain !== null &&
+ (!data.domain ||
+ !["connecting", "active", "removing", "removed", "failed"].includes(
+ data.domain.status,
+ ) ||
+ !Number.isSafeInteger(data.domain.revision) ||
+ (data.domain.origin !== null && !validOrigin(data.domain.origin))))
+ )
+ throw new Error("invalid response");
+ setDetails((current) =>
+ current?.domain &&
+ (!data.domain || data.domain.revision < current.domain.revision)
+ ? current
+ : (data as Details),
+ );
+ setError("");
+ setReconnect(false);
+ const domain = data.domain as Domain | null;
+ if (domain?.errorCode === "reauthorization_required") setReconnect(true);
+ if (!domain || domain.status === "removed") void loadZones();
+ } catch {
+ if (mounted.current) showError(null);
+ } finally {
+ if (mounted.current) setLoading(false);
+ }
+ }
+ async function loadZones() {
+ try {
+ const response = await fetch(`${endpoint}/zones`, {
+ credentials: "same-origin",
+ cache: "no-store",
+ });
+ const data = await json(response);
+ if (!mounted.current) return;
+ if (response.status === 401) return setReconnect(true);
+ if (!response.ok) return showError(data.error);
+ const values = Array.isArray(data.zones)
+ ? data.zones.filter(
+ (zone) =>
+ /^[a-f0-9]{32}$/.test(zone.id) && typeof zone.name === "string",
+ )
+ : [];
+ setZones(values);
+ if (values.length) setZoneId((current) => current || values[0].id);
+ } catch {
+ if (mounted.current)
+ showError(null, "Could not load your Cloudflare zones. Try again.");
+ }
+ }
+ useEffect(() => {
+ mounted.current = true;
+ try {
+ const saved = JSON.parse(sessionStorage.getItem(replayKey) ?? "null");
+ if (
+ saved &&
+ /^[a-f0-9]{32}$/.test(saved.requestId) &&
+ ["", "/remove", "/retry"].includes(saved.path) &&
+ saved.values &&
+ typeof saved.values === "object"
+ ) {
+ request.current = saved;
+ setUncertain(true);
+ }
+ } catch {
+ /* A missing local replay never grants authorization. */
+ }
+ void load();
+ return () => {
+ mounted.current = false;
+ };
+ }, [installationId]);
+ useEffect(() => {
+ if (
+ !details ||
+ !["connecting", "removing"].includes(details.domain?.status ?? "")
+ )
+ return;
+ let cancelled = false;
+ let timer: ReturnType;
+ async function poll() {
+ await load();
+ if (!cancelled) timer = setTimeout(() => void poll(), 3000);
+ }
+ timer = setTimeout(() => void poll(), 3000);
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ };
+ }, [installationId, details?.domain?.status]);
+
+ async function submit(
+ path: "" | "/remove" | "/retry",
+ values: Record,
+ ) {
+ if (busy) return;
+ const pending = request.current ?? { path, values, requestId: id() };
+ request.current = pending;
+ try {
+ sessionStorage.setItem(replayKey, JSON.stringify(pending));
+ } catch {
+ /* In-memory retries still retain the request. */
+ }
+ setBusy(true);
+ setError("");
+ try {
+ const response = await fetch(`${endpoint}${pending.path}`, {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ requestId: pending.requestId,
+ ...pending.values,
+ }).toString(),
+ signal: AbortSignal.timeout(30_000),
+ });
+ const data = await json(response);
+ if (!mounted.current) return;
+ if (response.status === 401) {
+ clearRequest();
+ return setReconnect(true);
+ }
+ if (!response.ok) {
+ if ([400, 403, 404, 409].includes(response.status)) clearRequest();
+ else setUncertain(true);
+ return showError(
+ data.error,
+ "The request may have been received. Retry to safely confirm it.",
+ );
+ }
+ if (!data.domain || !Number.isSafeInteger(data.domain.revision))
+ throw new Error("invalid response");
+ clearRequest();
+ setConfirmRemove(false);
+ setDetails((current) =>
+ current &&
+ (!current.domain || data.domain.revision >= current.domain.revision)
+ ? { ...current, domain: data.domain }
+ : current,
+ );
+ } catch {
+ setUncertain(true);
+ showError(
+ null,
+ "The request may have been received. Retry to safely confirm it.",
+ );
+ } finally {
+ if (mounted.current) setBusy(false);
+ }
+ }
+
+ const domain = details?.domain;
+ const configuring = !domain || domain.status === "removed";
+ const returnUrl = details
+ ? new URL("/settings", details.runtimeOrigin).href
+ : "#";
+ return (
+
+
+
Choose your address
+
+ Add an optional custom domain. Your workers.dev address always remains
+ available.
+
+
+ {loading && Checking domain setup…
}
+ {error && {error}
}
+ {uncertain && (
+
+
void submit("", {})}>
+ Confirm previous request
+
+
+ The previous result is uncertain. Confirm it before starting another
+ operation.
+
+
{
+ clearRequest();
+ void load();
+ }}
+ >
+ Check saved status instead
+
+
+ )}
+ {domain?.status === "connecting" && (
+
+ {domain.domainId ? "Waiting for HTTPS on" : "Connecting"}{" "}
+ {domain.hostname}… You can leave this page; setup will continue.
+
+ )}
+ {domain?.status === "removing" && (
+
+ Removing {domain.hostname}… Your workers.dev address remains
+ available.
+
+ )}
+ {domain &&
+ ["connecting", "removing"].includes(domain.status) &&
+ !uncertain && (
+
+ Setup interrupted?
+
+ Restart the saved operation if it is no longer making progress.
+ Existing resources will be checked, not replaced.
+
+ void submit("/retry", {})}>
+ Restart saved operation
+
+
+ )}
+ {domain?.status === "active" && (
+ <>
+
+ {domain.hostname} is active.
+
+ {domain.origin && validOrigin(domain.origin) && (
+
+ Open Flarebot at {domain.hostname}
+
+ )}
+ You may need to sign in again at your new address.
+ >
+ )}
+ {domain?.status === "failed" && (
+ <>
+
+ {errors[domain.errorCode ?? ""] ??
+ "Domain setup failed. Retry the same operation."}
+
+ void submit("/retry", {})}
+ >
+ {busy
+ ? "Retrying…"
+ : `Retry ${domain.action === "remove" ? "removal" : "connection"}`}
+
+ >
+ )}
+ {configuring && zones === null && !error && (
+ Loading active zones…
+ )}
+ {configuring && zones?.length === 0 && (
+
+ No active Cloudflare zones were found. This first version requires an
+ existing zone; add a domain from your registrar to Cloudflare, or
+ register one with Cloudflare, then reconnect.
+
+ )}
+ {configuring && !!zones?.length && (
+
+ )}
+ {(domain?.status === "active" || domain?.status === "failed") &&
+ !confirmRemove && (
+ setConfirmRemove(true)}
+ >
+ {domain.status === "active"
+ ? "Remove custom domain"
+ : "Cancel domain setup"}
+
+ )}
+ {(domain?.status === "active" || domain?.status === "failed") &&
+ confirmRemove && (
+
+
+ Remove {domain.hostname}? Flarebot will continue at its
+ workers.dev address.
+
+
+ void submit("/remove", {})}
+ >
+ Confirm removal
+
+ setConfirmRemove(false)}
+ >
+ Cancel
+
+
+
+ )}
+ {reconnect && (
+ <>
+
+ Reconnect Cloudflare to continue. Cloudflare may redirect you to
+ review authorization.
+
+
+ >
+ )}
+ {error && !uncertain && (
+ void load()}>
+ Refresh domain status
+
+ )}
+ {details && (
+
+ Skip for now — use workers.dev
+
+ )}
+
+ );
+}
diff --git a/control-plane/ui/InstallationStatus.tsx b/control-plane/ui/InstallationStatus.tsx
index 91f683a..ad14041 100644
--- a/control-plane/ui/InstallationStatus.tsx
+++ b/control-plane/ui/InstallationStatus.tsx
@@ -168,15 +168,23 @@ export function InstallationStatus({
)}
{record.status === "ready" && (
-
- {upToDate
- ? "Up to date"
- : upgradeAvailable && status.latestRelease
- ? `Version ${status.latestRelease.version} is available`
- : status.latestRelease
- ? "No compatible upgrade is available."
- : "Release availability could not be checked."}
-
+ <>
+
+ {upToDate
+ ? "Up to date"
+ : upgradeAvailable && status.latestRelease
+ ? `Version ${status.latestRelease.version} is available`
+ : status.latestRelease
+ ? "No compatible upgrade is available."
+ : "Release availability could not be checked."}
+
+
+ Choose your address
+
+ >
)}
{record.status !== "ready" && record.installedRelease && (
diff --git a/control-plane/ui/Onboarding.tsx b/control-plane/ui/Onboarding.tsx
index b1ed9df..0943a65 100644
--- a/control-plane/ui/Onboarding.tsx
+++ b/control-plane/ui/Onboarding.tsx
@@ -5,6 +5,7 @@ import { messages, type ErrorCode } from "../errors.ts";
import { useInstallationStatus } from "./installation-status.ts";
import { InstallationStatus } from "./InstallationStatus.tsx";
import { ProviderSetup } from "./ProviderSetup.tsx";
+import { DomainSetup } from "./DomainSetup.tsx";
import { UpdateInstallation } from "./UpdateInstallation.tsx";
interface Connection {
accounts: { id: string; name: string }[];
@@ -18,6 +19,9 @@ export function Onboarding() {
const [providerInstallationId, setProviderInstallationId] = useState<
string | null | undefined
>(undefined);
+ const [domainInstallationId, setDomainInstallationId] = useState<
+ string | null | undefined
+ >(undefined);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const installationId = params.get("installationId");
@@ -32,6 +36,10 @@ export function Onboarding() {
? installationId
: null,
);
+ const domainId = params.get("configureDomain");
+ setDomainInstallationId(
+ domainId && /^[a-f0-9]{32}$/.test(domainId) ? domainId : null,
+ );
}, []);
if (updateInstallationId)
return ;
@@ -52,11 +60,27 @@ export function Onboarding() {
);
+ if (domainInstallationId)
+ return (
+
+
+
+
+
Custom domain setup
+
Choose an optional address for your Flarebot installation.
+
+
+
+
+ );
return (
);
diff --git a/control-plane/ui/styles.css b/control-plane/ui/styles.css
index a1c454f..98fcae8 100644
--- a/control-plane/ui/styles.css
+++ b/control-plane/ui/styles.css
@@ -161,6 +161,25 @@ body {
outline: 2px solid var(--text-color-kumo-default);
outline-offset: 3px;
}
+.domain-form {
+ display: grid;
+ gap: 0.75rem;
+}
+.domain-form input,
+.domain-form select {
+ width: 100%;
+ min-width: 0;
+ padding: 0.625rem 0.75rem;
+ border: 1px solid var(--color-kumo-line);
+ border-radius: 0.375rem;
+ background: var(--color-kumo-canvas);
+ color: var(--text-color-kumo-default);
+ font: inherit;
+}
+.connect-card strong,
+.connect-card .open-flarebot {
+ overflow-wrap: anywhere;
+}
@media (max-width: 480px) {
.onboarding main {
padding: 1.5rem 1rem;
diff --git a/control-plane/vault.ts b/control-plane/vault.ts
index 0fe629c..e523a96 100644
--- a/control-plane/vault.ts
+++ b/control-plane/vault.ts
@@ -10,6 +10,7 @@ export interface Transaction {
returnTo: "/connect";
bridgeContinuation?: string;
providerInstallationId?: string;
+ domainInstallationId?: string;
expiresAt: number;
}
export interface Principal {
@@ -28,6 +29,7 @@ export interface BridgeRequest {
installationId: string;
state: string;
challenge: string;
+ audience?: string;
expiresAt: number;
}
export interface BridgeContinuation extends BridgeRequest {
diff --git a/docs/custom-domains.md b/docs/custom-domains.md
new file mode 100644
index 0000000..b3fe1f2
--- /dev/null
+++ b/docs/custom-domains.md
@@ -0,0 +1,112 @@
+# Custom domains
+
+An installed Flarebot can optionally use one custom hostname in an **active zone
+in the installation's Cloudflare account**. Wildcards, URLs with paths or ports,
+IP addresses, and workers.dev aliases are rejected. Domains not yet on Cloudflare
+need separate zone/registrar onboarding. Flarebot does not register domains or
+change nameservers. Existing DNS records and other Workers are never replaced.
+
+## Setup and publisher prerequisites
+
+Choose **Settings → Domain → Configure domain**, or **Choose your address** on a
+ready installation. The Flarebot publisher screen lists eligible zones, accepts
+a hostname, and shows progress while Cloudflare prepares HTTPS. The page can be
+closed without stopping the operation. Reconnecting Cloudflare may require an
+OAuth consent redirect; only a fixed installation ID is retained through that
+flow. An active hostname is offered as the preferred application address. Its
+host-only session is independent of the original workers.dev session.
+
+The customer must first have a release containing domain configuration support.
+Keep the publisher signing key stable and the customer's pinned key and session
+secret unchanged. An older customer must be upgraded; adding routing alone does
+not make that customer support a second authentication origin.
+
+Domain management is optional and fails closed with `setup_required` until the
+publisher reviews and registers the actual OAuth catalog scopes covering:
+
+| Capability | Required operations |
+| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
+| `domain-zones` | List/read active zones in the installation account; read DNS records to detect conflicts. |
+| `domain-routing` | List/read Worker Custom Domains; script-scoped domain records attachment with conflict override disabled; detach a saved Custom Domain. |
+
+Add these capability mappings to the existing `oauthCapabilities.scopes` manifest,
+keeping `oauthScopes` and the registered client's scope IDs consistent. These are
+**Flarebot capability names, not OAuth scope IDs**. No guessed scope mapping or
+additional default grant is installed by this feature. Existing sign-in and
+deployment remain valid with the original capability set. Test an authorized live
+grant against the exact endpoints before enabling the feature for customers.
+
+Cloudflare documents [Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/)
+as automatically managing DNS and certificates. Attachment uses the same native
+[script domain records interface as Wrangler](https://github.com/cloudflare/workers-sdk/blob/main/packages/deploy-helpers/src/triggers/publish-routes.ts):
+`PUT /accounts/:account/workers/scripts/:script/domains/records` with
+`override_scope: false`, `override_existing_origin: false`, and
+`override_existing_dns_record: false`. Unlike Wrangler's noninteractive defaults,
+Flarebot never turns on takeover flags. The scope flag preserves unrelated
+domains. DNS preflight is advisory; the write-time flags also protect against
+conflicts appearing after the check. This interface and its scope mapping require
+live certification; local fixtures do not prove Cloudflare entitlement or TLS.
+
+## Ownership and recovery
+
+`InstallationRegistry` owns a separate `domain:` record and
+per-installation request replay records. The immutable `resources.runtimeOrigin`
+remains the workers.dev management/recovery origin. A domain record contains only
+hostname, zone/domain IDs, operation identity, revision, deadline, intent, status,
+and a declared error category. No credential or grant reference is stored there.
+
+The existing native `InstallationWorkflow` runs domain operations with
+`kind: "domain"`. It commits write intent before attachment, reconciles uncertain
+provider replies, delivers a signed revisioned configuration to the runtime,
+checks the account's domain mapping and signed-challenge HTTPS endpoint, then
+marks the domain active. Browser polling observes metadata; it does not drive
+execution. Tokens remain in the expiring encrypted AuthVault and never enter
+Workflow inputs, results, or logs. Upgrade and domain mutations cannot start over
+each other's active installation operation.
+
+The customer persists the allowed origin in a dedicated PersonalAgent SQLite
+table. The management-only receipt uses a separate assertion purpose and pinned
+issuer, owner, installation and audience. Lower revisions and conflicting
+same-revision receipts are rejected; exact retries are idempotent. This state
+survives code upgrades without changing deployment configuration or user secrets.
+The login bridge validates an explicitly requested origin both when issuing and
+exchanging a one-time code. Login challenges, session audiences, mutation Origin
+headers and WebSockets remain bound to the exact origin being used.
+
+Removing a domain first removes it from login eligibility, then revokes the
+runtime origin, then detaches the saved mapping. The workers.dev address remains
+available throughout. Failed preflight cancellation never deletes a pre-existing
+domain. Cancellation is rejected while attachment is connecting or its outcome
+is unresolved. An explicit restart terminates the old Workflow and preserves
+write intent. Only one PUT can follow an intent: after a lost reply, retries
+observe the same hostname and never issue a second PUT. A definitive rejected
+write can be retried normally. An indeterminate write with no observed mapping
+stays `attachment_outcome_unknown`; it cannot be called removed or release its
+hostname slot until reconciled. This includes the rare crash between committing
+intent and sending the request: absence cannot distinguish that crash from a
+delayed accepted write. workers.dev remains usable; publisher/Cloudflare
+investigation may be needed if the outcome never becomes observable.
+
+## Browser interface and checks
+
+- `GET /api/installations/:id/domain`: owned metadata; identity session only.
+- `GET /api/installations/:id/domain/zones`: eligible zones; current grant required.
+- `POST /api/installations/:id/domain`: exact form `requestId`, `zoneId`, `hostname`.
+- `POST /api/installations/:id/domain/remove` or `/retry`: exact form `requestId`.
+- `GET /api/domain` on the customer: authenticated setup link and configured origin.
+
+All publisher mutations retain exact-Origin protection and revalidate the grant
+against the installation's stored account, not the account picker selection.
+HTTP/status errors expose only declared categories. Reuse the saved request ID
+after an uncertain response; retries after an observed failure start a new
+operation for the same hostname. A DNS conflict must be resolved explicitly or
+the setup cancelled before choosing another hostname.
+
+Build sequentially: `pnpm build:release`, then
+`pnpm build:control-plane:fixture` for a dirty local checkout (never publish this
+fixture catalog). Run `pnpm test:domains`, `pnpm test:bridge`, and
+`pnpm typecheck`. Domain tests use native SQLite DOs and Workflows with an explicit
+Cloudflare network fixture; the bridge suite exercises real two-site sessions,
+custom origin isolation, signed configuration replay/removal and persistence.
+Playwright renders setup, pending HTTPS, active, conflict, reconnect and removal
+states. These tests do not certify a live OAuth grant, DNS propagation or edge TLS.
diff --git a/docs/installation-ownership.md b/docs/installation-ownership.md
index b93a342..547ff13 100644
--- a/docs/installation-ownership.md
+++ b/docs/installation-ownership.md
@@ -77,6 +77,11 @@ Cloudflare responses and check the application's Sandbox namespace association.
The schema validates identifiers and relationships it can know locally; it does
not claim to prove Cloudflare ownership or health by parsing strings.
+An optional [custom domain](custom-domains.md) is managed in a separate
+revisioned record. It never replaces this immutable management/recovery origin.
+Only active verified domain records may supply an additional browser login
+audience; arbitrary caller-supplied origins remain forbidden.
+
The private binding exposes four methods with a sanitized result envelope:
```ts
diff --git a/docs/oauth-onboarding.md b/docs/oauth-onboarding.md
index 6191d1c..ae9b514 100644
--- a/docs/oauth-onboarding.md
+++ b/docs/oauth-onboarding.md
@@ -81,6 +81,13 @@ push permission. Account listing proves account visibility, never deployment
write permissions, product entitlement or container readiness. A real authorized
full-artifact installation is still required to certify the reviewed mapping.
+Optional [custom-domain setup](custom-domains.md) adds separately reviewed
+`domain-zones` and `domain-routing` capabilities. They are not required for
+ordinary installation or sign-in. The domain flow accepts `domainInstallationId`
+instead of `providerInstallationId` and returns only to the fixed Flarebot domain
+setup view after OAuth. Never infer the actual OAuth scope IDs from those
+capability names.
+
## Configuration manifest
Set `FLAREBOT_MODE=control-plane`, `FLAREBOT_ENV=production`, and a JSON string
diff --git a/package.json b/package.json
index 8330be3..8772c94 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"build:control-plane:fixture": "node scripts/build-catalog.mjs --development-fixture && vite build --config control-plane/ui/vite.config.ts && wrangler deploy --config wrangler.control-plane.jsonc --dry-run --outdir dist/control-plane/worker",
"test:orchestrator": "node --test --test-reporter=tap tests/orchestrator.test.mjs",
"test:bridge": "node --test tests/bridge.test.mjs",
+ "test:domains": "node --test --test-concurrency=1 tests/domain-auth.test.mjs tests/domain-provisioning.test.mjs tests/domain-workflow.test.mjs tests/domain-ui.test.mjs",
"test:installation-status": "node --test --test-reporter=tap tests/installation-status.test.mjs",
"test:updates": "node --test tests/update-on-visit.test.mjs tests/update-on-visit-ui.test.mjs",
"test:upgrade-state": "node --test tests/upgrade-state.test.mjs",
diff --git a/shared/bridge.ts b/shared/bridge.ts
index e310676..009eb94 100644
--- a/shared/bridge.ts
+++ b/shared/bridge.ts
@@ -2,6 +2,9 @@
export const LOGIN_PURPOSE = "flarebot-owner-login" as const;
export const HEALTH_PURPOSE = "flarebot-bootstrap-health" as const;
export const PROVIDER_PURPOSE = "flarebot-provider-enabled" as const;
+export const DOMAIN_CONFIGURATION_PURPOSE =
+ "flarebot-domain-configuration" as const;
+export const DOMAIN_HEALTH_PURPOSE = "flarebot-domain-health" as const;
export const encode = (bytes: Uint8Array) =>
btoa(String.fromCharCode(...bytes))
.replaceAll("+", "-")
@@ -30,7 +33,11 @@ export interface BridgeClaims {
sub: string;
installationId: string;
purpose:
- typeof LOGIN_PURPOSE | typeof HEALTH_PURPOSE | typeof PROVIDER_PURPOSE;
+ | typeof LOGIN_PURPOSE
+ | typeof HEALTH_PURPOSE
+ | typeof PROVIDER_PURPOSE
+ | typeof DOMAIN_CONFIGURATION_PURPOSE
+ | typeof DOMAIN_HEALTH_PURPOSE;
iat: number;
exp: number;
jti: string;
diff --git a/shared/domain-origin.ts b/shared/domain-origin.ts
new file mode 100644
index 0000000..a350afc
--- /dev/null
+++ b/shared/domain-origin.ts
@@ -0,0 +1,32 @@
+/** Parse the single supported custom-domain form into its canonical origin. */
+export function customDomainOrigin(value: unknown): string | null {
+ if (typeof value !== "string" || value.length > 253) return null;
+ try {
+ const url = new URL(value);
+ const hostname = url.hostname;
+ if (
+ url.protocol !== "https:" ||
+ url.origin !== value ||
+ url.port ||
+ url.username ||
+ url.password ||
+ url.pathname !== "/" ||
+ url.search ||
+ url.hash ||
+ hostname !== hostname.toLowerCase() ||
+ hostname.endsWith(".workers.dev") ||
+ hostname === "workers.dev" ||
+ hostname.includes("*") ||
+ !hostname.includes(".") ||
+ /^\d+(?:\.\d+){3}$/.test(hostname) ||
+ hostname.includes(":") ||
+ !hostname
+ .split(".")
+ .every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
+ )
+ return null;
+ return url.origin;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/routes/DomainSettings.tsx b/src/routes/DomainSettings.tsx
new file mode 100644
index 0000000..825c815
--- /dev/null
+++ b/src/routes/DomainSettings.tsx
@@ -0,0 +1,94 @@
+import { useEffect, useState } from "octane";
+
+type DomainInfo = { setupUrl: string; origin: string | null };
+
+function safeSetupUrl(value: unknown) {
+ try {
+ if (typeof value !== "string") return null;
+ const url = new URL(value);
+ const local = ["localhost", "127.0.0.1"].includes(url.hostname);
+ const params = url.searchParams;
+ return !url.username &&
+ !url.password &&
+ url.pathname === "/connect" &&
+ params.size === 1 &&
+ /^[a-f0-9]{32}$/.test(params.get("configureDomain") ?? "") &&
+ !url.hash &&
+ (url.protocol === "https:" || (url.protocol === "http:" && local))
+ ? url.href
+ : null;
+ } catch {
+ return null;
+ }
+}
+function safeOrigin(value: unknown) {
+ if (value === null) return null;
+ try {
+ const url = new URL(String(value));
+ const local = ["localhost", "127.0.0.1"].includes(url.hostname);
+ return (url.protocol === "https:" || (url.protocol === "http:" && local)) &&
+ url.pathname === "/" &&
+ !url.search &&
+ !url.hash &&
+ !url.username &&
+ !url.password
+ ? url.origin
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+export function DomainSettings() {
+ const [info, setInfo] = useState(null);
+ const [error, setError] = useState("");
+ useEffect(() => {
+ let active = true;
+ void fetch("/api/domain", { credentials: "same-origin", cache: "no-store" })
+ .then(async (response) => {
+ const data = await response.json();
+ const setupUrl = safeSetupUrl(data.setupUrl);
+ if (
+ !response.ok ||
+ !setupUrl ||
+ (data.origin !== null && !safeOrigin(data.origin))
+ )
+ throw new Error();
+ if (active) setInfo({ setupUrl, origin: safeOrigin(data.origin) });
+ })
+ .catch(() => {
+ if (active) setError("Could not load domain settings.");
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+ return (
+
+
+
Domain
+
+ A custom domain is optional. Your default workers.dev address remains
+ available.
+
+
+ {!info && !error && Loading domain…
}
+ {error && {error}
}
+ {info && (
+ <>
+
+ {info.origin ? (
+ <>
+ Current hostname:{" "}
+ {new URL(info.origin).hostname}
+ >
+ ) : (
+ "Using the default workers.dev address."
+ )}
+
+ Configure domain
+ >
+ )}
+
+ );
+}
diff --git a/src/routes/Settings.tsx b/src/routes/Settings.tsx
index db6087d..3258186 100644
--- a/src/routes/Settings.tsx
+++ b/src/routes/Settings.tsx
@@ -2,6 +2,7 @@ import { DiagnosticSettings } from "./DiagnosticSettings";
import { ModelSettings } from "./ModelSettings";
import { InstallationSettings } from "./InstallationSettings";
import { MemorySettings } from "./MemorySettings";
+import { DomainSettings } from "./DomainSettings";
import { useEffect, useRef, useState } from "octane";
import { Button } from "octane-kumo/components/button";
import { InputArea } from "octane-kumo/components/input";
@@ -203,6 +204,7 @@ export function Settings() {
{settings && connection && (
<>
+
>
diff --git a/tests/bridge.test.mjs b/tests/bridge.test.mjs
index fd741c9..a66ed0a 100644
--- a/tests/bridge.test.mjs
+++ b/tests/bridge.test.mjs
@@ -25,6 +25,8 @@ import {
LOGIN_PURPOSE,
HEALTH_PURPOSE,
PROVIDER_PURPOSE,
+ DOMAIN_CONFIGURATION_PURPOSE,
+ DOMAIN_HEALTH_PURPOSE,
} from "../shared/bridge.ts";
const id = () => randomBytes(32).toString("base64url");
@@ -755,6 +757,9 @@ test(
await setupPage
.locator("section[aria-labelledby='model-heading']")
.screenshot({ path: "/tmp/flarebot-openrouter-enabled.png" });
+ await setupPage
+ .locator("section[aria-labelledby='domain-heading']")
+ .screenshot({ path: "/tmp/flarebot-domain-settings.png" });
assert.ok(
!visited.some(
(url) => url.includes("assertion") || url.includes("oauth-secret"),
@@ -762,6 +767,210 @@ test(
);
await setupContext.close();
const cpIdentity = { Cookie: `${cpCookie.name}=${cpCookie.value}` };
+ // The custom hostname is a separate browser origin, not a cookie alias.
+ {
+ const customOrigin = "https://bot.example.com";
+ const domain = await (
+ await post(callCP, "/__bridge__/domain", { owner: OWNER, id: I })
+ ).json();
+ const domainToken = (revision, origin, changes = {}) =>
+ token({
+ ...claims,
+ purpose: DOMAIN_CONFIGURATION_PURPOSE,
+ state: String(revision),
+ challenge: origin ?? "",
+ iat: Math.floor(Date.now() / 1000),
+ exp: Math.floor(Date.now() / 1000) + 60,
+ jti: id(),
+ ...changes,
+ });
+ const configure = (signed) =>
+ callCustomer("/auth/domain-configuration", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${signed}` },
+ });
+ for (const changes of [
+ { sub: "not-owner" },
+ { aud: customOrigin },
+ { purpose: LOGIN_PURPOSE },
+ ])
+ assert.equal(
+ (
+ await configure(
+ domainToken(domain.revision, customOrigin, changes),
+ )
+ ).status,
+ 403,
+ );
+ const configured = domainToken(domain.revision, customOrigin);
+ assert.deepEqual(await (await configure(configured)).json(), {
+ revision: domain.revision,
+ origin: customOrigin,
+ });
+ assert.equal(
+ (await configure(configured)).status,
+ 200,
+ "same signed revision is idempotent",
+ );
+ assert.equal(
+ (
+ await configure(
+ domainToken(domain.revision, "https://other.example.com"),
+ )
+ ).status,
+ 403,
+ );
+ const customHeaders = { "x-fixture-origin": customOrigin };
+ const customBegin = await callCustomer("/auth/login", {
+ headers: customHeaders,
+ });
+ assert.equal(customBegin.status, 303);
+ const customDestination = new URL(customBegin.headers.get("location"));
+ assert.equal(
+ customDestination.searchParams.get("audience"),
+ customOrigin,
+ );
+ const customCode = await callCP(
+ customDestination.pathname + customDestination.search,
+ { headers: cpIdentity },
+ );
+ assert.equal(customCode.status, 303);
+ const customCallback = new URL(customCode.headers.get("location"));
+ assert.equal(customCallback.origin, customOrigin);
+ const loginCookie = cookie(customBegin, "__Host-flarebot-login");
+ assert.equal(
+ (
+ await callCustomer(
+ customCallback.pathname + customCallback.search,
+ {
+ headers: { Cookie: loginCookie },
+ },
+ )
+ ).status,
+ 403,
+ "challenge cannot be consumed on workers.dev",
+ );
+ const customSession = await callCustomer(
+ customCallback.pathname + customCallback.search,
+ { headers: { ...customHeaders, Cookie: loginCookie } },
+ );
+ assert.equal(customSession.status, 303);
+ const ownerCookie = cookie(customSession, "__Host-flarebot-session");
+ assert.ok(ownerCookie);
+ assert.equal(
+ (
+ await callCustomer("/api/domain", {
+ headers: { ...customHeaders, Cookie: ownerCookie },
+ })
+ ).status,
+ 200,
+ );
+ assert.equal(
+ (
+ await callCustomer("/api/domain", {
+ headers: { Cookie: ownerCookie },
+ })
+ ).status,
+ 401,
+ "sessions cannot cross approved origins",
+ );
+ assert.equal(
+ (
+ await callCustomer("/api/domain", {
+ headers: {
+ ...customHeaders,
+ Cookie: ownerCookie,
+ Origin: CUSTOMER,
+ },
+ })
+ ).status,
+ 403,
+ );
+ const forgedDestination = new URL(customDestination);
+ forgedDestination.searchParams.set(
+ "audience",
+ "https://attacker.example",
+ );
+ assert.equal(
+ (
+ await callCP(
+ forgedDestination.pathname + forgedDestination.search,
+ {
+ headers: cpIdentity,
+ },
+ )
+ ).status,
+ 403,
+ );
+ const probeClaims = {
+ ...claims,
+ purpose: DOMAIN_HEALTH_PURPOSE,
+ aud: customOrigin,
+ iat: Math.floor(Date.now() / 1000),
+ exp: Math.floor(Date.now() / 1000) + 60,
+ jti: id(),
+ };
+ const health = await callCustomer("/auth/domain-health", {
+ method: "POST",
+ headers: {
+ ...customHeaders,
+ Authorization: `Bearer ${token(probeClaims)}`,
+ },
+ });
+ assert.deepEqual(await health.json(), {
+ installationId: I,
+ origin: customOrigin,
+ state: claims.state,
+ challenge: claims.challenge,
+ });
+ await customer.stop();
+ customer = await startCustomer();
+ assert.equal(
+ (
+ await callCustomer("/api/domain", {
+ headers: { ...customHeaders, Cookie: ownerCookie },
+ })
+ ).status,
+ 200,
+ "custom domain survives native restart",
+ );
+ const removedDomain = await (
+ await post(callCP, "/__bridge__/domain", {
+ owner: OWNER,
+ id: I,
+ remove: true,
+ })
+ ).json();
+ assert.equal(
+ (await configure(domainToken(removedDomain.revision, null))).status,
+ 200,
+ );
+ assert.equal(
+ (await configure(configured)).status,
+ 403,
+ "stale receipt cannot resurrect removed domain",
+ );
+ assert.equal(
+ (
+ await callCustomer("/api/domain", {
+ headers: { ...customHeaders, Cookie: ownerCookie },
+ })
+ ).status,
+ 403,
+ );
+ assert.equal(
+ (
+ await callCP(
+ customDestination.pathname + customDestination.search,
+ {
+ headers: cpIdentity,
+ },
+ )
+ ).status,
+ 403,
+ "removed origin cannot receive login codes",
+ );
+ }
async function pendingLogin() {
const begin = await callCustomer("/auth/login");
assert.equal(begin.status, 303);
diff --git a/tests/domain-auth.test.mjs b/tests/domain-auth.test.mjs
new file mode 100644
index 0000000..e1b17ca
--- /dev/null
+++ b/tests/domain-auth.test.mjs
@@ -0,0 +1,71 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { Secret } from "../configuration/secrets.ts";
+import { customDomainOrigin } from "../shared/domain-origin.ts";
+import {
+ authorizeRuntimeRequest,
+ createOwnerSession,
+ verifyOwnerSession,
+} from "../worker/session.ts";
+
+const management =
+ "https://flarebot-0123456789abcdef0123456789abcdef.example.workers.dev";
+const custom = "https://bot.example.com";
+const installation = {
+ schemaVersion: 1,
+ installationId: "0123456789abcdef0123456789abcdef",
+ ownerSubject: "owner",
+ runtimeOrigin: custom,
+ controlPlaneOrigin: "https://control.example.com",
+};
+const secret = new Secret("a-session-secret-that-is-at-least-32-bytes");
+
+test("custom domain origins are canonical HTTPS DNS origins", () => {
+ assert.equal(customDomainOrigin(custom), custom);
+ for (const origin of [
+ "http://bot.example.com",
+ "https://bot.example.com/route",
+ "https://bot.example.com:8443",
+ "https://127.0.0.1",
+ "https://bot.example.workers.dev",
+ "https://*.example.com",
+ "https://BOT.example.com",
+ ])
+ assert.equal(customDomainOrigin(origin), null, origin);
+});
+
+test("owner session and state-changing requests bind the exact custom origin", async () => {
+ const setCookie = await createOwnerSession(secret, installation);
+ const cookie = setCookie.split(";")[0];
+ const request = (url, origin = custom, method = "GET") =>
+ new Request(url, { method, headers: { Cookie: cookie, Origin: origin } });
+ assert.ok(
+ await verifyOwnerSession(request(`${custom}/api`), secret, installation),
+ );
+ assert.equal(
+ await verifyOwnerSession(
+ request(`${management}/api`, management),
+ secret,
+ installation,
+ ),
+ null,
+ );
+ assert.equal(
+ await authorizeRuntimeRequest(
+ request(`${custom}/api`, management, "POST"),
+ secret,
+ installation,
+ ).then((response) => response?.status),
+ 403,
+ );
+ assert.equal(
+ await authorizeRuntimeRequest(
+ new Request(`${custom}/agents/personal`, {
+ headers: { Cookie: cookie, Origin: management, Upgrade: "websocket" },
+ }),
+ secret,
+ installation,
+ ).then((response) => response?.status),
+ 403,
+ );
+});
diff --git a/tests/domain-provisioning.test.mjs b/tests/domain-provisioning.test.mjs
new file mode 100644
index 0000000..ff6b6d2
--- /dev/null
+++ b/tests/domain-provisioning.test.mjs
@@ -0,0 +1,205 @@
+import assert from "node:assert/strict";
+import { randomBytes } from "node:crypto";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { createServer } from "node:net";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { test } from "node:test";
+import { parse } from "jsonc-parser";
+import { unstable_dev } from "wrangler";
+import "./fixtures/config.mjs";
+
+const id = () => randomBytes(16).toString("hex");
+async function freePort() {
+ const s = createServer();
+ await new Promise((r) => s.listen(0, "127.0.0.1", r));
+ const p = s.address().port;
+ await new Promise((r) => s.close(r));
+ return p;
+}
+
+test(
+ "native domain registry and Cloudflare domain contract",
+ { timeout: 120_000 },
+ async (t) => {
+ const temporary = await mkdtemp(join(tmpdir(), "flarebot-domain-"));
+ const port = await freePort();
+ const origin = `http://127.0.0.1:${port}`;
+ const base = parse(await readFile("wrangler.control-plane.jsonc", "utf8"));
+ const config = join(temporary, "wrangler.json");
+ await writeFile(
+ config,
+ JSON.stringify({
+ ...base,
+ name: "flarebot-domain-test",
+ main: resolve("tests/fixtures/domain-worker.ts"),
+ assets: undefined,
+ }),
+ );
+ let worker;
+ t.after(async () => {
+ await worker?.stop();
+ await rm(temporary, { recursive: true, force: true });
+ });
+ worker = await unstable_dev("tests/fixtures/domain-worker.ts", {
+ config,
+ local: true,
+ ip: "127.0.0.1",
+ port,
+ inspectorPort: 0,
+ persist: true,
+ persistTo: temporary,
+ logLevel: "error",
+ experimental: { disableExperimentalWarning: true, watch: false },
+ });
+ const post = async (path, value) =>
+ (
+ await fetch(origin + path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(value),
+ })
+ ).json();
+
+ await t.test(
+ "SQLite reservation is owner-isolated, strict, atomic and replayable",
+ async () => {
+ const owner = "owner-a",
+ installationId = id(),
+ requestId = id();
+ assert.deepEqual(
+ await post("/registry", {
+ action: "seed",
+ owner,
+ id: installationId,
+ }),
+ { ok: true },
+ );
+ assert.deepEqual(
+ await post("/registry", {
+ action: "get",
+ owner,
+ subject: "owner-b",
+ id: installationId,
+ }),
+ { ok: false, error: "installation_not_found" },
+ );
+ const intent = {
+ action: "attach",
+ zoneId: "c".repeat(32),
+ origin: "https://bot.example.com",
+ };
+ const [a, b] = await Promise.all([
+ post("/registry", {
+ action: "start",
+ owner,
+ id: installationId,
+ requestId,
+ intent,
+ }),
+ post("/registry", {
+ action: "start",
+ owner,
+ id: installationId,
+ requestId,
+ intent,
+ }),
+ ]);
+ assert.equal(a.ok, true);
+ assert.deepEqual(b, a);
+ assert.equal(a.value.status, "connecting");
+ assert.deepEqual(
+ await post("/registry", {
+ action: "start",
+ owner,
+ id: installationId,
+ requestId,
+ intent: { ...intent, origin: "https://other.example.com" },
+ }),
+ { ok: false, error: "installation_conflict" },
+ );
+ const badId = id();
+ await post("/registry", {
+ action: "seed",
+ owner,
+ id: badId,
+ value: { credential: "must-not-parse" },
+ });
+ assert.deepEqual(
+ await post("/registry", { action: "get", owner, id: badId }),
+ { ok: false, error: "invalid_metadata" },
+ );
+ await worker.stop();
+ worker = await unstable_dev("tests/fixtures/domain-worker.ts", {
+ config,
+ local: true,
+ ip: "127.0.0.1",
+ port,
+ inspectorPort: 0,
+ persist: true,
+ persistTo: temporary,
+ logLevel: "error",
+ experimental: { disableExperimentalWarning: true, watch: false },
+ });
+ assert.deepEqual(
+ (
+ await post("/registry", {
+ action: "getDomain",
+ owner,
+ id: installationId,
+ })
+ ).value,
+ a.value,
+ );
+ },
+ );
+
+ await t.test(
+ "PUT is non-destructive and a lost response reconciles without a second PUT",
+ async () => {
+ await post("/api/reset", { losePutReply: true });
+ const first = await post("/api/run", {
+ operation: "attach",
+ writeIntent: true,
+ });
+ assert.equal(first.error, "temporarily_unavailable");
+ const second = await post("/api/run", {
+ operation: "find",
+ writeIntent: true,
+ });
+ assert.equal(second.value, "d".repeat(32));
+ const puts = second.trace.filter((x) => x.method === "PUT");
+ assert.equal(puts.length, 1);
+ assert.deepEqual(puts[0].body, {
+ override_scope: false,
+ override_existing_origin: false,
+ override_existing_dns_record: false,
+ origins: [{ hostname: "bot.example.com", zone_id: "c".repeat(32) }],
+ });
+ },
+ );
+
+ await t.test(
+ "foreign Worker and DNS conflicts fail closed; failed preflight removal cannot delete",
+ async () => {
+ for (const existing of ["foreign-worker", "dns"]) {
+ await post("/api/reset", { existing });
+ const result = await post("/api/run", {
+ operation: "preflight",
+ writeIntent: false,
+ });
+ assert.equal(result.error, "resource_conflict", existing);
+ const removal = await post("/api/run", {
+ operation: "remove",
+ writeIntent: false,
+ });
+ assert.equal(removal.ok, true);
+ assert.equal(
+ removal.trace.some((x) => x.method === "DELETE"),
+ false,
+ );
+ }
+ },
+ );
+ },
+);
diff --git a/tests/domain-ui.test.mjs b/tests/domain-ui.test.mjs
new file mode 100644
index 0000000..1da52d6
--- /dev/null
+++ b/tests/domain-ui.test.mjs
@@ -0,0 +1,291 @@
+import assert from "node:assert/strict";
+import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
+import { test } from "node:test";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { createServer } from "node:net";
+import { parse } from "jsonc-parser";
+import { chromium } from "playwright";
+import { unstable_dev } from "wrangler";
+import { oauthBindings } from "./fixtures/oauth-config.mjs";
+
+test("custom-domain UI keeps management behind the publisher API", async () => {
+ const setup = await readFile("control-plane/ui/DomainSetup.tsx", "utf8");
+ const settings = await readFile("src/routes/DomainSettings.tsx", "utf8");
+
+ assert.match(setup, /\/api\/installations\/\$\{installationId\}\/domain/);
+ assert.match(setup, /Content-Type": "application\/x-www-form-urlencoded"/);
+ assert.match(setup, /domainInstallationId/);
+ assert.match(setup, /Confirm removal/);
+ assert.match(setup, /3000/);
+ assert.doesNotMatch(setup, /api\.cloudflare\.com/);
+ assert.match(settings, /fetch\("\/api\/domain"/);
+ assert.match(settings, /params\.size === 1/);
+});
+
+test(
+ "rendered domain onboarding handles pending HTTPS, replay, reconnect and confirmed removal",
+ { timeout: 120_000 },
+ async (t) => {
+ const temporary = await mkdtemp(join(tmpdir(), "flarebot-domain-ui-"));
+ const server = createServer();
+ await new Promise((done) => server.listen(0, "127.0.0.1", done));
+ const port = server.address().port;
+ await new Promise((done) => server.close(done));
+ const origin = `http://127.0.0.1:${port}`;
+ const config = join(temporary, "wrangler.json");
+ const base = parse(await readFile("wrangler.control-plane.jsonc", "utf8"));
+ await writeFile(
+ config,
+ JSON.stringify({
+ ...base,
+ name: "flarebot-domain-ui",
+ main: resolve("control-plane/index.ts"),
+ assets: {
+ binding: "ASSETS",
+ directory: resolve("dist/control-plane/client"),
+ },
+ }),
+ );
+ let worker, browser;
+ t.after(async () => {
+ await browser?.close();
+ await worker?.stop();
+ await rm(temporary, { recursive: true, force: true });
+ });
+ worker = await unstable_dev("control-plane/index.ts", {
+ config,
+ vars: oauthBindings(origin),
+ local: true,
+ ip: "127.0.0.1",
+ port,
+ inspectorPort: 0,
+ persistTo: temporary,
+ logLevel: "error",
+ experimental: { disableExperimentalWarning: true, watch: false },
+ });
+ browser = await chromium.launch({ headless: true });
+ const page = await browser.newPage({
+ viewport: { width: 1100, height: 900 },
+ });
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ const installationId = "a".repeat(32),
+ zoneId = "c".repeat(32);
+ const runtimeOrigin = `https://flarebot-${installationId}.fixture.workers.dev`;
+ const saved = {
+ revision: 1,
+ origin: "https://bot.example.com",
+ hostname: "bot.example.com",
+ zoneId,
+ domainId: null,
+ operationId: "e".repeat(32),
+ status: "connecting",
+ action: "attach",
+ errorCode: null,
+ };
+ let domain = null,
+ zoneStatus = 200,
+ zones = [{ id: zoneId, name: "example.com" }],
+ loseReply = false,
+ corruptReply = false,
+ reads = 0;
+ const requests = [];
+ let holdRead = false,
+ releaseRead;
+ await page.route("**/api/installations/*/domain**", async (route) => {
+ const request = route.request(),
+ path = new URL(request.url()).pathname;
+ const reply = (body, status = 200) =>
+ route.fulfill({
+ status,
+ contentType: "application/json",
+ body: JSON.stringify(body),
+ });
+ if (path.endsWith("/zones"))
+ return reply(
+ zoneStatus === 200
+ ? { zones }
+ : { error: "reauthorization_required" },
+ zoneStatus,
+ );
+ if (request.method() === "GET") {
+ reads++;
+ const snapshot = domain;
+ if (holdRead) {
+ holdRead = false;
+ await new Promise((resolve) => {
+ releaseRead = resolve;
+ });
+ }
+ return reply({
+ installationId,
+ accountId: "b".repeat(32),
+ runtimeOrigin,
+ domain: snapshot,
+ });
+ }
+ const form = Object.fromEntries(new URLSearchParams(request.postData()));
+ requests.push({ path, form });
+ if (path.endsWith("/remove"))
+ domain = {
+ ...saved,
+ revision: 5,
+ origin: null,
+ action: "remove",
+ status: "removing",
+ };
+ else if (path.endsWith("/retry"))
+ domain = { ...saved, revision: domain.revision + 1 };
+ else domain = { ...saved };
+ if (loseReply) {
+ loseReply = false;
+ return route.abort();
+ }
+ if (corruptReply) {
+ corruptReply = false;
+ return route.fulfill({ status: 202, body: "{" });
+ }
+ return reply({ domain }, 202);
+ });
+ const open = () =>
+ page.goto(`${origin}/connect?configureDomain=${installationId}`);
+ await open();
+ await page.getByLabel("Hostname", { exact: true }).fill("bot.example.com");
+ assert.equal(await page.getByLabel("Cloudflare zone").inputValue(), zoneId);
+ await page.screenshot({
+ path: "/tmp/flarebot-domain-setup.png",
+ fullPage: true,
+ });
+ loseReply = true;
+ await page
+ .getByRole("button", { name: "Connect domain", exact: true })
+ .click();
+ await page
+ .getByRole("button", { name: "Confirm previous request" })
+ .waitFor();
+ await page.reload();
+ corruptReply = true;
+ await page
+ .getByRole("button", { name: "Confirm previous request" })
+ .click();
+ await page
+ .getByRole("alert")
+ .filter({ hasText: "may have been received" })
+ .waitFor();
+ await page
+ .getByRole("button", { name: "Confirm previous request" })
+ .click();
+ await page.getByText(/Connecting bot.example.com/).waitFor();
+ assert.equal(
+ requests[0].form.requestId,
+ requests[1].form.requestId,
+ "lost-reply replay survives reload",
+ );
+ assert.deepEqual(requests[0].form, requests[1].form);
+ assert.deepEqual(
+ requests[0].form,
+ requests[2].form,
+ "an unreadable accepted response preserves the replay request",
+ );
+ domain = { ...saved, revision: 2, domainId: "d".repeat(32) };
+ await page.getByText(/Waiting for HTTPS on bot.example.com/).waitFor();
+ await page.screenshot({
+ path: "/tmp/flarebot-domain-pending.png",
+ fullPage: true,
+ });
+ const readCount = reads;
+ // Keep the revision unchanged for two complete polls: this caught a stalled
+ // polling effect that depended only on changes to the saved revision.
+ await page.waitForTimeout(6500);
+ assert.ok(reads >= readCount + 2);
+ holdRead = true;
+ while (!releaseRead) await page.waitForTimeout(50);
+ await page.getByText("Setup interrupted?", { exact: true }).click();
+ await page.getByRole("button", { name: "Restart saved operation" }).click();
+ await page.getByText(/Connecting bot.example.com/).waitFor();
+ releaseRead();
+ await page.waitForTimeout(300);
+ assert.equal(
+ await page.getByText(/Waiting for HTTPS/).count(),
+ 0,
+ "an older poll cannot overwrite the newer restart response",
+ );
+ domain = {
+ ...domain,
+ revision: 4,
+ domainId: "d".repeat(32),
+ status: "active",
+ };
+ const link = page.getByRole("link", {
+ name: "Open Flarebot at bot.example.com",
+ });
+ await link.waitFor();
+ assert.equal(await link.getAttribute("href"), saved.origin);
+ await page.screenshot({
+ path: "/tmp/flarebot-domain-active.png",
+ fullPage: true,
+ });
+ await page.getByRole("button", { name: "Remove custom domain" }).click();
+ assert.equal(
+ requests.length,
+ 4,
+ "removal requires a second explicit confirmation",
+ );
+ await page.getByRole("button", { name: "Confirm removal" }).click();
+ await page.getByText(/Removing bot.example.com/).waitFor();
+ await page.screenshot({
+ path: "/tmp/flarebot-domain-removing.png",
+ fullPage: true,
+ });
+ domain = { ...domain, revision: 6, status: "removed" };
+ await page
+ .getByRole("button", { name: "Connect domain", exact: true })
+ .waitFor();
+ assert.equal(requests.at(-1).path.endsWith("/remove"), true);
+ domain = { ...saved, status: "failed", errorCode: "resource_conflict" };
+ await page.reload();
+ await page.getByRole("alert").filter({ hasText: "conflicts" }).waitFor();
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.screenshot({
+ path: "/tmp/flarebot-domain-conflict-mobile.png",
+ fullPage: true,
+ });
+ assert.ok(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= window.innerWidth,
+ ),
+ );
+ domain = {
+ ...saved,
+ status: "failed",
+ errorCode: "attachment_outcome_unknown",
+ };
+ await page.reload();
+ await page
+ .getByRole("alert")
+ .filter({ hasText: "not confirmed" })
+ .waitFor();
+ await page.screenshot({
+ path: "/tmp/flarebot-domain-unknown.png",
+ fullPage: true,
+ });
+ domain = null;
+ zoneStatus = 401;
+ await page.reload();
+ await page.getByRole("button", { name: "Reconnect Cloudflare" }).waitFor();
+ assert.equal(
+ await page.locator('input[name="domainInstallationId"]').inputValue(),
+ installationId,
+ );
+ await page.screenshot({
+ path: "/tmp/flarebot-domain-reconnect.png",
+ fullPage: true,
+ });
+ zoneStatus = 200;
+ zones = [];
+ await page.reload();
+ await page.getByText(/No active Cloudflare zones were found/).waitFor();
+ assert.deepEqual(errors, []);
+ },
+);
diff --git a/tests/domain-workflow.test.mjs b/tests/domain-workflow.test.mjs
new file mode 100644
index 0000000..96df69c
--- /dev/null
+++ b/tests/domain-workflow.test.mjs
@@ -0,0 +1,253 @@
+import assert from "node:assert/strict";
+import { generateKeyPairSync, randomBytes } from "node:crypto";
+import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
+import { createServer } from "node:net";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { test } from "node:test";
+import { parse } from "jsonc-parser";
+import { unstable_dev } from "wrangler";
+import { oauthBindings, oauthConfig } from "./fixtures/oauth-config.mjs";
+
+test(
+ "native domain Workflow reconciles lost replies, checks HTTPS, and revokes before removal",
+ { timeout: 180_000 },
+ async (t) => {
+ const temporary = await mkdtemp(
+ join(tmpdir(), "flarebot-domain-workflow-"),
+ );
+ const server = createServer();
+ await new Promise((done) => server.listen(0, "127.0.0.1", done));
+ const port = server.address().port;
+ await new Promise((done) => server.close(done));
+ const origin = `http://127.0.0.1:${port}`;
+ const base = parse(await readFile("wrangler.control-plane.jsonc", "utf8"));
+ const configPath = join(temporary, "wrangler.json");
+ await writeFile(
+ configPath,
+ JSON.stringify({
+ ...base,
+ name: "flarebot-domain-flow-test",
+ main: resolve("tests/fixtures/domain-workflow-worker.ts"),
+ assets: undefined,
+ durable_objects: {
+ bindings: [
+ ...base.durable_objects.bindings,
+ { name: "DOMAIN_PROVIDER", class_name: "DomainProvider" },
+ ],
+ },
+ exports: {
+ ...base.exports,
+ DomainProvider: { type: "durable-object", storage: "sqlite" },
+ },
+ }),
+ );
+ const keys = generateKeyPairSync("ed25519");
+ const config = oauthConfig(origin);
+ config.bridge = {
+ keyId: "fixture",
+ publicKey: keys.publicKey.export({ format: "jwk" }).x,
+ };
+ config.oauthCapabilities.scopes[2].capabilities.push(
+ "domain-zones",
+ "domain-routing",
+ );
+ const worker = await unstable_dev(
+ "tests/fixtures/domain-workflow-worker.ts",
+ {
+ config: configPath,
+ vars: {
+ ...oauthBindings(origin),
+ FLAREBOT_CONTROL_PLANE: JSON.stringify(config),
+ FLAREBOT_BRIDGE_SIGNING_KEY: keys.privateKey
+ .export({ format: "der", type: "pkcs8" })
+ .toString("base64url"),
+ },
+ local: true,
+ ip: "127.0.0.1",
+ port,
+ inspectorPort: 0,
+ persistTo: temporary,
+ logLevel: "error",
+ experimental: { disableExperimentalWarning: true, watch: false },
+ },
+ );
+ t.after(async () => {
+ await worker.stop();
+ await rm(temporary, { recursive: true, force: true });
+ });
+ const post = (path, body) =>
+ fetch(origin + path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const id = randomBytes(16).toString("hex");
+ const auth = await (await post("/fixture/seed", { id })).json();
+ const endpoint = `/api/installations/${id}/domain`;
+ const form = (suffix, values, cookie = auth.cookie, csrf = origin) =>
+ fetch(origin + endpoint + suffix, {
+ method: "POST",
+ headers: {
+ Cookie: cookie,
+ Origin: csrf,
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams(values),
+ });
+ const read = () =>
+ fetch(origin + endpoint, { headers: { Cookie: auth.cookie } });
+ const wait = async (status) => {
+ let domain;
+ for (let n = 0; n < 160; n++) {
+ domain = (await (await read()).json()).domain;
+ if (domain?.status === status) return domain;
+ if (domain?.status === "failed" && status !== "failed")
+ assert.fail(JSON.stringify(domain));
+ await new Promise((done) => setTimeout(done, 250));
+ }
+ assert.fail(`Expected ${status}, got ${JSON.stringify(domain)}`);
+ };
+ assert.equal(
+ (await form("", {}, auth.cookie, "https://attacker.example")).status,
+ 403,
+ );
+ assert.equal((await fetch(origin + endpoint)).status, 401);
+ const other = await (
+ await post("/fixture/seed", { id: "f".repeat(32), owner: "other-owner" })
+ ).json();
+ assert.equal(
+ (await fetch(origin + endpoint, { headers: { Cookie: other.cookie } }))
+ .status,
+ 404,
+ );
+ await post("/fixture/provider", { losePut: true });
+ const request = {
+ requestId: randomBytes(16).toString("hex"),
+ zoneId: "c".repeat(32),
+ hostname: "bot.example.com",
+ };
+ const replies = await Promise.all([form("", request), form("", request)]);
+ const bodies = await Promise.all(replies.map((r) => r.json()));
+ assert.deepEqual(
+ replies.map((r) => r.status),
+ [202, 202],
+ JSON.stringify(bodies),
+ );
+ assert.equal(bodies[0].domain.operationId, bodies[1].domain.operationId);
+ assert.equal(bodies[0].domain.deadline, bodies[1].domain.deadline);
+ const active = await wait("active");
+ assert.equal(active.origin, "https://bot.example.com");
+ let provider = await (await fetch(origin + "/fixture/provider")).json();
+ assert.equal(
+ provider.trace.filter((row) => row.method === "PUT").length,
+ 1,
+ );
+ assert.ok(provider.trace.some((row) => row.path === "/auth/domain-health"));
+ assert.equal(JSON.stringify(active).includes("test-token"), false);
+ const removalRequest = { requestId: randomBytes(16).toString("hex") };
+ assert.equal((await form("/remove", removalRequest)).status, 202);
+ const removed = await wait("removed");
+ assert.equal(removed.origin, null);
+ provider = await (await fetch(origin + "/fixture/provider")).json();
+ assert.equal(provider.domain, null);
+ assert.equal(provider.origin, null);
+ const deletion = provider.trace.findIndex((row) => row.method === "DELETE");
+ assert.equal(
+ provider.trace[deletion - 2]?.path === "/auth/domain-configuration" ||
+ provider.trace
+ .slice(0, deletion)
+ .some((row) => row.path === "/auth/domain-configuration"),
+ true,
+ );
+ assert.equal(
+ (await form("/remove", removalRequest)).status,
+ 200,
+ "completed removal is replayable",
+ );
+ await post("/fixture/provider", { conflict: true });
+ const nextRequest = {
+ ...request,
+ requestId: randomBytes(16).toString("hex"),
+ };
+ assert.equal((await form("", nextRequest)).status, 202);
+ assert.equal((await wait("failed")).errorCode, "resource_conflict");
+ await post("/fixture/provider", { conflict: false });
+ assert.equal(
+ (await form("/retry", { requestId: randomBytes(16).toString("hex") }))
+ .status,
+ 202,
+ );
+ await wait("active");
+ assert.equal(
+ (await form("/remove", { requestId: randomBytes(16).toString("hex") }))
+ .status,
+ 202,
+ );
+ await wait("removed");
+ await post("/fixture/provider", { delayPut: true });
+ assert.equal(
+ (
+ await form("", {
+ ...request,
+ requestId: randomBytes(16).toString("hex"),
+ })
+ ).status,
+ 202,
+ );
+ const unknown = await wait("failed");
+ assert.equal(unknown.errorCode, "attachment_outcome_unknown");
+ assert.equal(
+ (await form("/remove", { requestId: randomBytes(16).toString("hex") }))
+ .status,
+ 409,
+ );
+ assert.equal(
+ (await (await read()).json()).domain.operationId,
+ unknown.operationId,
+ "rejected cancellation cannot supersede unresolved attachment",
+ );
+ const countPuts = async () =>
+ (await (await fetch(origin + "/fixture/provider")).json()).trace.filter(
+ (row) => row.method === "PUT",
+ ).length;
+ const beforeRetry = await countPuts();
+ assert.equal(
+ (await form("/retry", { requestId: randomBytes(16).toString("hex") }))
+ .status,
+ 202,
+ );
+ assert.equal(
+ (await wait("failed")).errorCode,
+ "attachment_outcome_unknown",
+ );
+ assert.equal(
+ await countPuts(),
+ beforeRetry,
+ "absence cannot authorize a second PUT while the first may still commit",
+ );
+ await post("/fixture/provider", { commitPending: true });
+ assert.equal(
+ (await form("/retry", { requestId: randomBytes(16).toString("hex") }))
+ .status,
+ 202,
+ );
+ await wait("active");
+ assert.equal(
+ await countPuts(),
+ beforeRetry,
+ "late first write is reconciled, not repeated",
+ );
+ await post("/fixture/expire", { grantRef: auth.grantRef });
+ assert.equal(
+ (await read()).status,
+ 200,
+ "status remains readable after grant expiry",
+ );
+ assert.equal(
+ (await form("/remove", { requestId: randomBytes(16).toString("hex") }))
+ .status,
+ 401,
+ );
+ },
+);
diff --git a/tests/fixtures/bridge-control-worker.ts b/tests/fixtures/bridge-control-worker.ts
index 4652eb1..48f729d 100644
--- a/tests/fixtures/bridge-control-worker.ts
+++ b/tests/fixtures/bridge-control-worker.ts
@@ -18,6 +18,7 @@ import type {
OperationBinding,
} from "../../control-plane/vault.ts";
import type { BridgeClaims } from "../../shared/bridge.ts";
+import { unwrap } from "../../control-plane/installation-metadata.ts";
export { InstallationRegistry, AuthVault };
const providerState: {
gateway?: any;
@@ -84,6 +85,44 @@ export default {
await stub.fixturePut(record.installationId, record);
return Response.json({ ok: true });
}
+ if (input.pathname === "/__bridge__/domain") {
+ const body = (await request.json()) as {
+ owner: string;
+ id: string;
+ remove?: boolean;
+ };
+ const stub = installationRegistry(env, body.owner);
+ const domain = unwrap(
+ await stub.startDomain(
+ body.owner,
+ body.id,
+ crypto.randomUUID().replaceAll("-", ""),
+ body.remove
+ ? { action: "remove" }
+ : {
+ action: "attach",
+ origin: "https://bot.example.com",
+ zoneId: "c".repeat(32),
+ },
+ ),
+ );
+ return Response.json(
+ unwrap(
+ await stub.updateDomain(
+ body.owner,
+ body.id,
+ domain.operationId,
+ domain.revision,
+ {
+ status: body.remove ? "removed" : "active",
+ errorCode: null,
+ writeIntent: true,
+ domainId: "d".repeat(32),
+ },
+ ),
+ ),
+ );
+ }
if (input.pathname === "/__bridge__/health") {
try {
const record =
diff --git a/tests/fixtures/bridge-customer-worker.ts b/tests/fixtures/bridge-customer-worker.ts
index 22e651c..b2ffa9a 100644
--- a/tests/fixtures/bridge-customer-worker.ts
+++ b/tests/fixtures/bridge-customer-worker.ts
@@ -88,10 +88,17 @@ export default {
}
return Response.json(await personal.fixtureInspect());
}
+ const personal = await getAgentByName(env.PersonalAgent, "personal");
+ const requestOrigin = new URL(request.url).origin;
+ const effectiveInstallation =
+ requestOrigin !== installation.runtimeOrigin &&
+ (await personal.configuredDomainOrigin()) === requestOrigin
+ ? { ...installation, runtimeOrigin: requestOrigin }
+ : installation;
const bridge = await handleCustomerBridge(
request,
env,
- installation,
+ effectiveInstallation,
loadCustomerSecrets(env).sessionSecret,
async (input) => {
const outgoing = new Request(input);
diff --git a/tests/fixtures/domain-worker.ts b/tests/fixtures/domain-worker.ts
new file mode 100644
index 0000000..0afd36c
--- /dev/null
+++ b/tests/fixtures/domain-worker.ts
@@ -0,0 +1,209 @@
+import { DomainAPI } from "../../control-plane/domain-api.ts";
+import { DomainError } from "../../control-plane/domain-metadata.ts";
+import { InstallationRegistry as NativeRegistry } from "../../control-plane/installation-registry.ts";
+export { AuthVault } from "./oauth-worker.ts";
+export { InstallationWorkflow } from "../../control-plane/installation-workflow.ts";
+
+interface Env {
+ INSTALLATIONS: DurableObjectNamespace;
+}
+export class InstallationRegistry extends NativeRegistry {
+ fixturePut(id: string, value: unknown) {
+ return this.ctx.storage.put(`installation:${id}`, value);
+ }
+}
+
+const domainId = "d".repeat(32);
+let attached = false;
+let losePutReply = false;
+let existing: "none" | "foreign-worker" | "dns" = "none";
+const trace: Array<{ path: string; method: string; body: unknown }> = [];
+const ok = (result: unknown, result_info?: unknown) =>
+ Response.json({
+ success: true,
+ result,
+ ...(result_info ? { result_info } : {}),
+ });
+
+function installation(id: string, ownerSubject: string) {
+ const release = {
+ version: "1.0.0",
+ sourceRevision: "1".repeat(40),
+ artifactDigest: "2".repeat(64),
+ };
+ return {
+ schemaVersion: 1,
+ installationId: id,
+ ownerSubject,
+ accountId: "a".repeat(32),
+ createdAt: 1,
+ updatedAt: 2,
+ revision: 7,
+ resources: {
+ workerName: `flarebot-${id}`,
+ sandboxApplicationName: `flarebot-shell-${id}`,
+ personalAgentNamespaceId: "3".repeat(32),
+ sandboxNamespaceId: "4".repeat(32),
+ sandboxApplicationId: "5".repeat(32),
+ runtimeOrigin: `https://flarebot-${id}.fixture.workers.dev`,
+ },
+ desiredRelease: release,
+ installedRelease: { ...release, installedAt: 2 },
+ status: "ready",
+ progress: "complete",
+ errorCode: null,
+ operationId: "6".repeat(32),
+ };
+}
+
+function registry(env: Env, subject: string) {
+ return env.INSTALLATIONS.getByName(
+ `owner:${subject}`,
+ ) as unknown as DurableObjectStub;
+}
+
+export default {
+ async fetch(request: Request, env: Env) {
+ const url = new URL(request.url);
+ const body =
+ request.method === "POST" ? ((await request.json()) as any) : {};
+ if (url.pathname === "/registry") {
+ const stub = registry(env, body.owner);
+ if (body.action === "seed")
+ return Response.json(
+ await stub
+ .fixturePut(
+ body.id,
+ body.value ?? installation(body.id, body.owner),
+ )
+ .then(() => ({ ok: true })),
+ );
+ if (body.action === "get")
+ return Response.json(
+ await stub.get(body.subject ?? body.owner, body.id),
+ );
+ if (body.action === "getDomain")
+ return Response.json(
+ await stub.getDomain(body.subject ?? body.owner, body.id),
+ );
+ if (body.action === "start")
+ return Response.json(
+ await stub.startDomain(
+ body.owner,
+ body.id,
+ body.requestId,
+ body.intent,
+ ),
+ );
+ if (body.action === "update")
+ return Response.json(
+ await stub.updateDomain(
+ body.owner,
+ body.id,
+ body.operationId,
+ body.revision,
+ body.changes,
+ ),
+ );
+ }
+ if (url.pathname === "/api/reset") {
+ attached = false;
+ losePutReply = !!body.losePutReply;
+ existing = body.existing ?? "none";
+ trace.length = 0;
+ return Response.json({ ok: true });
+ }
+ if (url.pathname === "/api/run") {
+ const id = body.installationId ?? "b".repeat(32);
+ const record = installation(id, "owner");
+ const domain = {
+ revision: 1,
+ origin: "https://bot.example.com",
+ hostname: "bot.example.com",
+ zoneId: "c".repeat(32),
+ domainId: body.savedDomainId ?? null,
+ operationId: "e".repeat(32),
+ status: "connecting",
+ action: "attach",
+ errorCode: null,
+ writeIntent: body.writeIntent ?? true,
+ };
+ const network = async (input: RequestInfo | URL, init?: RequestInit) => {
+ const outgoing = new Request(input, init);
+ const path =
+ new URL(outgoing.url).pathname + new URL(outgoing.url).search;
+ const requestBody = outgoing.body
+ ? await outgoing.clone().json()
+ : null;
+ trace.push({ path, method: outgoing.method, body: requestBody });
+ if (path === `/client/v4/zones/${domain.zoneId}`)
+ return ok({
+ id: domain.zoneId,
+ name: "example.com",
+ account: { id: record.accountId },
+ status: "active",
+ });
+ if (path.includes("/dns_records"))
+ return ok(existing === "dns" ? [{ id: "x" }] : []);
+ if (path.includes("/workers/domains?")) {
+ if (!attached && existing !== "foreign-worker")
+ return ok([], { total_pages: 1 });
+ return ok(
+ [
+ {
+ id: domainId,
+ hostname: domain.hostname,
+ zone_id: domain.zoneId,
+ service:
+ existing === "foreign-worker"
+ ? "somebody-else"
+ : record.resources.workerName,
+ environment: "production",
+ },
+ ],
+ { total_pages: 1 },
+ );
+ }
+ if (path.endsWith("/domains/records") && outgoing.method === "PUT") {
+ attached = true;
+ if (losePutReply) {
+ losePutReply = false;
+ throw new Error("lost reply");
+ }
+ return ok({});
+ }
+ if (
+ path.endsWith(`/workers/domains/${domainId}`) &&
+ outgoing.method === "DELETE"
+ ) {
+ attached = false;
+ return ok({});
+ }
+ throw new Error(`unexpected request ${outgoing.method} ${path}`);
+ };
+ try {
+ const api = new DomainAPI(
+ "token",
+ record as any,
+ network as typeof fetch,
+ );
+ const value =
+ body.operation === "preflight"
+ ? await api.preflight(domain as any)
+ : body.operation === "remove"
+ ? await api.remove(domain as any)
+ : body.operation === "find"
+ ? await api.find(domain as any)
+ : await api.attach(domain as any);
+ return Response.json({ ok: true, value, trace });
+ } catch (error) {
+ return Response.json({
+ ok: false,
+ error: error instanceof DomainError ? error.code : "unexpected",
+ trace,
+ });
+ }
+ }
+ return new Response(null, { status: 404 });
+ },
+} satisfies ExportedHandler;
diff --git a/tests/fixtures/domain-workflow-worker.ts b/tests/fixtures/domain-workflow-worker.ts
new file mode 100644
index 0000000..e51f31e
--- /dev/null
+++ b/tests/fixtures/domain-workflow-worker.ts
@@ -0,0 +1,214 @@
+// Native Workflow/DO test harness. Only Cloudflare's external responses are fake.
+import { DurableObject } from "cloudflare:workers";
+import { InstallationWorkflow as NativeWorkflow } from "../../control-plane/installation-workflow.ts";
+import { InstallationRegistry } from "./domain-worker.ts";
+import { AuthVault } from "../../control-plane/vault.ts";
+import { handleInstallations } from "../../control-plane/installations.ts";
+import {
+ random,
+ decode,
+ verifyAssertion,
+ DOMAIN_CONFIGURATION_PURPOSE,
+ DOMAIN_HEALTH_PURPOSE,
+} from "../../shared/bridge.ts";
+import { vault } from "../../control-plane/session.ts";
+import { loadControlPlaneConfig } from "../../configuration/control-plane.ts";
+import type { Env as ControlEnv } from "../../control-plane/config.ts";
+export { InstallationRegistry, AuthVault };
+interface Env extends ControlEnv {
+ DOMAIN_PROVIDER: DurableObjectNamespace;
+}
+const network =
+ (env: Env): typeof fetch =>
+ (input, init) =>
+ env.DOMAIN_PROVIDER.getByName("provider").fetch(new Request(input, init));
+export class InstallationWorkflow extends NativeWorkflow {
+ protected network() {
+ return network(this.env as Env);
+ }
+}
+export class DomainProvider extends DurableObject {
+ async fetch(request: Request) {
+ const url = new URL(request.url);
+ const state = (await this.ctx.storage.get("state")) ?? {
+ domain: null,
+ origin: null,
+ revision: 0,
+ trace: [],
+ losePut: false,
+ conflict: false,
+ };
+ if (url.pathname === "/fixture") {
+ if (request.method === "POST") {
+ Object.assign(state, await request.json());
+ if (state.commitPending) {
+ state.domain = state.pendingDomain;
+ state.pendingDomain = null;
+ state.commitPending = false;
+ }
+ await this.ctx.storage.put("state", state);
+ }
+ return Response.json(state);
+ }
+ const ok = (result: unknown) =>
+ Response.json({ success: true, result, result_info: { total_pages: 1 } });
+ const data = request.body ? await request.json() : null;
+ state.trace.push({ method: request.method, path: url.pathname, data });
+ let response: Response;
+ if (url.pathname === "/client/v4/accounts")
+ response = ok([{ id: "a".repeat(32), name: "Customer" }]);
+ else if (url.pathname === `/client/v4/zones/${"c".repeat(32)}`)
+ response = ok({
+ id: "c".repeat(32),
+ name: "example.com",
+ status: "active",
+ account: { id: "a".repeat(32) },
+ });
+ else if (url.pathname.endsWith("/dns_records"))
+ response = ok(state.conflict ? [{ id: "existing-dns" }] : []);
+ else if (url.pathname.endsWith("/workers/domains"))
+ response = ok(state.domain ? [state.domain] : []);
+ else if (
+ url.pathname.endsWith("/domains/records") &&
+ request.method === "PUT"
+ ) {
+ state.domain = {
+ id: "d".repeat(32),
+ hostname: (data as any).origins[0].hostname,
+ zone_id: "c".repeat(32),
+ service: url.pathname.split("/").at(-3),
+ };
+ response = state.losePut ? new Response(null, { status: 503 }) : ok({});
+ state.losePut = false;
+ if (state.delayPut) {
+ state.pendingDomain = state.domain;
+ state.domain = null;
+ state.delayPut = false;
+ response = new Response(null, { status: 503 });
+ }
+ } else if (
+ url.pathname.endsWith(`/workers/domains/${"d".repeat(32)}`) &&
+ request.method === "DELETE"
+ ) {
+ if (state.origin !== null)
+ throw new Error("Runtime must be revoked before deletion");
+ state.domain = null;
+ response = ok({});
+ } else if (
+ ["/auth/domain-configuration", "/auth/domain-health"].includes(
+ url.pathname,
+ )
+ ) {
+ const token = request.headers.get("Authorization")!.slice(7);
+ const claims = JSON.parse(
+ new TextDecoder().decode(decode(token.split(".")[1])),
+ );
+ const { iat, exp, jti, ...expected } = claims;
+ await verifyAssertion(
+ token,
+ loadControlPlaneConfig(this.env).config.bridge!,
+ expected,
+ );
+ if (url.pathname === "/auth/domain-configuration") {
+ if (
+ claims.purpose !== DOMAIN_CONFIGURATION_PURPOSE ||
+ Number(claims.state) < state.revision
+ )
+ throw new Error("Invalid configuration");
+ state.revision = Number(claims.state);
+ state.origin = claims.challenge || null;
+ response = Response.json({
+ revision: state.revision,
+ origin: state.origin,
+ });
+ } else {
+ if (
+ claims.purpose !== DOMAIN_HEALTH_PURPOSE ||
+ url.origin !== state.origin
+ )
+ throw new Error("Invalid health");
+ response = Response.json({
+ installationId: claims.installationId,
+ origin: state.origin,
+ state: claims.state,
+ challenge: claims.challenge,
+ });
+ }
+ } else throw new Error(`Unexpected fixture request ${url.pathname}`);
+ await this.ctx.storage.put("state", state);
+ return response;
+ }
+}
+export default {
+ async fetch(request: Request, env: Env) {
+ const url = new URL(request.url);
+ if (url.pathname === "/fixture/provider")
+ return env.DOMAIN_PROVIDER.getByName("provider").fetch(
+ new Request("https://fixture/fixture", request),
+ );
+ if (url.pathname === "/fixture/seed") {
+ const { id, owner = "owner" } = (await request.json()) as {
+ id: string;
+ owner?: string;
+ };
+ const registry = env.INSTALLATIONS.getByName(
+ `owner:${owner}`,
+ ) as unknown as DurableObjectStub;
+ const release = {
+ version: "1.0.0",
+ sourceRevision: "1".repeat(40),
+ artifactDigest: "2".repeat(64),
+ };
+ await registry.fixturePut(id, {
+ schemaVersion: 1,
+ installationId: id,
+ ownerSubject: owner,
+ accountId: "a".repeat(32),
+ createdAt: 1,
+ updatedAt: 2,
+ revision: 1,
+ resources: {
+ workerName: `flarebot-${id}`,
+ sandboxApplicationName: `flarebot-shell-${id}`,
+ personalAgentNamespaceId: "3".repeat(32),
+ sandboxNamespaceId: "4".repeat(32),
+ sandboxApplicationId: "5".repeat(32),
+ runtimeOrigin: `https://flarebot-${id}.fixture.workers.dev`,
+ },
+ desiredRelease: release,
+ installedRelease: { ...release, installedAt: 2 },
+ status: "ready",
+ progress: "complete",
+ errorCode: null,
+ operationId: "6".repeat(32),
+ });
+ const session = random(),
+ grantRef = random();
+ await vault(env, "grant", grantRef).createGrant({
+ subject: owner,
+ accessToken: "test-token",
+ scopes: ["openid", "account.read", "workers-platform.write"],
+ expiresAt: Date.now() + 3600_000,
+ });
+ await vault(env, "session", session).createSession({
+ subject: owner,
+ grantRef,
+ selectedAccountId: null,
+ expiresAt: Date.now() + 3600_000,
+ });
+ return Response.json({
+ cookie: `__Host-flarebot-control-session=${session}`,
+ grantRef,
+ });
+ }
+ if (url.pathname === "/fixture/expire") {
+ const { grantRef } = (await request.json()) as { grantRef: string };
+ await vault(env, "grant", grantRef).destroy();
+ return Response.json({ ok: true });
+ }
+ return (
+ (await handleInstallations(request, env, network(env))) ??
+ new Response(null, { status: 404 })
+ );
+ },
+} satisfies ExportedHandler;
diff --git a/tests/worker.test.mjs b/tests/worker.test.mjs
index f2d5cce..5985bc2 100644
--- a/tests/worker.test.mjs
+++ b/tests/worker.test.mjs
@@ -29,6 +29,11 @@ test(
assert.doesNotMatch(html, /Something went wrong!/);
assert.ok(!html.includes(customerBindings.FLAREBOT_SESSION_SECRET));
+ // Public assets can render on the local test host, but that does not
+ // approve it as a login or private API origin.
+ for (const path of ["/auth/login", "/api/domain", "/api"])
+ assert.equal((await worker.fetch(path)).status, 403, path);
+
const assets = [
...html.matchAll(/(?:src|href)="(\/assets\/[^"\s]+\.(?:js|css))"/g),
];
diff --git a/worker/bridge.ts b/worker/bridge.ts
index ea9e987..d98a615 100644
--- a/worker/bridge.ts
+++ b/worker/bridge.ts
@@ -1,5 +1,8 @@
import { getAgentByName } from "agents";
-import type { InstallationConfig } from "../configuration/customer";
+import {
+ loadCustomerConfig,
+ type InstallationConfig,
+} from "../configuration/customer";
import type { Secret } from "../configuration/secrets";
import {
decode,
@@ -66,11 +69,56 @@ export async function handleCustomerBridge(
"/auth/callback",
"/auth/bootstrap-health",
"/auth/provider-enabled",
+ "/auth/domain-configuration",
+ "/auth/domain-health",
].includes(url.pathname)
)
return null;
try {
- if (url.origin !== installation.runtimeOrigin || request.url.length > 2048)
+ if (request.url.length > 2048) throw new Error("Bridge unavailable");
+ const personal = await getAgentByName(env.PersonalAgent, "personal");
+ if (url.pathname === "/auth/domain-configuration") {
+ if (
+ url.origin !==
+ loadCustomerConfig(env).effectiveInstallation.runtimeOrigin ||
+ request.method !== "POST" ||
+ url.search ||
+ request.headers.has("Origin") ||
+ request.headers.has("Cookie") ||
+ request.headers.has("Sec-Fetch-Site")
+ )
+ throw new Error("Invalid domain configuration");
+ const assertion = request.headers
+ .get("Authorization")
+ ?.match(/^Bearer ([A-Za-z0-9_.-]{1,4096})$/)?.[1];
+ if (!assertion) throw new Error("Invalid domain configuration");
+ return response(
+ JSON.stringify(await personal.configureDomain(assertion)),
+ 200,
+ { "Content-Type": "application/json" },
+ );
+ }
+ if (url.pathname === "/auth/domain-health") {
+ if (
+ request.method !== "POST" ||
+ url.search ||
+ request.headers.has("Origin") ||
+ request.headers.has("Cookie") ||
+ request.headers.has("Sec-Fetch-Site") ||
+ (await personal.configuredDomainOrigin()) !== url.origin
+ )
+ throw new Error("Invalid domain health");
+ const assertion = request.headers
+ .get("Authorization")
+ ?.match(/^Bearer ([A-Za-z0-9_.-]{1,4096})$/)?.[1];
+ if (!assertion) throw new Error("Invalid domain health");
+ return response(
+ JSON.stringify(await personal.domainHealth(assertion, url.origin)),
+ 200,
+ { "Content-Type": "application/json" },
+ );
+ }
+ if (url.origin !== installation.runtimeOrigin)
throw new Error("Bridge unavailable");
if (
env.FLAREBOT_ENV === "development" &&
@@ -99,21 +147,19 @@ export async function handleCustomerBridge(
.get("Authorization")
?.match(/^Bearer ([A-Za-z0-9_.-]{1,4096})$/)?.[1];
if (!assertion) throw new Error("Invalid provider receipt");
- const personal = await getAgentByName(env.PersonalAgent, "personal");
const enabled = await personal.enableOpenRouter(assertion);
return response(JSON.stringify(enabled), 200, {
"Content-Type": "application/json",
});
}
if (url.pathname === "/auth/login" && navigation(request) && !url.search) {
- const personal = await getAgentByName(env.PersonalAgent, "personal");
const state = random();
const binding = random();
const verifier = random();
const challenge = await hash(verifier);
await personal.createLoginChallenge({
state,
- bindingHash: await hash(binding),
+ bindingHash: await hash(`${binding}.${installation.runtimeOrigin}`),
verifier,
challenge,
// Leave room for independent Worker and Durable Object request clocks.
@@ -128,6 +174,7 @@ export async function handleCustomerBridge(
installationId: installation.installationId,
state,
challenge,
+ audience: installation.runtimeOrigin,
}).toString();
return response(null, 303, {
Location: destination.href,
@@ -147,10 +194,9 @@ export async function handleCustomerBridge(
const binding = challengeCookie(request);
if (!opaque(code) || !opaque(state) || !binding)
throw new Error("Invalid callback");
- const personal = await getAgentByName(env.PersonalAgent, "personal");
const challenge = await personal.claimLoginChallenge(
state,
- await hash(binding),
+ await hash(`${binding}.${installation.runtimeOrigin}`),
);
if (!challenge) throw new Error("Invalid challenge");
const exchanged = await network(
diff --git a/worker/index.ts b/worker/index.ts
index b7c66d6..81acc09 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -34,21 +34,63 @@ export default {
},
);
}
+ const managementInstallation = config.effectiveInstallation;
+ const requestOrigin = new URL(request.url).origin;
+ let effectiveInstallation = managementInstallation;
+ let approvedDomain: string | null = null;
+ if (requestOrigin !== managementInstallation.runtimeOrigin) {
+ try {
+ const personal = await getAgentByName(env.PersonalAgent, "personal");
+ approvedDomain = await personal.configuredDomainOrigin();
+ } catch {
+ return privateResponse("Forbidden", 403);
+ }
+ if (approvedDomain === requestOrigin)
+ effectiveInstallation = {
+ ...managementInstallation,
+ runtimeOrigin: requestOrigin,
+ };
+ }
const bridge = await handleCustomerBridge(
request,
env,
- config.effectiveInstallation,
+ effectiveInstallation,
secrets.sessionSecret,
);
if (bridge) return bridge;
const update = await updateOnOwnerVisit(
request,
- config.effectiveInstallation,
+ effectiveInstallation,
secrets.sessionSecret,
config.environment === "production",
);
if (update) return update;
const path = new URL(request.url).pathname;
+ if (path === "/api/domain" && request.method === "GET") {
+ const denied = await authorizeRuntimeRequest(
+ request,
+ secrets.sessionSecret,
+ effectiveInstallation,
+ );
+ if (denied) return denied;
+ const personal = await getAgentByName(env.PersonalAgent, "personal");
+ const origin =
+ approvedDomain ?? (await personal.configuredDomainOrigin());
+ const setup = new URL(
+ "/connect",
+ effectiveInstallation.controlPlaneOrigin,
+ );
+ setup.searchParams.set(
+ "configureDomain",
+ effectiveInstallation.installationId,
+ );
+ return Response.json(
+ { setupUrl: setup.href, origin },
+ {
+ headers: { "Cache-Control": "no-store" },
+ },
+ );
+ }
if (
path.startsWith("/agents/") ||
path === "/api" ||
@@ -57,7 +99,7 @@ export default {
const denied = await authorizeRuntimeRequest(
request,
secrets.sessionSecret,
- config.effectiveInstallation,
+ effectiveInstallation,
);
if (denied) return denied;
const conversationId = conversationIdFromPath(path);
diff --git a/worker/personal-agent.ts b/worker/personal-agent.ts
index 5287463..71ba47c 100644
--- a/worker/personal-agent.ts
+++ b/worker/personal-agent.ts
@@ -9,11 +9,14 @@ import { version, dependencies } from "../package.json";
import { BridgeStore, type LoginChallenge } from "./bridge-store";
import type { BridgeClaims } from "../shared/bridge";
import {
+ DOMAIN_CONFIGURATION_PURPOSE,
+ DOMAIN_HEALTH_PURPOSE,
HEALTH_PURPOSE,
PROVIDER_PURPOSE,
opaque,
verifyAssertion,
} from "../shared/bridge";
+import { customDomainOrigin } from "../shared/domain-origin";
import { parseSSEStream, type ExecEvent } from "@cloudflare/sandbox";
import type { ConversationSummary } from "../shared/conversations";
import { runtimeInfo } from "./runtime-info";
@@ -185,6 +188,11 @@ export class PersonalAgent extends Agent {
owner_subject TEXT NOT NULL,
created_at TEXT NOT NULL
)`;
+ this.sql`CREATE TABLE IF NOT EXISTS flarebot_domain_configuration (
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
+ revision INTEGER NOT NULL,
+ origin TEXT
+ )`;
this.sql`INSERT OR IGNORE INTO flarebot_runtime
(singleton, schema_version, installation_id, owner_subject, created_at)
VALUES (1, 1, ${installation.installationId}, ${installation.ownerSubject}, ${new Date().toISOString()})`;
@@ -344,6 +352,101 @@ export class PersonalAgent extends Agent {
claimLoginChallenge(state: string, bindingHash: string) {
return this.#bridgeStore.claim(state, bindingHash);
}
+ configuredDomainOrigin(): string | null {
+ return (
+ this.sql<{ origin: string | null }>`SELECT origin FROM
+ flarebot_domain_configuration WHERE singleton = 1`[0]?.origin ?? null
+ );
+ }
+ async configureDomain(assertion: string) {
+ const installation = loadCustomerConfig(this.env).effectiveInstallation;
+ if (!installation.bridge)
+ throw new Error("Domain configuration unavailable");
+ const parts = assertion.split(".");
+ if (parts.length !== 3) throw new Error("Invalid domain configuration");
+ const claims = JSON.parse(
+ new TextDecoder().decode(
+ Uint8Array.from(
+ atob(parts[1].replaceAll("-", "+").replaceAll("_", "/")),
+ (c) => c.charCodeAt(0),
+ ),
+ ),
+ ) as BridgeClaims;
+ const revision = Number(claims.state);
+ const origin =
+ claims.challenge === "" ? null : customDomainOrigin(claims.challenge);
+ if (
+ !Number.isSafeInteger(revision) ||
+ revision < 1 ||
+ (claims.challenge && !origin)
+ )
+ throw new Error("Invalid domain configuration");
+ await verifyAssertion(assertion, installation.bridge, {
+ iss: installation.controlPlaneOrigin,
+ aud: installation.runtimeOrigin,
+ sub: installation.ownerSubject,
+ installationId: installation.installationId,
+ purpose: DOMAIN_CONFIGURATION_PURPOSE,
+ state: String(revision),
+ challenge: origin ?? "",
+ });
+ this.ctx.storage.transactionSync(() => {
+ const current = this.sql<{
+ revision: number;
+ origin: string | null;
+ }>`SELECT revision, origin
+ FROM flarebot_domain_configuration WHERE singleton = 1`[0];
+ if (
+ current &&
+ (revision < current.revision ||
+ (revision === current.revision && origin !== current.origin))
+ )
+ throw new Error("Stale domain configuration");
+ if (!current)
+ this
+ .sql`INSERT INTO flarebot_domain_configuration VALUES (1, ${revision}, ${origin})`;
+ else if (revision > current.revision)
+ this
+ .sql`UPDATE flarebot_domain_configuration SET revision = ${revision}, origin = ${origin}
+ WHERE singleton = 1`;
+ });
+ return { revision, origin };
+ }
+ async domainHealth(assertion: string, expectedOrigin: string) {
+ const installation = loadCustomerConfig(this.env).effectiveInstallation;
+ if (
+ !installation.bridge ||
+ this.configuredDomainOrigin() !== expectedOrigin
+ )
+ throw new Error("Domain unavailable");
+ const parts = assertion.split(".");
+ if (parts.length !== 3) throw new Error("Invalid domain health");
+ const claims = JSON.parse(
+ new TextDecoder().decode(
+ Uint8Array.from(
+ atob(parts[1].replaceAll("-", "+").replaceAll("_", "/")),
+ (c) => c.charCodeAt(0),
+ ),
+ ),
+ ) as BridgeClaims;
+ if (!opaque(claims.state) || !opaque(claims.challenge))
+ throw new Error("Invalid domain health");
+ await verifyAssertion(assertion, installation.bridge, {
+ iss: installation.controlPlaneOrigin,
+ aud: expectedOrigin,
+ sub: installation.ownerSubject,
+ installationId: installation.installationId,
+ purpose: DOMAIN_HEALTH_PURPOSE,
+ state: claims.state,
+ challenge: claims.challenge,
+ });
+ return {
+ installationId: installation.installationId,
+ origin: expectedOrigin,
+ state: claims.state,
+ challenge: claims.challenge,
+ };
+ }
async bootstrapHealth(claims: BridgeClaims) {
const config = loadCustomerConfig(this.env).effectiveInstallation;
const release = config.release;
diff --git a/worker/session.ts b/worker/session.ts
index 4346582..e04c2e3 100644
--- a/worker/session.ts
+++ b/worker/session.ts
@@ -96,7 +96,7 @@ export async function verifyOwnerSession(
session.version !== 1 ||
session.subject !== installation.ownerSubject ||
session.installationId !== installation.installationId ||
- session.audience !== installation.runtimeOrigin ||
+ session.audience !== new URL(request.url).origin ||
!Number.isSafeInteger(session.issuedAt) ||
!Number.isSafeInteger(session.expiresAt) ||
session.issuedAt > now ||