Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/product/command-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@ They default to `production`; `--branch <name>` 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.

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:
Expand Down
17 changes: 17 additions & 0 deletions docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,23 @@ 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_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.
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/service/domain-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/commands/service/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,48 @@ 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",
`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)
Expand Down
92 changes: 92 additions & 0 deletions packages/cli/tests/service-domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,98 @@ 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.";
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({
Expand Down
Loading