Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9f10f0f
fix(migrate-auth-secret): exit cleanly when there are no 2FA records
ngenohkevin May 12, 2026
a714e0f
Merge pull request #4394 from ngenohkevin/fix/migrate-auth-secret-exi…
Siumauricio May 12, 2026
754774e
feat(compose): add import from base64 in create service dropdown
Siumauricio May 12, 2026
63e33a2
[autofix.ci] apply automated fixes
autofix-ci[bot] May 12, 2026
7a568aa
Merge pull request #4395 from Dokploy/feat/import-compose-from-base64
Siumauricio May 12, 2026
f8fcf68
Enhance version synchronization workflow to include SDK repository
Siumauricio May 12, 2026
558d809
feat(deployment): add readLogs procedure to fetch deployment logs
Siumauricio May 13, 2026
aff200f
feat(deployment): add server access validation for deployment actions
Siumauricio May 13, 2026
67278d8
feat(organization): prevent inviting users with owner role
Siumauricio May 13, 2026
1fdbe87
feat(user): implement session cleanup on user update
Siumauricio May 13, 2026
a50f958
feat(settings): add copy button to server IP in web server settings (…
Siumauricio May 13, 2026
8d88a34
fix: copy Dokploy server IP when clicking server badge (#4390)
vadamk May 13, 2026
ef0cf9b
fix: responsive layout (#4391)
nhridoy May 13, 2026
6e342ee
fix: automatically converting username to lowercase both in creation …
Baker May 13, 2026
af8072d
fix: allow square brackets in zip path validation for Next.js dynamic…
Siumauricio May 22, 2026
b06138b
fix: prevent webhook deploy crash when commit data lacks modified fil…
Siumauricio May 22, 2026
f6e6e5c
fix: add type="button" to TooltipTrigger in form components to preven…
mixelburg May 22, 2026
34d38cf
fix: enable comment toggle shortcut in env variable editor (#4402) (#…
Siumauricio May 22, 2026
103e2f7
fix: add tls=true label for domains when certificateType is none (#40…
Siumauricio May 22, 2026
2f43f60
chore: update version to v0.29.5 in package.json
Siumauricio May 22, 2026
6675aa6
chore(deps): upgrade next to 16.2.6 (#4477)
jasael May 24, 2026
8018027
feat: add self-hosted enterprise restrictions (remote-servers-only, e…
Siumauricio May 30, 2026
4ba0f71
fix: grant create and delete SSH key permissions when canAccessToSSHK…
Siumauricio May 30, 2026
d7d6422
fix: use create permission for basic auth delete instead of delete (#…
Siumauricio May 30, 2026
ad680ae
fix: wrap long server names and keep actions menu visible (#4434)
pparage May 30, 2026
9bd4451
chore: update version to v0.29.6 in package.json
Siumauricio May 30, 2026
85211af
fix: preserve HOME in compose deploy so --with-registry-auth can read…
youcefzemmar May 30, 2026
d56a17c
Merge branch 'main' into canary
Siumauricio May 30, 2026
6ff2ca0
fix: scope dokploy-server schedules to organization instead of user (…
Siumauricio May 31, 2026
6b15591
feat(cloudflare): add Cloudflare integration credentials and settings
maxsam4 May 30, 2026
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
102 changes: 102 additions & 0 deletions apps/dokploy/__test__/cloudflare/cloudflare-api-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {
CloudflareApiError,
verifyToken,
} from "@dokploy/server/utils/providers/cloudflare";
import { afterEach, describe, expect, it, vi } from "vitest";

/**
* Minimal stand-in for a `fetch` Response. The client only relies on `ok`,
* `status` and `json()`.
*/
const fakeResponse = (ok: boolean, status: number, body: unknown) =>
({
ok,
status,
json: async () => body,
}) as unknown as Response;

const stubFetch = (response: Response) => {
const fetchMock = vi.fn().mockResolvedValue(response);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
};

afterEach(() => {
vi.unstubAllGlobals();
});

describe("verifyToken", () => {
it("resolves and sends a bearer token to the verify endpoint", async () => {
const fetchMock = stubFetch(
fakeResponse(true, 200, {
success: true,
errors: [],
messages: [],
result: { id: "abc", status: "active" },
}),
);

const result = await verifyToken("token-123");

expect(result.status).toBe("active");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://api.cloudflare.com/client/v4/user/tokens/verify");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer token-123",
);
});

it("throws a mapped CloudflareApiError when the token is invalid", async () => {
stubFetch(
fakeResponse(false, 401, {
success: false,
errors: [{ code: 1000, message: "Invalid API Token" }],
messages: [],
result: null,
}),
);

await expect(verifyToken("bad-token")).rejects.toThrow("Invalid API Token");
await expect(verifyToken("bad-token")).rejects.toBeInstanceOf(
CloudflareApiError,
);
});

it("throws when the token verifies but is not active", async () => {
stubFetch(
fakeResponse(true, 200, {
success: true,
errors: [],
messages: [],
result: { id: "abc", status: "disabled" },
}),
);

await expect(verifyToken("token")).rejects.toThrow(/not active|disabled/i);
});

it("never leaks the API token in the error message", async () => {
const secret = "super-secret-token-value";
stubFetch(
fakeResponse(false, 403, {
success: false,
errors: [
{ code: 9109, message: "Unauthorized to access requested resource" },
],
messages: [],
result: null,
}),
);

await expect(verifyToken(secret)).rejects.toMatchObject({
message: expect.not.stringContaining(secret),
});
});

it("falls back to a generic message when the body has no errors", async () => {
stubFetch(fakeResponse(false, 500, { success: false }));

await expect(verifyToken("token")).rejects.toThrow(/HTTP 500/);
});
});
82 changes: 82 additions & 0 deletions apps/dokploy/__test__/cloudflare/cloudflare-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { cloudflareRouter } from "@/server/api/routers/cloudflare";
import { createCallerFactory } from "@/server/api/trpc";

/**
* These tests assert the authorization model for the Cloudflare integration:
* a `member` must never be able to read or mutate org-scoped Cloudflare
* credentials, while owner/admin pass the gate.
*
* This guards against the subtle bug where `withPermission("cloudflare", …)`
* would be used instead of `adminProcedure`: because `cloudflare` is an
* enterprise-only resource, `checkPermission` short-circuits for static roles
* and would silently authorize a `member`. The router uses `adminProcedure`
* to enforce an owner/admin role directly — these tests fail if that ever
* regresses to a permission-based gate.
*/
const createCaller = createCallerFactory(cloudflareRouter);

const ctxFor = (role: "owner" | "admin" | "member") =>
({
user: { id: "user-1", email: "user@test.com", role },
session: { activeOrganizationId: "org-1" },
req: {} as unknown,
res: {} as unknown,
}) as never;

const validCreate = {
name: "production",
apiToken: "cf-test-token",
accountId: "acct-1",
};

describe("cloudflare router authorization", () => {
describe("a member is denied every Cloudflare operation", () => {
const caller = createCaller(ctxFor("member"));

it("cannot create", async () => {
await expect(caller.create(validCreate)).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});

it("cannot update", async () => {
await expect(
caller.update({ cloudflareId: "x", name: "y", accountId: "z" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});

it("cannot remove", async () => {
await expect(caller.remove({ cloudflareId: "x" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});

it("cannot test a connection", async () => {
await expect(
caller.testConnection({ apiToken: "t", accountId: "a" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});

it("cannot list or read", async () => {
await expect(caller.all()).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
await expect(caller.one({ cloudflareId: "x" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
});

describe("owners and admins pass the admin gate", () => {
it("owner can create", async () => {
const caller = createCaller(ctxFor("owner"));
await expect(caller.create(validCreate)).resolves.toBeDefined();
});

it("admin can create", async () => {
const caller = createCaller(ctxFor("admin"));
await expect(caller.create(validCreate)).resolves.toBeDefined();
});
});
});
56 changes: 56 additions & 0 deletions apps/dokploy/__test__/cloudflare/cloudflare-redaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";

/**
* The stored Cloudflare API token must never be returned to the client. These
* tests stub the service layer to return a row that still contains the token
* and assert the router strips it before responding.
*/
const SECRET = "cf-secret-token-value";

const fullRow = {
cloudflareId: "cf-1",
name: "production",
apiToken: SECRET,
accountId: "acct-1",
defaultTunnelId: null,
organizationId: "org-1",
createdAt: new Date(),
};

vi.mock("@dokploy/server", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dokploy/server")>();
return {
...actual,
createCloudflare: vi.fn(async () => fullRow),
findCloudflareById: vi.fn(async () => fullRow),
};
});

const { cloudflareRouter } = await import("@/server/api/routers/cloudflare");
const { createCallerFactory } = await import("@/server/api/trpc");

const createCaller = createCallerFactory(cloudflareRouter);
const caller = createCaller({
user: { id: "user-1", email: "owner@test.com", role: "owner" },
session: { activeOrganizationId: "org-1" },
req: {} as unknown,
res: {} as unknown,
} as never);

describe("cloudflare token redaction", () => {
it("does not return the API token from create", async () => {
const result = await caller.create({
name: "production",
apiToken: SECRET,
accountId: "acct-1",
});
expect(result).not.toHaveProperty("apiToken");
expect(JSON.stringify(result)).not.toContain(SECRET);
});

it("does not return the API token from one", async () => {
const result = await caller.one({ cloudflareId: "cf-1" });
expect(result).not.toHaveProperty("apiToken");
expect(JSON.stringify(result)).not.toContain(SECRET);
});
});
52 changes: 52 additions & 0 deletions apps/dokploy/__test__/compose/build-compose-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it, vi } from "vitest";

// Isolate the command builder from the compose-file I/O performed by
// writeDomainsToCompose; we only care about the docker invocation it emits.
vi.mock("@dokploy/server/utils/docker/domain", () => ({
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
}));

const baseCompose = {
appName: "my-app",
sourceType: "raw",
command: "",
composePath: "docker-compose.yml",
composeType: "stack",
isolatedDeployment: false,
randomize: false,
suffix: "",
serverId: null,
env: "",
mounts: [],
domains: [],
environment: { project: { env: "" }, env: "" },
} as unknown as Parameters<typeof getBuildComposeCommand>[0];

// Regression coverage for #4401: the deploy command runs under `env -i`, which
// clears the environment except for the vars listed explicitly. HOME must be
// preserved so docker can resolve ~/.docker/config.json — otherwise
// `docker stack deploy --with-registry-auth` ships no credentials to the swarm
// and private-registry images fail to pull.
describe("getBuildComposeCommand registry auth (#4401)", () => {
it("preserves HOME for swarm stack deploys", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
composeType: "stack",
});

expect(command).toContain("stack deploy");
expect(command).toContain("--with-registry-auth");
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
});

it("preserves HOME for docker compose deploys", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
composeType: "docker-compose",
});

expect(command).toContain("compose -p my-app");
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const ENTERPRISE_RESOURCES = [
"schedule",
"domain",
"destination",
"cloudflare",
"notification",
"tag",
"logs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ const baseSettings: WebServerSettings = {
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
remoteServersOnly: false,
enforceSSO: false,
createdAt: null,
updatedAt: new Date(),
};
Expand Down
17 changes: 12 additions & 5 deletions apps/dokploy/components/dashboard/project/add-application.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ interface Props {
export const AddApplication = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: servers } = api.server.withSSHKey.useQuery();
Expand Down Expand Up @@ -171,7 +174,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server {!isCloud ? "(Optional)" : ""}
Select a Server{" "}
{showLocalOption ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
Expand All @@ -191,17 +195,19 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
field.value || (showLocalOption ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
placeholder={
showLocalOption ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
{showLocalOption && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
Expand All @@ -225,7 +231,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
Servers (
{servers?.length + (showLocalOption ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
Expand Down
Loading