From aa9868cb9800a95385278dc0e0893c4e67f89e28 Mon Sep 17 00:00:00 2001 From: Nathan Beddoe Date: Mon, 7 Sep 2026 20:02:39 +0200 Subject: [PATCH] Add in-app custom domain onboarding with durable Cloudflare provisioning Amp-Thread-ID: https://ampcode.com/threads/T-01a07ccd-91de-77ce-8943-b1714687bbea Co-authored-by: Amp --- configuration/oauth-capabilities.ts | 14 +- control-plane/bridge.ts | 88 +++- control-plane/domain-api.ts | 172 ++++++++ control-plane/domain-metadata.ts | 63 +++ control-plane/domain-setup.ts | 228 +++++++++++ control-plane/domain-workflow.ts | 224 +++++++++++ control-plane/http.ts | 23 +- control-plane/installation-registry.ts | 209 ++++++++++ control-plane/installation-workflow.ts | 4 + control-plane/installations.ts | 25 ++ control-plane/provider-setup.ts | 17 +- control-plane/ui/DomainSetup.tsx | 487 +++++++++++++++++++++++ control-plane/ui/InstallationStatus.tsx | 26 +- control-plane/ui/Onboarding.tsx | 26 +- control-plane/ui/styles.css | 19 + control-plane/vault.ts | 2 + docs/custom-domains.md | 112 ++++++ docs/installation-ownership.md | 5 + docs/oauth-onboarding.md | 7 + package.json | 1 + shared/bridge.ts | 9 +- shared/domain-origin.ts | 32 ++ src/routes/DomainSettings.tsx | 94 +++++ src/routes/Settings.tsx | 2 + tests/bridge.test.mjs | 209 ++++++++++ tests/domain-auth.test.mjs | 71 ++++ tests/domain-provisioning.test.mjs | 205 ++++++++++ tests/domain-ui.test.mjs | 291 ++++++++++++++ tests/domain-workflow.test.mjs | 253 ++++++++++++ tests/fixtures/bridge-control-worker.ts | 39 ++ tests/fixtures/bridge-customer-worker.ts | 9 +- tests/fixtures/domain-worker.ts | 209 ++++++++++ tests/fixtures/domain-workflow-worker.ts | 214 ++++++++++ tests/worker.test.mjs | 5 + worker/bridge.ts | 60 ++- worker/index.ts | 48 ++- worker/personal-agent.ts | 103 +++++ worker/session.ts | 2 +- 38 files changed, 3566 insertions(+), 41 deletions(-) create mode 100644 control-plane/domain-api.ts create mode 100644 control-plane/domain-metadata.ts create mode 100644 control-plane/domain-setup.ts create mode 100644 control-plane/domain-workflow.ts create mode 100644 control-plane/ui/DomainSetup.tsx create mode 100644 docs/custom-domains.md create mode 100644 shared/domain-origin.ts create mode 100644 src/routes/DomainSettings.tsx create mode 100644 tests/domain-auth.test.mjs create mode 100644 tests/domain-provisioning.test.mjs create mode 100644 tests/domain-ui.test.mjs create mode 100644 tests/domain-workflow.test.mjs create mode 100644 tests/fixtures/domain-worker.ts create mode 100644 tests/fixtures/domain-workflow-worker.ts 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 && ( +
+ +

+ The previous result is uncertain. Confirm it before starting another + operation. +

+ +
+ )} + {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. +

+ +
+ )} + {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."} +

+ + + )} + {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 && ( +
{ + event.preventDefault(); + void submit("", { + zoneId, + hostname: hostname.trim().toLowerCase(), + }); + }} + > + + + + + setHostname((event.currentTarget as HTMLInputElement).value) + } + placeholder={`bot.${zones.find((zone) => zone.id === zoneId)?.name ?? zones[0].name}`} + required + disabled={busy || uncertain} + /> +
+ +
+
+ )} + {(domain?.status === "active" || domain?.status === "failed") && + !confirmRemove && ( + + )} + {(domain?.status === "active" || domain?.status === "failed") && + confirmRemove && ( +
+

+ Remove {domain.hostname}? Flarebot will continue at its + workers.dev address. +

+
+ + +
+
+ )} + {reconnect && ( + <> +

+ Reconnect Cloudflare to continue. Cloudflare may redirect you to + review authorization. +

+
+ + +
+ + )} + {error && !uncertain && ( + + )} + {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 ( +

+
+ Flarebot +
+
+
+

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 || -- 2.51.2