From 53386d12c55e42ea2be8502cbaab22ac3bc3c451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Fri, 23 Oct 2026 17:07:42 +0700 Subject: [PATCH 1/2] feat(service): guide TXT domain ownership verification --- docs/product/command-principles.md | 5 ++ docs/reference/error-reference.md | 8 ++++ .../cli/src/commands/service/domain-add.ts | 2 +- packages/cli/src/commands/service/errors.ts | 21 +++++++++ packages/cli/tests/service-domain.test.ts | 46 +++++++++++++++++++ 5 files changed, 81 insertions(+), 1 deletion(-) diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index 7ef6835c..fed2de70 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -153,6 +153,11 @@ They default to `production`; `--branch ` selects a preview or staging branch. The service must have a promoted, running version before a domain can be added. Domain lookup, retry, waiting, and deletion stay within that service. +New domain attachments require a persistent workspace TXT ownership record as +well as the routing CNAME. Missing proof returns the exact DNS instructions and +a command to retry on the same branch. Existing connected domains keep working; +recreating a branch in the same workspace reuses its TXT record. + ## Shared Flag Conventions Shared CLI flag rules: diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 78dc52e0..21e9c6a0 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -462,6 +462,14 @@ The general "Management API call failed" wrapper for the `service` command famil The hostname given to a `service domain` command is not a usable custom domain — raised either by local validation before any API call (protocol, path, port, wildcard, single label, or bad DNS labels) or when `service domain add` gets a plain HTTP 400 rejection from the domain API. The fix is to pass a bare hostname such as `shop.acme.com`. Meta: `status`, `apiCode`, `hint` (API-rejection path only; none for local validation). +### SERVICE.DOMAIN_OWNERSHIP_REQUIRED + +The API could not find the TXT record proving that this workspace may attach the +hostname. Add the exact name and value in the API hint, keep the TXT record for +future attachments in this workspace, and rerun the suggested command. Recovery +preserves the selected service, project, and branch. This does not mean that the +service needs another deployment. Meta: `status`, `apiCode`, `hint`. + ### SERVICE.DOMAIN_NOT_FOUND A `service domain` command targeted a hostname that is not attached to the resolved service — raised when the service's domain listing has no matching hostname, or when a show/delete/retry/wait call gets HTTP 404. The fix is to check the hostname and service, or add the domain first. Meta: none. diff --git a/packages/cli/src/commands/service/domain-add.ts b/packages/cli/src/commands/service/domain-add.ts index 68d22726..53788dd6 100644 --- a/packages/cli/src/commands/service/domain-add.ts +++ b/packages/cli/src/commands/service/domain-add.ts @@ -14,7 +14,7 @@ export const serviceDomainAddCommand = defineCommand({ help: { summary: "Register a custom domain on the selected service branch", description: - "Points a hostname you own at the service instead of its generated URL. After registering, create the DNS record the platform asks for, then 'service domain wait' blocks until verification and TLS provisioning finish.", + "Points a hostname you own at the service instead of its generated URL. Add the routing CNAME and workspace TXT record shown by Console or the verification error, then rerun this command. Keep the TXT record for future attachments in this workspace. Use 'service domain wait' to wait for TLS provisioning.", examples: [ "service domain add shop.acme.com --service my-service", "service domain add staging.acme.com --service my-service --branch staging", diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index 4e8ff353..7eccf310 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -478,6 +478,27 @@ function domainAddFailure( hostname: string, target: ServiceDomainTarget, ): CliStructuredError | null { + if (error.code === "domain-ownership-required") { + return new CliStructuredError( + "SERVICE.DOMAIN_OWNERSHIP_REQUIRED", + `Verify ownership of ${hostname}`, + { + why: "DNS does not contain the TXT proof for this workspace.", + meta: debugMeta(error), + nextActions: [ + adviceAction( + error.hint ?? + "Add the workspace TXT record shown in Console's domain setup.", + ), + runCommandAction( + "Verify and connect", + domainRecoveryCommand("add", hostname, target), + ), + ], + cause: error, + }, + ); + } if ( (error.status === 400 || error.status === 422) && isDomainDnsError(error) diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index f0092e1c..aadd0dc5 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -106,6 +106,52 @@ it.each([ }); describe("prisma service domain add", () => { + it("shows TXT instructions and keeps the target when workspace ownership is missing", async () => { + const hint = + "Add a TXT record named _prisma-verification.staging.example.com with value prisma-verification=workspace-proof. Keep this record to reconnect domains in this workspace, then retry the same command."; + const harness = await makeServiceCli({ + routes: domainRoutes({ + "POST /v1/apps/{appId}/domains": () => ({ + status: 422, + error: { + error: { + code: "domain-ownership-required", + message: "Verify ownership before connecting this domain.", + hint, + }, + }, + }), + }), + }); + const result = await harness.cli.run( + [ + "service", + "domain", + "add", + "staging.example.com", + ...TARGET_ARGS, + "--branch", + "feature/domain fix", + "--json", + ], + { cwd: harness.cwd, env: harness.env }, + ); + const frame = result.json[result.json.length - 1]; + if (frame?.kind !== "result" || frame.envelope.ok) + throw new Error("expected an errored envelope"); + expect(frame.envelope.error.code).toBe("SERVICE.DOMAIN_OWNERSHIP_REQUIRED"); + expect(frame.envelope.error.meta).toMatchObject({ hint }); + expect(frame.envelope.nextActions).toEqual([ + { kind: "user-choice", label: hint }, + { + kind: "run-command", + label: "Verify and connect", + command: + "prisma service domain add staging.example.com --service svc_1 --project proj_1 --branch 'feature/domain fix'", + }, + ]); + }); + it("uses the API's wait guidance when the workspace creation budget is exhausted", async () => { const harness = await makeServiceCli({ routes: domainRoutes({ From 231634ad916894ce495d57afaa24afc7ea11382f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Fri, 23 Oct 2026 21:16:54 +0700 Subject: [PATCH 2/2] feat(service): guide Cloudflare domain proxy recovery --- docs/product/command-principles.md | 5 +++ docs/reference/error-reference.md | 9 ++++ packages/cli/src/commands/service/errors.ts | 21 ++++++++++ packages/cli/tests/service-domain.test.ts | 46 +++++++++++++++++++++ 4 files changed, 81 insertions(+) diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index fed2de70..937fdee9 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -158,6 +158,11 @@ well as the routing CNAME. Missing proof returns the exact DNS instructions and a command to retry on the same branch. Existing connected domains keep working; recreating a branch in the same workspace reuses its TXT record. +Cloudflare proxy detection returns `SERVICE.DOMAIN_CLOUDFLARE_PROXY` with DNS-only +instructions and a retry command for the same target. The customer can turn the +proxy back on after the domain is Active, with Full (strict) SSL/TLS and the +ACME challenge path accessible for certificate renewal. + ## Shared Flag Conventions Shared CLI flag rules: diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 21e9c6a0..7a5e2ec0 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -470,6 +470,15 @@ future attachments in this workspace, and rerun the suggested command. Recovery preserves the selected service, project, and branch. This does not mean that the service needs another deployment. Meta: `status`, `apiCode`, `hint`. +### SERVICE.DOMAIN_CLOUDFLARE_PROXY + +The API found Cloudflare proxy addresses while the hostname's routing check +failed. Set that DNS record to DNS only (gray cloud) and rerun the suggested +command. After the domain is Active, Proxied can be enabled again with Full +(strict) SSL/TLS. Keep `/.well-known/acme-challenge/*` accessible without caching +or security challenges so Prisma can renew the certificate. Meta: `status`, +`apiCode`, `hint`. + ### SERVICE.DOMAIN_NOT_FOUND A `service domain` command targeted a hostname that is not attached to the resolved service — raised when the service's domain listing has no matching hostname, or when a show/delete/retry/wait call gets HTTP 404. The fix is to check the hostname and service, or add the domain first. Meta: none. diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index 7eccf310..7525544f 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -478,6 +478,27 @@ function domainAddFailure( hostname: string, target: ServiceDomainTarget, ): CliStructuredError | null { + if (error.code === "domain-cloudflare-proxy") { + return new CliStructuredError( + "SERVICE.DOMAIN_CLOUDFLARE_PROXY", + `Turn off Cloudflare's proxy to connect ${hostname}`, + { + why: error.message, + meta: debugMeta(error), + nextActions: [ + adviceAction( + error.hint ?? + "Set this DNS record to DNS only (gray cloud), then retry. After the domain is Active, you can turn Proxied back on with Full (strict) SSL/TLS. Keep /.well-known/acme-challenge/* accessible for certificate renewal.", + ), + runCommandAction( + "Verify and connect", + domainRecoveryCommand("add", hostname, target), + ), + ], + cause: error, + }, + ); + } if (error.code === "domain-ownership-required") { return new CliStructuredError( "SERVICE.DOMAIN_OWNERSHIP_REQUIRED", diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index aadd0dc5..b64d2316 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -106,6 +106,52 @@ it.each([ }); describe("prisma service domain add", () => { + it("explains when to restore the Cloudflare proxy and retries the same branch", async () => { + const hint = + "Set this record to DNS only (gray cloud). Once the domain is Active, turn Proxied back on with Full (strict) SSL/TLS and keep /.well-known/acme-challenge/* accessible."; + const harness = await makeServiceCli({ + routes: domainRoutes({ + "POST /v1/apps/{appId}/domains": () => ({ + status: 400, + error: { + error: { + code: "domain-cloudflare-proxy", + message: "This hostname appears to use Cloudflare's proxy.", + hint, + }, + }, + }), + }), + }); + const result = await harness.cli.run( + [ + "service", + "domain", + "add", + "staging.example.com", + ...TARGET_ARGS, + "--branch", + "feature/domain fix", + "--json", + ], + { cwd: harness.cwd, env: harness.env }, + ); + const frame = result.json[result.json.length - 1]; + if (frame?.kind !== "result" || frame.envelope.ok) { + throw new Error("expected an errored envelope"); + } + expect(frame.envelope.error.code).toBe("SERVICE.DOMAIN_CLOUDFLARE_PROXY"); + expect(frame.envelope.nextActions).toEqual([ + { kind: "user-choice", label: hint }, + { + kind: "run-command", + label: "Verify and connect", + command: + "prisma service domain add staging.example.com --service svc_1 --project proj_1 --branch 'feature/domain fix'", + }, + ]); + }); + it("shows TXT instructions and keeps the target when workspace ownership is missing", async () => { const hint = "Add a TXT record named _prisma-verification.staging.example.com with value prisma-verification=workspace-proof. Keep this record to reconnect domains in this workspace, then retry the same command.";