diff --git a/apps/web/components/dashboard/custom-domain-section.test.tsx b/apps/web/components/dashboard/custom-domain-section.test.tsx index 34cd4f4..86eb204 100644 --- a/apps/web/components/dashboard/custom-domain-section.test.tsx +++ b/apps/web/components/dashboard/custom-domain-section.test.tsx @@ -5,8 +5,11 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CustomDomainSection } from "./custom-domain-section"; +import { ApiError } from "@/lib/api"; import { + type CustomDomainStatus, fetchCustomDomain, + setProjectCustomDomain, verifyProjectCustomDomain, } from "@/lib/projects-api"; @@ -18,49 +21,47 @@ vi.mock("@/lib/projects-api", () => ({ verifyProjectCustomDomain: vi.fn(), })); -vi.mock("@/lib/clipboard", () => ({ - copyTextToClipboard, -})); +vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard })); -const initialStatus = { +const initialStatus: CustomDomainStatus = { hostname: "mcp.example.com", verified: true, verification_token: null, verification_record_name: null, instructions: null, - fly_ownership_verification_record_name: "_fly-ownership.mcp.example.com", - fly_ownership_verification_record_value: "fly-token", - fly_a_record_values: null, - fly_aaaa_record_values: null, - fly_cname_record_value: null, - certificate_status: "pending" as const, - certificate_message: "Fly certificate provisioning is pending.", -}; - -const issuedStatus = { - ...initialStatus, - certificate_status: "issued" as const, - certificate_message: "Fly edge TLS certificate is issued.", + platform_dns_records: [ + { + type: "TXT", + name: "_railway-verify.mcp.example.com", + value: "railway-token", + status: "DNS_RECORD_STATUS_PROPAGATED", + purpose: "OWNERSHIP_VERIFICATION", + }, + { + type: "CNAME", + name: "mcp.example.com", + value: "gateway.up.railway.app", + status: "DNS_RECORD_STATUS_REQUIRES_UPDATE", + purpose: "DNS_RECORD_PURPOSE_TRAFFIC_ROUTE", + }, + ], + certificate_status: "pending", + certificate_message: "Railway TLS certificate provisioning is pending.", }; -const dnsRequiredStatus = { +const issuedStatus: CustomDomainStatus = { ...initialStatus, - instructions: [ - "Add an A record on mcp.example.com pointing to: 66.241.125.232", - "Add an AAAA record on mcp.example.com pointing to: 2a09:8280:1::1", - "Add a CNAME record on mcp.example.com pointing to: gateway.fly.dev", - ].join("\n"), - fly_a_record_values: ["66.241.125.232"], - fly_aaaa_record_values: ["2a09:8280:1::1"], - fly_cname_record_value: "gateway.fly.dev", - certificate_message: "Fly certificate validation is waiting on DNS records.", + platform_dns_records: initialStatus.platform_dns_records!.map((record) => ({ + ...record, + status: "DNS_RECORD_STATUS_PROPAGATED", + })), + certificate_status: "issued", + certificate_message: "Railway edge TLS certificate is issued.", }; function deferred() { let resolve: (value: T) => void = () => {}; - const promise = new Promise((innerResolve) => { - resolve = innerResolve; - }); + const promise = new Promise((innerResolve) => { resolve = innerResolve; }); return { promise, resolve }; } @@ -73,9 +74,7 @@ async function waitFor(assertion: () => void) { return; } catch (error) { lastError = error; - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); }); } } throw lastError; @@ -91,10 +90,7 @@ describe("CustomDomainSection", () => { vi.mocked(verifyProjectCustomDomain).mockResolvedValue(issuedStatus); copyTextToClipboard.mockResolvedValue(undefined); queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); host = document.createElement("div"); document.body.appendChild(host); @@ -102,18 +98,13 @@ describe("CustomDomainSection", () => { }); afterEach(async () => { - await act(async () => { - root.unmount(); - }); + await act(async () => { root.unmount(); }); host.remove(); queryClient.clear(); - vi.clearAllMocks(); + vi.resetAllMocks(); }); - it("shows DNS and TLS check phases while refresh is running", async () => { - const check = deferred(); - vi.mocked(verifyProjectCustomDomain).mockReturnValue(check.promise); - + async function renderSection() { await act(async () => { root.render( @@ -121,235 +112,208 @@ describe("CustomDomainSection", () => { , ); }); + await waitFor(() => { expect(host.textContent).toContain("Hostname:"); }); + } - await waitFor(() => { - expect(host.textContent).toContain("Refresh DNS/TLS"); - }); - - const button = Array.from(host.querySelectorAll("button")).find( - (candidate) => candidate.textContent?.includes("Refresh DNS/TLS"), + function button(label: string) { + const result = Array.from(host.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.includes(label), ); + expect(result).toBeDefined(); + return result!; + } + async function click(label: string) { + await act(async () => { button(label).click(); }); + } + + async function enterHostname(value: string) { + const input = host.querySelector("input")!; await act(async () => { - button?.click(); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); }); + } - await waitFor(() => { - expect(verifyProjectCustomDomain).toHaveBeenCalledWith("project-1"); - expect(host.textContent).toContain("Checking DNS and TLS"); - expect(host.textContent).toContain("DNS TXT verification:"); - expect(host.textContent).toContain("Fly ownership TXT:"); - expect(host.textContent).toContain("Fly routing:"); - expect(host.textContent).toContain("Fly TLS certificate:"); - expect(host.textContent).toContain("requesting status"); - }); + it("renders Railway TXT and CNAME requirements without Fly routing choices", async () => { + await renderSection(); + expect(host.textContent).toContain("Required Railway DNS records"); + expect(host.textContent).toContain("_railway-verify.mcp.example.com"); + expect(host.textContent).toContain("railway-token"); + expect(host.textContent).toContain("gateway.up.railway.app"); + expect(host.textContent).not.toContain("Fly"); + expect(host.textContent).not.toContain("Routing options"); + }); - await act(async () => { - check.resolve(issuedStatus); - await check.promise; - }); + it("shows each Railway record's status independently of saved project verification", async () => { + await renderSection(); + const ownershipRow = Array.from(host.querySelectorAll("span")).find( + (span) => span.textContent === "_railway-verify.mcp.example.com", + )!.parentElement!; + const routingRow = Array.from(host.querySelectorAll("span")).find( + (span) => span.textContent === "gateway.up.railway.app", + )!.parentElement!.parentElement!; + expect(ownershipRow.textContent).toContain("Propagated"); + expect(routingRow.textContent).toContain("Needs DNS update"); + expect(routingRow.textContent).not.toContain("Propagated"); + expect(host.textContent).toContain("Project domain verification: verified"); }); - it("shows project verification TXT on a non-conflicting record name", async () => { + it("renders the non-conflicting project verification record once with generic aliases", async () => { vi.mocked(fetchCustomDomain).mockResolvedValue({ ...initialStatus, verified: false, verification_token: "project-token", verification_record_name: "_mcp-verify.mcp.example.com", - fly_ownership_verification_record_name: null, - fly_ownership_verification_record_value: null, - certificate_status: null, - certificate_message: null, - }); - - await act(async () => { - root.render( - - - , - ); - }); - - await waitFor(() => { - expect(host.textContent).toContain("Required verification records"); - expect(host.textContent).toContain("_mcp-verify.mcp.example.com"); - expect(host.textContent).toContain("project-token"); + ownership_verification_record_name: "_mcp-verify.mcp.example.com", + ownership_verification_record_value: "project-token", }); + await renderSection(); + expect(host.textContent).toContain("Project verification record"); + expect(host.textContent?.match(/project-token/g)).toHaveLength(1); + expect(host.textContent).toContain("_mcp-verify.mcp.example.com"); + expect(host.textContent).toContain("_railway-verify.mcp.example.com"); }); - it("renders the returned TLS status and message after refresh", async () => { - await act(async () => { - root.render( - - - , - ); - }); - - await waitFor(() => { - expect(host.textContent).toContain("Refresh DNS/TLS"); - }); - - const button = Array.from(host.querySelectorAll("button")).find( - (candidate) => candidate.textContent?.includes("Refresh DNS/TLS"), + it("copies complete Railway records, retaining both verification and routing", async () => { + await renderSection(); + await click("Copy Railway records"); + expect(copyTextToClipboard).toHaveBeenCalledWith( + "_railway-verify.mcp.example.com\tTXT\trailway-token\nmcp.example.com\tCNAME\tgateway.up.railway.app", + { success: "Railway DNS records copied to clipboard", error: "Could not copy DNS records" }, ); - - await act(async () => { - button?.click(); - }); - - await waitFor(() => { - expect(host.textContent).toContain("Latest DNS/TLS check"); - expect(host.textContent).toContain("TLS issued"); - expect(host.textContent).toContain("Fly edge TLS certificate is issued."); - }); + expect(button("Copy Railway records").disabled).toBe(false); }); - it("renders Fly DNS requirements returned by the TLS check", async () => { - vi.mocked(verifyProjectCustomDomain).mockResolvedValue(dnsRequiredStatus); - - await act(async () => { - root.render( - - - , - ); - }); - - await waitFor(() => { - expect(host.textContent).toContain("Refresh DNS/TLS"); - }); - - const button = Array.from(host.querySelectorAll("button")).find( - (candidate) => candidate.textContent?.includes("Refresh DNS/TLS"), - ); + it("shows refresh progress and prevents hostname changes during verification", async () => { + const check = deferred(); + vi.mocked(verifyProjectCustomDomain).mockReturnValue(check.promise); + await renderSection(); + await enterHostname("next.example.com"); + await click("Refresh DNS/TLS"); + await waitFor(() => { expect(host.textContent).toContain("Checking DNS and TLS"); }); + expect(host.textContent).toContain("Railway TLS certificate: requesting status"); + expect(button("Update Hostname").disabled).toBe(true); + expect(host.querySelector("input")!.disabled).toBe(true); + await act(async () => { check.resolve(issuedStatus); await check.promise; }); + await waitFor(() => { expect(host.textContent).toContain("TLS issued"); }); + }); + it("renders successive TLS refreshes and later query refetches", async () => { + await renderSection(); + await click("Refresh DNS/TLS"); + await waitFor(() => { expect(host.textContent).toContain("TLS issued"); }); + vi.mocked(verifyProjectCustomDomain).mockResolvedValue(initialStatus); + await click("Refresh DNS/TLS"); + await waitFor(() => { expect(host.textContent).toContain("TLS pending"); }); + vi.mocked(fetchCustomDomain).mockResolvedValue(issuedStatus); await act(async () => { - button?.click(); - }); - - await waitFor(() => { - expect(host.textContent).toContain("DNS records"); - expect(host.textContent).toContain("Required verification records"); - expect(host.textContent).toContain("TXT"); - expect(host.textContent).toContain("_fly-ownership.mcp.example.com"); - expect(host.textContent).toContain("fly-token"); - expect(host.textContent).toContain("Routing options"); - expect(host.textContent).toContain("Copying one option disables the other to avoid invalid DNS records."); - expect(host.textContent).toContain("Option 1: A/AAAA records"); - expect(host.textContent).toContain("Copy A/AAAA"); - expect(host.textContent).toContain("A"); - expect(host.textContent).toContain("66.241.125.232"); - expect(host.textContent).toContain("AAAA"); - expect(host.textContent).toContain("2a09:8280:1::1"); - expect(host.textContent).toContain("Option 2: CNAME record"); - expect(host.textContent).toContain("Copy CNAME"); - expect(host.textContent).toContain("CNAME"); - expect(host.textContent).toContain("gateway.fly.dev"); - expect(host.textContent).toContain("Add an A record on mcp.example.com pointing to: 66.241.125.232"); + await queryClient.refetchQueries({ queryKey: ["custom-domain", "project-1"] }); }); + await waitFor(() => { expect(host.textContent).toContain("TLS issued"); }); }); - it("locks routing to the copied option until the flow is restarted", async () => { - vi.mocked(verifyProjectCustomDomain).mockResolvedValue(dnsRequiredStatus); - + it("displays the new hostname after verification then replacement and blocks overlapping refresh", async () => { + const save = deferred(); + vi.mocked(setProjectCustomDomain).mockReturnValue(save.promise); + await renderSection(); + await click("Refresh DNS/TLS"); + await waitFor(() => { expect(host.textContent).toContain("TLS issued"); }); + await enterHostname("next.example.com"); + await click("Update Hostname"); + await waitFor(() => { expect(button("Refresh DNS/TLS").disabled).toBe(true); }); + expect(setProjectCustomDomain).toHaveBeenCalledWith("project-1", "next.example.com"); await act(async () => { - root.render( - - - , - ); + save.resolve({ + hostname: "next.example.com", + verified: false, + verification_token: "new-token", + certificate_status: "pending", + platform_dns_records: [], + }); + await save.promise; }); - await waitFor(() => { - expect(host.textContent).toContain("Refresh DNS/TLS"); + expect(host.textContent).toContain("Hostname: next.example.com"); + expect(host.textContent).toContain("new-token"); + expect(host.textContent).not.toContain("mcp.example.com"); + expect(host.textContent).not.toContain("TLS issued"); }); + }); - const refresh = Array.from(host.querySelectorAll("button")).find( - (candidate) => candidate.textContent?.includes("Refresh DNS/TLS"), - ); - + it("does not let an earlier in-flight query overwrite a verification result", async () => { + await renderSection(); + const staleRead = deferred(); + vi.mocked(fetchCustomDomain).mockReturnValue(staleRead.promise); + let refetch: Promise; await act(async () => { - refresh?.click(); + refetch = queryClient.refetchQueries({ queryKey: ["custom-domain", "project-1"] }); }); - - await waitFor(() => { - expect(host.textContent).toContain("Copy A/AAAA"); - expect(host.textContent).toContain("Copy CNAME"); - }); - - const copyAddress = Array.from(host.querySelectorAll("button")).find( - (candidate) => candidate.textContent?.includes("Copy A/AAAA"), - ); - + await click("Refresh DNS/TLS"); + await waitFor(() => { expect(host.textContent).toContain("TLS issued"); }); await act(async () => { - copyAddress?.click(); + staleRead.resolve(initialStatus); + await staleRead.promise; + await refetch; }); - - expect(copyTextToClipboard).toHaveBeenCalledWith( - "66.241.125.232\n2a09:8280:1::1", - { - success: "A/AAAA records copied to clipboard", - error: "Could not copy DNS records", - }, - ); - expect(host.textContent).toContain("Selected: A/AAAA records"); - - const copyCnameDisabled = Array.from(host.querySelectorAll("button")).find( - (candidate) => candidate.textContent?.includes("Copy CNAME"), - ); - expect(copyCnameDisabled?.hasAttribute("disabled")).toBe(true); - - const restart = Array.from(host.querySelectorAll("button")).find( - (candidate) => candidate.textContent?.includes("Restart DNS flow"), - ); - - await act(async () => { - restart?.click(); - }); - - const copyCnameEnabled = Array.from(host.querySelectorAll("button")).find( - (candidate) => candidate.textContent?.includes("Copy CNAME"), - ); - expect(copyCnameEnabled?.hasAttribute("disabled")).toBe(false); + expect(host.textContent).toContain("TLS issued"); + expect(queryClient.getQueryData(["custom-domain", "project-1"])).toEqual(issuedStatus); }); - it("does not repeat the certificate message in the instructions", async () => { - vi.mocked(verifyProjectCustomDomain).mockResolvedValue({ - ...dnsRequiredStatus, - instructions: [ - "Fly certificate validation is waiting on DNS records.", - "Add an A record on mcp.example.com pointing to: 66.241.125.232", - ].join("\n"), - certificate_message: "Fly certificate validation is waiting on DNS records.", + it("preserves extra validation record types and does not claim unknown status is propagated", async () => { + vi.mocked(fetchCustomDomain).mockResolvedValue({ + ...initialStatus, + platform_dns_records: [{ type: "CAA", name: "example.com", value: '0 issue "letsencrypt.org"', status: "NEW_STATUS" }], }); + await renderSection(); + expect(host.textContent).toContain("CAA"); + expect(host.textContent).toContain('0 issue "letsencrypt.org"'); + expect(host.textContent).toContain("Not confirmed"); + }); - await act(async () => { - root.render( - - - , - ); + it("refreshes Railway records when verification returns missing DNS requirements", async () => { + await renderSection(); + vi.mocked(fetchCustomDomain).mockResolvedValue({ + ...initialStatus, + platform_dns_records: [{ type: "TXT", name: "_railway-verify.mcp.example.com", value: "new-railway-token" }], }); - + vi.mocked(verifyProjectCustomDomain).mockRejectedValue(new ApiError("Bad Request", 400, { + reason: "Railway is still waiting for required DNS records.", + })); + await click("Refresh DNS/TLS"); await waitFor(() => { - expect(host.textContent).toContain("Refresh DNS/TLS"); + expect(host.textContent).toContain("new-railway-token"); + expect(host.textContent).toContain("Railway is still waiting for required DNS records."); }); + expect(host.textContent).not.toContain("gateway.up.railway.app"); + expect(button("Refresh DNS/TLS").disabled).toBe(false); + }); - const button = Array.from(host.querySelectorAll("button")).find( - (candidate) => candidate.textContent?.includes("Refresh DNS/TLS"), - ); - - await act(async () => { - button?.click(); + it("shows missing Railway provisioning configuration without claiming TLS is ready", async () => { + vi.mocked(fetchCustomDomain).mockResolvedValue({ + ...initialStatus, + platform_dns_records: [], + certificate_status: "not_configured", + certificate_message: "Railway domain provisioning is not configured. Set a Railway project token on the Gateway service.", }); + await renderSection(); + expect(host.textContent).toContain("TLS not configured"); + expect(host.textContent).toContain("Set a Railway project token on the Gateway service."); + expect(host.textContent).not.toContain("TLS issued"); + expect(button("Refresh DNS/TLS").disabled).toBe(false); + }); - await waitFor(() => { - expect(host.textContent).toContain("Add an A record on mcp.example.com pointing to: 66.241.125.232"); + it("shows save failures and does not repeat the certificate message in instructions", async () => { + vi.mocked(fetchCustomDomain).mockResolvedValue({ + ...initialStatus, + instructions: `${initialStatus.certificate_message}\nAdd the required CNAME record.`, }); - - const messageMatches = - host.textContent?.match(/Fly certificate validation is waiting on DNS records\./g) ?? []; - expect(messageMatches).toHaveLength(1); + vi.mocked(setProjectCustomDomain).mockRejectedValue(new Error("Unavailable")); + await renderSection(); + expect(host.textContent?.match(/Railway TLS certificate provisioning is pending\./g)).toHaveLength(1); + expect(host.textContent).toContain("Add the required CNAME record."); + await enterHostname("next.example.com"); + await click("Update Hostname"); + await waitFor(() => { expect(host.textContent).toContain("Could not save the custom hostname."); }); }); }); diff --git a/apps/web/components/dashboard/custom-domain-section.tsx b/apps/web/components/dashboard/custom-domain-section.tsx index 9594bef..db13865 100644 --- a/apps/web/components/dashboard/custom-domain-section.tsx +++ b/apps/web/components/dashboard/custom-domain-section.tsx @@ -31,7 +31,7 @@ const SECTION_SHELL = const INSET_SURFACE = "rounded-lg border border-border/80 bg-muted/35 dark:bg-muted/20"; -type RoutingChoice = "address" | "cname"; +type DnsRecord = NonNullable[number]; function certificateStatusLabel( status: "not_configured" | "pending" | "issued" | "failed" | "unknown" | null | undefined, @@ -68,45 +68,27 @@ function hasText(value: string | null | undefined) { function dnsRecordGroups(data: CustomDomainStatus) { const hostname = data.hostname?.trim(); - const verification: Array<{ type: string; name: string; value: string }> = []; - const addressRouting: Array<{ type: string; name: string; value: string }> = []; - const cnameRouting: Array<{ type: string; name: string; value: string }> = []; + const verification: DnsRecord[] = []; + const token = data.ownership_verification_record_value || data.verification_token; - if (hostname && hasText(data.verification_token)) { + if (hostname && hasText(token)) { verification.push({ type: "TXT", - name: data.verification_record_name?.trim() || `_mcp-verify.${hostname}`, - value: data.verification_token!.trim(), + name: data.ownership_verification_record_name?.trim() || data.verification_record_name?.trim() || `_mcp-verify.${hostname}`, + value: token!.trim(), }); } - if (hasText(data.fly_ownership_verification_record_name) && hasText(data.fly_ownership_verification_record_value)) { - verification.push({ - type: "TXT", - name: data.fly_ownership_verification_record_name!.trim(), - value: data.fly_ownership_verification_record_value!.trim(), - }); - } - if (hostname) { - for (const value of data.fly_a_record_values ?? []) { - if (hasText(value)) { - addressRouting.push({ type: "A", name: hostname, value: value.trim() }); - } - } - for (const value of data.fly_aaaa_record_values ?? []) { - if (hasText(value)) { - addressRouting.push({ type: "AAAA", name: hostname, value: value.trim() }); - } - } - if (hasText(data.fly_cname_record_value)) { - cnameRouting.push({ - type: "CNAME", - name: hostname, - value: data.fly_cname_record_value!.trim(), - }); - } - } - return { verification, addressRouting, cnameRouting }; + return { verification, platform: data.platform_dns_records ?? [] }; +} + +function dnsRecordStatusLabel(status: DnsRecord["status"]) { + switch (status?.replace(/^DNS_RECORD_STATUS_/, "")) { + case "PROPAGATED": return "Propagated"; + case "REQUIRES_UPDATE": return "Needs DNS update"; + case "PENDING": return "Pending propagation"; + default: return "Not confirmed"; + } } function visibleInstructions(data: CustomDomainStatus) { @@ -127,9 +109,9 @@ function visibleInstructions(data: CustomDomainStatus) { return lines.length > 0 ? lines.join("\n") : null; } -function formatDnsRecordsForCopy(records: Array<{ type: string; name: string; value: string }>) { +function formatDnsRecordsForCopy(records: DnsRecord[]) { return records - .map((record) => record.value) + .map((record) => `${record.name}\t${record.type}\t${record.value}`) .join("\n"); } @@ -156,22 +138,19 @@ function DnsRecordList({ title, records, action, - disabled, + showStatus = false, }: { title: string; - records: Array<{ type: string; name: string; value: string }>; + records: DnsRecord[]; action?: { label: string; onClick: () => void; }; - disabled?: boolean; + showStatus?: boolean; }) { return (
{title} @@ -181,10 +160,9 @@ function DnsRecordList({ size="sm" variant="outline" onClick={action.onClick} - disabled={disabled} className="h-7 px-2 text-xs" > - + {action.label} )} @@ -197,7 +175,10 @@ function DnsRecordList({ > {record.type} {record.name} - {record.value} +
+ {record.value} + {showStatus && {dnsRecordStatusLabel(record.status)}} +
))}
@@ -213,7 +194,6 @@ interface CustomDomainSectionProps { export function CustomDomainSection({ projectId, isPro = true }: CustomDomainSectionProps) { // Hooks must be called unconditionally before any early returns. const [hostname, setHostname] = useState(""); - const [routingChoice, setRoutingChoice] = useState(null); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ @@ -223,22 +203,26 @@ export function CustomDomainSection({ projectId, isPro = true }: CustomDomainSec const setMutation = useMutation({ mutationFn: () => setProjectCustomDomain(projectId, hostname.trim()), - onSuccess: (next) => { + onMutate: () => queryClient.cancelQueries({ queryKey: ["custom-domain", projectId] }), + onSuccess: async (next) => { + // A read started before this write must not replace its newer result. + await queryClient.cancelQueries({ queryKey: ["custom-domain", projectId] }); queryClient.setQueryData(["custom-domain", projectId], next); - queryClient.invalidateQueries({ queryKey: ["custom-domain", projectId] }); queryClient.invalidateQueries({ queryKey: ["project", projectId] }); setHostname(""); - setRoutingChoice(null); + verifyMutation.reset(); }, }); const verifyMutation = useMutation({ mutationFn: () => verifyProjectCustomDomain(projectId), - onSuccess: (next) => { + onMutate: () => queryClient.cancelQueries({ queryKey: ["custom-domain", projectId] }), + onSuccess: async (next) => { + await queryClient.cancelQueries({ queryKey: ["custom-domain", projectId] }); queryClient.setQueryData(["custom-domain", projectId], next); - queryClient.invalidateQueries({ queryKey: ["custom-domain", projectId] }); queryClient.invalidateQueries({ queryKey: ["project", projectId] }); }, + onError: () => queryClient.invalidateQueries({ queryKey: ["custom-domain", projectId] }), }); if (!isPro) { @@ -275,15 +259,9 @@ export function CustomDomainSection({ projectId, isPro = true }: CustomDomainSec return ; } - const visibleData = verifyMutation.data ?? setMutation.data ?? data; + const visibleData = data; const tlsLabel = certificateStatusLabel(visibleData.certificate_status); - const canRefreshChecks = - Boolean(visibleData.hostname) && - (!visibleData.verified || - visibleData.certificate_status === "pending" || - visibleData.certificate_status === "failed" || - visibleData.certificate_status === "not_configured" || - visibleData.certificate_status === "unknown"); + const canRefreshChecks = Boolean(visibleData.hostname); const verifyError = verifyMutation.error instanceof ApiError ? formatApiErrorDetail(verifyMutation.error.body) || verifyMutation.error.message @@ -291,28 +269,23 @@ export function CustomDomainSection({ projectId, isPro = true }: CustomDomainSec ? "Could not refresh DNS and TLS checks." : null; const isChecking = verifyMutation.isPending; + const isMutating = isChecking || setMutation.isPending; + const saveError = setMutation.error instanceof ApiError + ? formatApiErrorDetail(setMutation.error.body) || setMutation.error.message + : setMutation.error ? "Could not save the custom hostname." : null; const showCheckDetails = Boolean(visibleData.hostname) && (isChecking || visibleData.verified || hasText(visibleData.certificate_message) || - hasText(visibleData.fly_ownership_verification_record_name)); + Boolean(visibleData.platform_dns_records?.length)); const recordGroups = dnsRecordGroups(visibleData); - const hasRoutingOptions = - recordGroups.addressRouting.length > 0 || recordGroups.cnameRouting.length > 0; - const hasDnsRecords = recordGroups.verification.length > 0 || hasRoutingOptions; - const addressDisabled = routingChoice === "cname"; - const cnameDisabled = routingChoice === "address"; + const hasDnsRecords = recordGroups.verification.length > 0 || recordGroups.platform.length > 0; const instructions = visibleInstructions(visibleData); - function copyRoutingRecords(choice: RoutingChoice) { - const records = - choice === "address" ? recordGroups.addressRouting : recordGroups.cnameRouting; - setRoutingChoice(choice); - void copyTextToClipboard(formatDnsRecordsForCopy(records), { - success: choice === "address" - ? "A/AAAA records copied to clipboard" - : "CNAME record copied to clipboard", + function copyPlatformRecords() { + void copyTextToClipboard(formatDnsRecordsForCopy(recordGroups.platform), { + success: "Railway DNS records copied to clipboard", error: "Could not copy DNS records", }); } @@ -330,8 +303,8 @@ export function CustomDomainSection({ projectId, isPro = true }: CustomDomainSec Custom Domain

- Point your own hostname at this project. Add the TXT record we show, - then verify. Routing remains active while the account has Pro. + Point your own hostname at this project. Add the project verification + and Railway DNS records shown below, then verify. Routing remains active while the account has Pro.

@@ -364,57 +337,17 @@ export function CustomDomainSection({ projectId, isPro = true }: CustomDomainSec
{recordGroups.verification.length > 0 && ( )} - {hasRoutingOptions && ( -
-
-

Routing options

-

- Choose one option for the hostname. Copying one option disables the other to avoid invalid DNS records. -

-
- {routingChoice && ( -
- - Selected: {routingChoice === "address" ? "A/AAAA records" : "CNAME record"} - - -
- )} - {recordGroups.addressRouting.length > 0 && ( - copyRoutingRecords("address"), - }} - /> - )} - {recordGroups.cnameRouting.length > 0 && ( - copyRoutingRecords("cname"), - }} - /> - )} -
+ {recordGroups.platform.length > 0 && ( + )} )} @@ -432,27 +365,13 @@ export function CustomDomainSection({ projectId, isPro = true }: CustomDomainSec
  • - DNS TXT verification:{" "} + Project domain verification:{" "} {visibleData.verified ? "verified" : isChecking ? "checking" : "waiting"}
  • - {hasText(visibleData.fly_ownership_verification_record_name) && ( -
  • - Fly ownership TXT:{" "} - - {visibleData.verified ? "verified" : isChecking ? "checking" : "waiting"} - -
  • - )}
  • - Fly routing:{" "} - - {visibleData.verified ? "verified" : isChecking ? "checking" : "waiting"} - -
  • -
  • - Fly TLS certificate:{" "} + Railway TLS certificate:{" "} {isChecking ? "requesting status" @@ -480,12 +399,16 @@ export function CustomDomainSection({ projectId, isPro = true }: CustomDomainSec {verifyError && (

    {verifyError}

    )} + {saveError && ( +

    {saveError}

    + )}
    setHostname(e.target.value)} + disabled={isMutating} placeholder="mcp.example.com" className="bg-transparent dark:bg-transparent" /> @@ -495,7 +418,7 @@ export function CustomDomainSection({ projectId, isPro = true }: CustomDomainSec type="button" size="sm" onClick={() => setMutation.mutate()} - disabled={setMutation.isPending || !hostname.trim()} + disabled={isMutating || !hostname.trim()} > {setMutation.isPending ? "Saving…" @@ -509,9 +432,10 @@ export function CustomDomainSection({ projectId, isPro = true }: CustomDomainSec size="sm" variant="secondary" onClick={() => verifyMutation.mutate()} - disabled={verifyMutation.isPending} + disabled={isMutating} > {verifyMutation.isPending diff --git a/packages/mycontext-api-contract/BACKEND_API_CONTRACT.md b/packages/mycontext-api-contract/BACKEND_API_CONTRACT.md index a3c2062..ce91453 100644 --- a/packages/mycontext-api-contract/BACKEND_API_CONTRACT.md +++ b/packages/mycontext-api-contract/BACKEND_API_CONTRACT.md @@ -352,9 +352,9 @@ Content-Type: application/json **Response:** `CustomDomainStatus` -- Resets verification when the hostname changes. -- The frontend should show both TXT verification records when present. -- Routing records are alternatives: use either the A/AAAA values or the CNAME value for the same hostname, not both. +- Ensures the domain exists on the configured Railway Gateway service before saving; saving resets project verification. +- Show the project ownership TXT record plus **all** `platform_dns_records` returned by Railway. Railway ownership TXT, routing CNAME, and certificate-validation records are requirements, not alternative routing options. +- Production fails closed with HTTP 503 when Railway provisioning is not configured. A provider provisioning failure also returns HTTP 503 without saving the hostname. --- @@ -370,7 +370,9 @@ POST /projects/:id/custom-domain/verify - Verifies `TXT _mcp-verify.` against `verification_token`. - Falls back to the legacy TXT-at-hostname check for existing domains. -- Requests or refreshes Fly certificate provisioning after project TXT verification. +- Ensures the Railway domain exists after project TXT verification (also repairs domains saved before the Railway migration). +- Requires Railway ownership verification and propagated traffic-routing records before marking the project domain verified. TLS can still be pending; inspect `certificate_status` separately. +- Already-verified projects skip the project TXT check, but still refresh Railway checks. An unavailable provisioning configuration returns HTTP 503; failed provisioning returns HTTP 502; missing DNS requirements return HTTP 400. --- @@ -418,6 +420,17 @@ Use these shapes for request/response bodies. "verification_token": "string | null | undefined", "verification_record_name": "string | null | undefined", "instructions": "string | null | undefined", + "ownership_verification_record_name": "string | null | undefined", + "ownership_verification_record_value": "string | null | undefined", + "platform_dns_records": [ + { + "type": "string", + "name": "string", + "value": "string", + "status": "string | null | undefined", + "purpose": "string | null | undefined" + } + ], "fly_ownership_verification_record_name": "string | null | undefined", "fly_ownership_verification_record_value": "string | null | undefined", "fly_a_record_values": "string[] | null | undefined", @@ -429,10 +442,11 @@ Use these shapes for request/response bodies. ``` - `verification_record_name`: usually `_mcp-verify.` while project ownership is pending. -- `fly_ownership_verification_record_name` / `fly_ownership_verification_record_value`: Fly ownership TXT record required before certificate issuance. -- `fly_a_record_values` and `fly_aaaa_record_values`: address-record routing option for the custom hostname. -- `fly_cname_record_value`: CNAME routing option for the custom hostname. -- DNS routing options are mutually exclusive for a hostname: configure the A/AAAA records or configure the CNAME record, not both. +- `ownership_verification_record_name` / `ownership_verification_record_value`: provider-neutral aliases for the **project** ownership record, not Railway's ownership TXT record. +- `platform_dns_records` is optional/nullable. Each row is a required Railway DNS record with its exact type, name, and value. The Gateway includes Railway's separate ownership token as a TXT row with purpose `OWNERSHIP_VERIFICATION`; routing rows use `DNS_RECORD_PURPOSE_TRAFFIC_ROUTE`. +- Per-record status `DNS_RECORD_STATUS_PROPAGATED` indicates a ready record. Other statuses, including absent or unknown statuses, must not be shown as verified. Do not derive current Railway DNS readiness from the persisted project `verified` flag. +- `certificate_status` is the normalized Railway edge certificate state, independent of project verification and routing DNS readiness. +- The `fly_*` fields are deprecated compatibility fields and are null/omitted on the Railway backend. Do not use them for new setup instructions. ### Project catalog (`GET /projects/:id/catalog`) diff --git a/railway/README.md b/railway/README.md index e070f96..1da2a1c 100644 --- a/railway/README.md +++ b/railway/README.md @@ -41,5 +41,40 @@ environments. Railway terminates TLS for the product wildcard and tenant custom domains. The Gateway provisions tenant domains through Railway's API when `RAILWAY_PROJECT_TOKEN` is set on the Gateway service; -that token must be scoped to this project and production environment. See the development and -production runbooks under `docs/runbooks/` for guarded database-copy and DNS procedures. +that token must be scoped to this project and the Gateway's own environment. See the development +and production runbooks under `docs/runbooks/` for guarded database-copy and DNS procedures. + +## Tenant custom-domain TLS + +For each environment, set a project token as the Gateway runtime secret `RAILWAY_PROJECT_TOKEN`. +The GitHub Actions deployment secrets above do **not** configure this runtime secret. Never reuse +the Production token in Development. The Gateway sends project tokens using Railway's +`Project-Access-Token` header; account/workspace `RAILWAY_API_TOKEN` bearer authentication is +supported, but prefer the narrower project/environment-scoped token. + +Railway injects `RAILWAY_PROJECT_ID`, `RAILWAY_ENVIRONMENT_ID`, and `RAILWAY_SERVICE_ID`. Normally +these need no overrides. `RAILWAY_DOMAIN_PROJECT_ID`, `RAILWAY_DOMAIN_ENVIRONMENT_ID`, and +`RAILWAY_DOMAIN_SERVICE_ID` override them when explicitly configured; they must still target the +correct Gateway, not Web. Set `RAILWAY_DOMAIN_TARGET_PORT=8080` to match the Gateway listener. + +The setup flow is: + +1. Save the hostname in project settings. The Gateway finds or creates its Railway custom domain. +2. Add the project `_mcp-verify.` TXT record and every Railway DNS record shown in the + dashboard, including Railway's ownership TXT and routing CNAME. Do not reuse Fly A/AAAA targets + or Fly ownership records as Railway requirements. Root domains require a DNS provider that + supports CNAME flattening, ALIAS, or ANAME; Railway does not provide static routing IPs. +3. Refresh DNS and TLS checks. Project verification requires the project TXT token plus Railway + ownership and routing readiness. Certificate issuance may remain pending afterward. +4. Verify certificate issuance and the actual custom-host HTTPS/MCP flow, not just a healthy + Gateway deployment. A previously verified project does not prove its current Railway DNS state. + +Missing runtime credentials appear as `certificate_status=not_configured`; Production hostname +saves and verification fail closed. Existing manually provisioned Railway domains can continue +serving TLS even while this secret is missing, so their health does not prove self-service works. +For a saved hostname missing from Railway, use Refresh Checks to provision it after credentials +are configured. Do not delete and recreate a domain whose certificate is already issuing. + +Validate the workflow in Development first. Production secret changes and deployment require +explicit approval. Reference: [Railway domain API](https://docs.railway.com/integrations/api/manage-domains) +and [API authentication](https://docs.railway.com/integrations/api). diff --git a/services/mcp-gateway/Tests/AppTests/RailwayDomainServiceTests.swift b/services/mcp-gateway/Tests/AppTests/RailwayDomainServiceTests.swift new file mode 100644 index 0000000..fdba15b --- /dev/null +++ b/services/mcp-gateway/Tests/AppTests/RailwayDomainServiceTests.swift @@ -0,0 +1,120 @@ +import Testing +@testable import App + +@Suite("Railway custom-domain TLS") +struct RailwayDomainServiceTests { + @Test("Railway ownership TXT and routing CNAME are both required setup records") + func requiredDNSRecords() { + let result = RailwayDomainService.result(from: domain()) + + #expect(result.dnsRecords.map(\.type) == ["TXT", "CNAME"]) + #expect(result.dnsRecords.map(\.name) == ["_railway-verify.mcp.example.com", "mcp.example.com"]) + #expect(result.dnsRecords.map(\.value) == ["railway-ownership-token", "gateway.up.railway.app"]) + #expect(result.dnsRecords.first?.purpose == "OWNERSHIP_VERIFICATION") + #expect(result.dnsRecords.first?.status == "DNS_RECORD_STATUS_REQUIRES_UPDATE") + #expect(!result.ownershipVerified) + #expect(!result.routingReady) + #expect(result.status == .pending) + } + + @Test("Ownership, routing, and certificate readiness remain independent") + func independentReadiness() { + let pendingCertificate = RailwayDomainService.result(from: domain( + verified: true, + records: [routingRecord(status: "DNS_RECORD_STATUS_PROPAGATED")] + )) + #expect(pendingCertificate.ownershipVerified) + #expect(pendingCertificate.routingReady) + #expect(pendingCertificate.status == .pending) + + let issuedCertificate = RailwayDomainService.result(from: domain( + certificate: "CERTIFICATE_STATUS_TYPE_VALID" + )) + #expect(issuedCertificate.status == .issued) + #expect(!issuedCertificate.ownershipVerified) + #expect(!issuedCertificate.routingReady) + } + + @Test("All traffic records must propagate, and absent records are not ready") + func routingRequiresEveryRecord() { + let ready = routingRecord(status: "DNS_RECORD_STATUS_PROPAGATED") + let pending = routingRecord(status: "DNS_RECORD_STATUS_REQUIRES_UPDATE") + #expect(!RailwayDomainService.result(from: domain(records: [])).routingReady) + #expect(!RailwayDomainService.result(from: domain(records: [ready, pending])).routingReady) + #expect(!RailwayDomainService.result(from: domain(records: [routingRecord(status: "FUTURE_STATUS")])).routingReady) + } + + @Test("Certificate validation records are preserved without becoming traffic records") + func certificateValidationRecords() { + let validation = RailwayDomainService.DomainDNSRecord( + recordType: "DNS_RECORD_TYPE_CNAME", + fqdn: "_acme-challenge.mcp.example.com", + hostlabel: "_acme-challenge.mcp", + requiredValue: "validation.example.com", + status: "DNS_RECORD_STATUS_REQUIRES_UPDATE", + purpose: "DNS_RECORD_PURPOSE_CERTIFICATE_VALIDATION" + ) + let result = RailwayDomainService.result(from: domain(records: [ + routingRecord(status: "DNS_RECORD_STATUS_PROPAGATED"), validation, + ])) + #expect(result.routingReady) + #expect(result.dnsRecords.last?.name == "_acme-challenge.mcp.example.com") + #expect(result.dnsRecords.last?.value == "validation.example.com") + #expect(result.dnsRecords.last?.status == "DNS_RECORD_STATUS_REQUIRES_UPDATE") + } + + @Test("Duplicate Railway ownership requirements are only shown once") + func deduplicatedOwnershipRecord() { + let ownership = RailwayDomainService.DomainDNSRecord( + recordType: "DNS_RECORD_TYPE_TXT", + fqdn: "_railway-verify.mcp.example.com", + hostlabel: "_railway-verify.mcp", + requiredValue: "railway-ownership-token", + status: "DNS_RECORD_STATUS_REQUIRES_UPDATE", + purpose: "OWNERSHIP_VERIFICATION" + ) + let result = RailwayDomainService.result(from: domain(records: [ownership, routingRecord()])) + #expect(result.dnsRecords.count == 2) + } + + @Test("Railway certificate states are normalized without assuming unknown states are ready", arguments: [ + ("CERTIFICATE_STATUS_TYPE_VALID", RailwayDomainService.Status.issued), + ("CERTIFICATE_STATUS_TYPE_ISSUING", .pending), + ("CERTIFICATE_STATUS_TYPE_VALIDATING_OWNERSHIP", .pending), + ("CERTIFICATE_STATUS_TYPE_ISSUE_FAILED", .failed), + ("FUTURE_STATUS", .unknown), + ]) + func certificateStates(raw: String, expected: RailwayDomainService.Status) { + #expect(RailwayDomainService.result(from: domain(certificate: raw)).status == expected) + } + + private func domain( + verified: Bool = false, + certificate: String = "CERTIFICATE_STATUS_TYPE_ISSUING", + records: [RailwayDomainService.DomainDNSRecord]? = nil + ) -> RailwayDomainService.CustomDomain { + RailwayDomainService.CustomDomain( + id: "railway-domain-id", + domain: "mcp.example.com", + status: .init( + verified: verified, + verificationDnsHost: "_railway-verify.mcp.example.com", + verificationToken: "railway-ownership-token", + certificateStatus: certificate, + certificateErrorMessage: nil, + dnsRecords: records ?? [routingRecord()] + ) + ) + } + + private func routingRecord(status: String = "DNS_RECORD_STATUS_REQUIRES_UPDATE") -> RailwayDomainService.DomainDNSRecord { + .init( + recordType: "DNS_RECORD_TYPE_CNAME", + fqdn: "mcp.example.com", + hostlabel: "mcp", + requiredValue: "gateway.up.railway.app", + status: status, + purpose: "DNS_RECORD_PURPOSE_TRAFFIC_ROUTE" + ) + } +}