diff --git a/apps/dokploy/__test__/dns/ovh.test.ts b/apps/dokploy/__test__/dns/ovh.test.ts
index 5fe829d27..4f70d4c6c 100644
--- a/apps/dokploy/__test__/dns/ovh.test.ts
+++ b/apps/dokploy/__test__/dns/ovh.test.ts
@@ -149,14 +149,23 @@ describe("ovhClient.listZones", () => {
]);
});
- it("propagates the API error message", async () => {
+ it("names the missing root right when OVH refuses the zone listing", async () => {
const cfg = freshConfig();
mockApi(ovhError("This call has not been granted", 403));
+ // A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`,
+ // so the raw OVH message would send users looking in the wrong place.
await expect(ovhClient.listZones(cfg)).rejects.toThrow(
- "This call has not been granted",
+ /missing the `GET \/domain\/zone` right/,
);
});
+
+ it("propagates the API error message", async () => {
+ const cfg = freshConfig();
+ mockApi(ovhError("Invalid signature", 403));
+
+ await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
+ });
});
describe("ovhClient.listRecords", () => {
diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx
index f5d475c29..7d21f42bd 100644
--- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx
+++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx
@@ -488,10 +488,22 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
- Create the three keys at once on api.ovh.com/createToken
- with the GET, POST,{" "}
- PUT and DELETE rights on{" "}
- /domain/zone/*.
+ Create the three keys at once on
+ api.ovh.com/createToken, with exactly these five rights:
+
+ GET /domain/zone
+
+ GET /domain/zone/*
+
+ POST /domain/zone/*
+
+ PUT /domain/zone/*
+
+ DELETE /domain/zone/*
+
+ The first one lists your zones and has to be granted on
+ its own: OVH matches rights per exact path, so{" "}
+ /domain/zone/* does not cover it.
diff --git a/packages/server/src/utils/dns/ovh.ts b/packages/server/src/utils/dns/ovh.ts
index 58c372356..b24666193 100644
--- a/packages/server/src/utils/dns/ovh.ts
+++ b/packages/server/src/utils/dns/ovh.ts
@@ -209,9 +209,29 @@ const recordBody = (
...(record.ttl === undefined ? {} : { ttl: record.ttl }),
});
+// OVH grants access per exact path: a `/domain/zone/*` rule covers the subtree
+// but not the bare `/domain/zone` listing, which needs its own rule. That is an
+// easy one to leave out of a token, so say so plainly rather than surfacing a
+// bare "This call has not been granted".
+const listZoneNames = async (config: OvhConfig) => {
+ try {
+ return await ovhFetch(config, "/domain/zone");
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ error.message.includes("has not been granted")
+ ) {
+ throw new Error(
+ "OVH: the credentials are missing the `GET /domain/zone` right, which lists your zones. A `GET /domain/zone/*` rule does not cover it — add the rule without the wildcard as well.",
+ );
+ }
+ throw error;
+ }
+};
+
export const ovhClient: DnsClient = {
async listZones(config) {
- const zones = await ovhFetch(config, "/domain/zone");
+ const zones = await listZoneNames(config);
return zones.map((zone) => ({ id: zone, name: zone }));
},
@@ -318,6 +338,6 @@ export const ovhClient: DnsClient = {
},
async testConnection(config) {
- await ovhFetch(config, "/domain/zone");
+ await listZoneNames(config);
},
};