fix(databases): allow clearing external port to revoke internet exposure

This commit is contained in:
detail-app[bot] 2026-09-04 02:50:37 +00:00 committed by GitHub
parent 1572008cdf
commit 85ac164d4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 612 additions and 80 deletions

View File

@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { externalPortFormSchema } from "@/components/dashboard/shared/external-port-schema";
const parse = (input: unknown) =>
externalPortFormSchema.safeParse({ externalPort: input });
const getPort = (data: unknown): unknown =>
(data as { externalPort?: unknown } | null)?.externalPort;
describe("externalPort client preprocess (G7-G12)", () => {
it("G7: empty string '' -> null (clearing revokes exposure, no client parse error)", () => {
const result = parse("");
expect(result.success).toBe(true);
if (result.success) {
expect(getPort(result.data)).toBeNull();
}
});
it("G8: undefined -> null (DB row started with null, form submitted undefined)", () => {
const result = parse(undefined);
expect(result.success).toBe(true);
if (result.success) {
expect(getPort(result.data)).toBeNull();
}
});
it("G8b: null -> null (passed through)", () => {
const result = parse(null);
expect(result.success).toBe(true);
if (result.success) {
expect(getPort(result.data)).toBeNull();
}
});
it("G9 + G10: numeric string '3306' -> 3306 (unchanged numeric value re-saves)", () => {
const result = parse("3306");
expect(result.success).toBe(true);
if (result.success) {
expect(getPort(result.data)).toBe(3306);
}
});
it("G9b: number 3306 -> 3306 (reset numeric value re-saves without z.string().parse(number) throw)", () => {
const result = parse(3306);
expect(result.success).toBe(true);
if (result.success) {
expect(getPort(result.data)).toBe(3306);
}
});
it("boundaries: 0 -> 0 (valid, lowest port)", () => {
const result = parse(0);
expect(result.success).toBe(true);
if (result.success) {
expect(getPort(result.data)).toBe(0);
}
});
it("boundaries: '0' -> 0", () => {
const result = parse("0");
expect(result.success).toBe(true);
if (result.success) {
expect(getPort(result.data)).toBe(0);
}
});
it("boundaries: 65535 -> 65535 (valid, highest port)", () => {
const result = parse(65535);
expect(result.success).toBe(true);
if (result.success) {
expect(getPort(result.data)).toBe(65535);
}
});
it("G11: 65536 -> rejected with 'Range must be 0 - 65535'", () => {
const result = parse(65536);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toContain(
"Range must be 0 - 65535",
);
}
});
it("G11b: -1 -> rejected with 'Range must be 0 - 65535'", () => {
const result = parse(-1);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toContain(
"Range must be 0 - 65535",
);
}
});
it("G11c: '99999' -> rejected", () => {
const result = parse("99999");
expect(result.success).toBe(false);
});
it("G12: non-numeric garbage 'abc' -> NaN maps to null (existing behavior preserved)", () => {
const result = parse("abc");
expect(result.success).toBe(true);
if (result.success) {
expect(getPort(result.data)).toBeNull();
}
});
it("whitespace string ' 12 ' -> NaN -> null (parseInt(' 12 ')=12 but String then parseInt handles trim)", () => {
const result = parse(" 12 ");
expect(result.success).toBe(true);
if (result.success) {
expect(getPort(result.data)).toBe(12);
}
});
});

View File

@ -0,0 +1,169 @@
import type { MariadbNested } from "@dokploy/server/utils/databases/mariadb";
import { buildMariadb } from "@dokploy/server/utils/databases/mariadb";
import type { MongoNested } from "@dokploy/server/utils/databases/mongo";
import { buildMongo } from "@dokploy/server/utils/databases/mongo";
import type { MysqlNested } from "@dokploy/server/utils/databases/mysql";
import { buildMysql } from "@dokploy/server/utils/databases/mysql";
import type { PostgresNested } from "@dokploy/server/utils/databases/postgres";
import { buildPostgres } from "@dokploy/server/utils/databases/postgres";
import type { RedisNested } from "@dokploy/server/utils/databases/redis";
import { buildRedis } from "@dokploy/server/utils/databases/redis";
import { beforeEach, describe, expect, it, vi } from "vitest";
type MockSettings = {
EndpointSpec?: {
Mode?: string;
Ports?: Array<Record<string, unknown>>;
} | null;
[key: string]: unknown;
};
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
vi.hoisted(() => {
const inspect = vi.fn<() => Promise<never>>();
const getService = vi.fn(() => ({ inspect }));
const createService = vi.fn<(opts: MockSettings) => Promise<void>>(
async () => undefined,
);
const getRemoteDocker = vi.fn(async () => ({
getService,
createService,
}));
return {
inspectMock: inspect,
getServiceMock: getService,
createServiceMock: createService,
getRemoteDockerMock: getRemoteDocker,
};
});
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: getRemoteDockerMock,
}));
const baseFixture = {
appName: "test-db",
env: null,
dockerImage: "image:latest",
memoryLimit: null,
memoryReservation: null,
cpuLimit: null,
cpuReservation: null,
command: null,
args: null,
mounts: [],
databaseName: "db",
databaseUser: "user",
databasePassword: "pass",
databaseRootPassword: "rootpass",
externalPort: null as number | null,
serverId: "server-id",
environment: {
project: { env: null },
env: null,
},
updateConfigSwarm: null,
endpointSpecSwarm: null,
networkIds: [],
detachDokployNetwork: false,
};
type Case = {
name: string;
build: (input: unknown) => Promise<unknown>;
targetPort: number;
extras?: Record<string, unknown>;
};
const cases: Case[] = [
{
name: "mariadb",
build: (x) => buildMariadb(x as MariadbNested),
targetPort: 3306,
},
{
name: "mysql",
build: (x) => buildMysql(x as MysqlNested),
targetPort: 3306,
},
{
name: "postgres",
build: (x) => buildPostgres(x as PostgresNested),
targetPort: 5432,
},
{
name: "mongo",
build: (x) => buildMongo(x as MongoNested),
targetPort: 27017,
extras: { replicaSets: false },
},
{
name: "redis",
build: (x) => buildRedis(x as RedisNested),
targetPort: 6379,
},
];
beforeEach(() => {
inspectMock.mockReset();
inspectMock.mockImplementation(() => {
throw new Error("service not found");
});
getServiceMock.mockClear();
createServiceMock.mockClear();
getRemoteDockerMock.mockClear();
getRemoteDockerMock.mockResolvedValue({
getService: getServiceMock,
createService: createServiceMock,
});
});
const lastSettings = (): MockSettings => {
const call = createServiceMock.mock.calls[0] as [MockSettings] | undefined;
if (!call) throw new Error("createService was not called");
return call[0];
};
describe.each(cases)(
"build $name EndpointSpec.Ports (G13/G16)",
({ name, build, targetPort, extras }) => {
it("G13: externalPort null deploys with empty Ports (no host-published port = not internet-reachable)", async () => {
await build({ ...baseFixture, ...extras, externalPort: null });
expect(createServiceMock).toHaveBeenCalledTimes(1);
expect(lastSettings().EndpointSpec?.Ports).toEqual([]);
});
it("G16: externalPort set deploys with a single host-published port", async () => {
await build({ ...baseFixture, ...extras, externalPort: 13306 });
expect(createServiceMock).toHaveBeenCalledTimes(1);
const ports = lastSettings().EndpointSpec?.Ports;
expect(ports).toEqual([
{
Protocol: "tcp",
TargetPort: targetPort,
PublishedPort: 13306,
PublishMode: "host",
},
]);
});
it("G13b: externalPort 0 (falsy) deploys with empty Ports", async () => {
await build({ ...baseFixture, ...extras, externalPort: 0 });
expect(lastSettings().EndpointSpec?.Ports).toEqual([]);
expect(lastSettings().EndpointSpec?.Mode).toBe("dnsrr");
});
it("sanity: target port matches the expected $name default", async () => {
await build({ ...baseFixture, ...extras, externalPort: 13306 });
expect(lastSettings().EndpointSpec?.Ports?.[0]?.TargetPort).toBe(
targetPort,
);
if (name === "redis") {
expect(true).toBe(true);
}
});
},
);

View File

@ -0,0 +1,169 @@
import {
apiSaveExternalPortMariaDB,
apiSaveExternalPortMongo,
apiSaveExternalPortMySql,
apiSaveExternalPortPostgres,
apiSaveExternalPortRedis,
apiUpdateMariaDB,
apiUpdateMongo,
apiUpdateMySql,
apiUpdatePostgres,
apiUpdateRedis,
} from "@dokploy/server/db/schema";
import { describe, expect, it } from "vitest";
import type { z } from "zod";
const getExternalPort = (data: unknown): unknown =>
(data as { externalPort?: unknown } | null)?.externalPort;
type SchemaCase = {
name: string;
schema: z.ZodTypeAny;
updateSchema: z.ZodTypeAny;
idField: string;
idValue: string;
};
const cases: SchemaCase[] = [
{
name: "mariadb",
schema: apiSaveExternalPortMariaDB,
updateSchema: apiUpdateMariaDB,
idField: "mariadbId",
idValue: "mariadb-1",
},
{
name: "mysql",
schema: apiSaveExternalPortMySql,
updateSchema: apiUpdateMySql,
idField: "mysqlId",
idValue: "mysql-1",
},
{
name: "postgres",
schema: apiSaveExternalPortPostgres,
updateSchema: apiUpdatePostgres,
idField: "postgresId",
idValue: "postgres-1",
},
{
name: "mongo",
schema: apiSaveExternalPortMongo,
updateSchema: apiUpdateMongo,
idField: "mongoId",
idValue: "mongo-1",
},
{
name: "redis",
schema: apiSaveExternalPortRedis,
updateSchema: apiUpdateRedis,
idField: "redisId",
idValue: "redis-1",
},
];
describe.each(cases)(
"saveExternalPort schema for $name accepts null to revoke exposure",
({ schema, updateSchema, idField, idValue }) => {
it("accepts a valid published port number", () => {
const result = schema.safeParse({
[idField]: idValue,
externalPort: 3306,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual({
[idField]: idValue,
externalPort: 3306,
});
}
});
it("accepts null and preserves it (clearing revokes external exposure)", () => {
const result = schema.safeParse({
[idField]: idValue,
externalPort: null,
});
expect(result.success).toBe(true);
if (result.success) {
expect(getExternalPort(result.data)).toBeNull();
}
});
it("rejects a string port (client must coerce before sending)", () => {
const result = schema.safeParse({
[idField]: idValue,
externalPort: "3306",
});
expect(result.success).toBe(false);
});
it("rejects a missing/undefined port (field must be sent explicitly as null)", () => {
const result = schema.safeParse({ [idField]: idValue });
expect(result.success).toBe(false);
});
it("apiUpdate accepts input without externalPort (partial) (G6)", () => {
const result = updateSchema.safeParse({ [idField]: idValue });
expect(result.success).toBe(true);
if (result.success) {
expect(getExternalPort(result.data)).toBeUndefined();
}
});
it("apiUpdate accepts externalPort: null to clear via the update flow (G6)", () => {
const result = updateSchema.safeParse({
[idField]: idValue,
externalPort: null,
});
expect(result.success).toBe(true);
if (result.success) {
expect(getExternalPort(result.data)).toBeNull();
}
});
it("apiUpdate accepts a valid externalPort number (G6 no-regression)", () => {
const result = updateSchema.safeParse({
[idField]: idValue,
externalPort: 5432,
});
expect(result.success).toBe(true);
if (result.success) {
expect(getExternalPort(result.data)).toBe(5432);
}
});
},
);
describe("cross-DB lockstep: all 5 saveExternalPort schemas behave identically (H)", () => {
it("all accept { externalPort: null } and parse to null", () => {
for (const { schema, idField, idValue } of cases) {
const result = schema.safeParse({
[idField]: idValue,
externalPort: null,
});
expect(result.success, `${idField} null`).toBe(true);
if (result.success) {
expect(
getExternalPort(result.data),
`${idField} null value`,
).toBeNull();
}
}
});
it("all accept { externalPort: 5432 } and parse to 5432", () => {
for (const { schema, idField, idValue } of cases) {
const result = schema.safeParse({
[idField]: idValue,
externalPort: 5432,
});
expect(result.success, `${idField} 5432`).toBe(true);
if (result.success) {
expect(getExternalPort(result.data), `${idField} 5432 value`).toBe(
5432,
);
}
}
});
});

View File

@ -0,0 +1,129 @@
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFind = vi.hoisted(() => vi.fn());
const mockUpdate = vi.hoisted(() => vi.fn());
const mockDeploy = vi.hoisted(() => vi.fn());
const mockCheckPort = vi.hoisted(() => vi.fn());
const mockCheckPerm = vi.hoisted(() => vi.fn());
const mockAudit = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", () => ({
findMariadbById: mockFind,
updateMariadbById: mockUpdate,
deployMariadb: mockDeploy,
checkPortInUse: mockCheckPort,
IS_CLOUD: false,
}));
vi.mock("@dokploy/server/services/permission", () => ({
checkServicePermissionAndAccess: mockCheckPerm,
checkServiceAccess: vi.fn(),
checkPermission: vi.fn(),
findMemberByUserId: vi.fn(),
addNewService: vi.fn(),
hasPermission: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({ audit: mockAudit }));
const { mariadbRouter } = await import("@/server/api/routers/mariadb");
const mariadbFixture = {
mariadbId: "mariadb-1",
appName: "test-mariadb",
serverId: null,
externalPort: 3306,
};
const ctx = {
user: { id: "user-1", email: "u@test.com", role: "owner" },
session: { activeOrganizationId: "org-1" },
} as unknown as Parameters<typeof mariadbRouter.createCaller>[0];
const call = () => mariadbRouter.createCaller(ctx);
beforeEach(() => {
vi.clearAllMocks();
mockCheckPerm.mockResolvedValue(undefined);
mockFind.mockResolvedValue({ ...mariadbFixture });
mockUpdate.mockResolvedValue({ ...mariadbFixture });
mockDeploy.mockResolvedValue(undefined);
mockAudit.mockResolvedValue(undefined);
});
describe("mariadb.saveExternalPort handler (G13-G17)", () => {
it("G13+G14: externalPort null clears the port — update called with null, deploy called, checkPortInUse NOT called", async () => {
await call().saveExternalPort({
mariadbId: "mariadb-1",
externalPort: null,
});
expect(mockCheckPerm).toHaveBeenCalledWith(ctx, "mariadb-1", {
service: ["create"],
});
expect(mockCheckPort).not.toHaveBeenCalled();
expect(mockUpdate).toHaveBeenCalledWith("mariadb-1", {
externalPort: null,
});
expect(mockDeploy).toHaveBeenCalledWith("mariadb-1");
expect(mockAudit).toHaveBeenCalledOnce();
});
it("G16: externalPort number (free port) runs checkPortInUse and deploys with the port", async () => {
mockCheckPort.mockResolvedValue({ isInUse: false });
await call().saveExternalPort({
mariadbId: "mariadb-1",
externalPort: 3306,
});
expect(mockCheckPort).toHaveBeenCalledWith(3306, undefined);
expect(mockUpdate).toHaveBeenCalledWith("mariadb-1", {
externalPort: 3306,
});
expect(mockDeploy).toHaveBeenCalledWith("mariadb-1");
});
it("G15: externalPort number on a busy port throws CONFLICT and does NOT update/deploy", async () => {
mockCheckPort.mockResolvedValue({
isInUse: true,
conflictingContainer: "some-container",
});
await expect(
call().saveExternalPort({ mariadbId: "mariadb-1", externalPort: 3306 }),
).rejects.toMatchObject({
code: "CONFLICT",
message: "Port 3306 is already in use by some-container",
});
expect(mockUpdate).not.toHaveBeenCalled();
expect(mockDeploy).not.toHaveBeenCalled();
expect(mockAudit).not.toHaveBeenCalled();
});
it("G15b: checkPortInUse receives the serverId from the mariadb row when present", async () => {
mockFind.mockResolvedValue({ ...mariadbFixture, serverId: "server-9" });
mockCheckPort.mockResolvedValue({ isInUse: false });
await call().saveExternalPort({
mariadbId: "mariadb-1",
externalPort: 3306,
});
expect(mockCheckPort).toHaveBeenCalledWith(3306, "server-9");
});
it("G17: unauthorized caller is rejected before any service call", async () => {
mockCheckPerm.mockRejectedValue(new TRPCError({ code: "UNAUTHORIZED" }));
await expect(
call().saveExternalPort({ mariadbId: "mariadb-1", externalPort: null }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mockFind).not.toHaveBeenCalled();
expect(mockUpdate).not.toHaveBeenCalled();
expect(mockDeploy).not.toHaveBeenCalled();
});
});

View File

@ -3,7 +3,8 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import type { z } from "zod";
import { externalPortFormSchema as DockerProviderSchema } from "@/components/dashboard/shared/external-port-schema";
import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
@ -26,20 +27,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
}, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
});
type DockerProvider = z.infer<typeof DockerProviderSchema>;
interface Props {

View File

@ -3,7 +3,8 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import type { z } from "zod";
import { externalPortFormSchema as DockerProviderSchema } from "@/components/dashboard/shared/external-port-schema";
import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
@ -26,20 +27,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
}, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
});
type DockerProvider = z.infer<typeof DockerProviderSchema>;
interface Props {

View File

@ -3,7 +3,8 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import type { z } from "zod";
import { externalPortFormSchema as DockerProviderSchema } from "@/components/dashboard/shared/external-port-schema";
import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
@ -26,20 +27,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
}, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
});
type DockerProvider = z.infer<typeof DockerProviderSchema>;
interface Props {

View File

@ -3,7 +3,8 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import type { z } from "zod";
import { externalPortFormSchema as DockerProviderSchema } from "@/components/dashboard/shared/external-port-schema";
import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
@ -26,20 +27,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
}, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
});
type DockerProvider = z.infer<typeof DockerProviderSchema>;
interface Props {

View File

@ -3,7 +3,8 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import type { z } from "zod";
import { externalPortFormSchema as DockerProviderSchema } from "@/components/dashboard/shared/external-port-schema";
import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
@ -26,20 +27,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
}, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
});
type DockerProvider = z.infer<typeof DockerProviderSchema>;
interface Props {

View File

@ -0,0 +1,15 @@
import { z } from "zod";
export const externalPortFormSchema = z.object({
externalPort: z.preprocess((a) => {
if (a === null || a === undefined || a === "") return null;
const parsed = Number.parseInt(String(a), 10);
return Number.isNaN(parsed) ? null : parsed;
}, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
});
export type ExternalPortForm = z.infer<typeof externalPortFormSchema>;

View File

@ -145,7 +145,7 @@ const createSchema = createInsertSchema(mariadb, {
cpuLimit: z.string().optional(),
environmentId: z.string(),
applicationStatus: z.enum(["idle", "running", "done", "error"]),
externalPort: z.number(),
externalPort: z.number().nullable(),
description: z.string().optional(),
serverId: z.string().optional(),
healthCheckSwarm: HealthCheckSwarmSchema.nullable(),

View File

@ -135,7 +135,7 @@ const createSchema = createInsertSchema(mongo, {
cpuLimit: z.string().optional(),
environmentId: z.string(),
applicationStatus: z.enum(["idle", "running", "done", "error"]),
externalPort: z.number(),
externalPort: z.number().nullable(),
description: z.string().optional(),
serverId: z.string().optional(),
replicaSets: z.boolean().default(false),

View File

@ -142,7 +142,7 @@ const createSchema = createInsertSchema(mysql, {
cpuReservation: z.string().optional(),
cpuLimit: z.string().optional(),
applicationStatus: z.enum(["idle", "running", "done", "error"]),
externalPort: z.number(),
externalPort: z.number().nullable(),
description: z.string().optional(),
serverId: z.string().optional(),
healthCheckSwarm: HealthCheckSwarmSchema.nullable(),

View File

@ -136,7 +136,7 @@ const createSchema = createInsertSchema(postgres, {
cpuLimit: z.string().optional(),
environmentId: z.string(),
applicationStatus: z.enum(["idle", "running", "done", "error"]),
externalPort: z.number(),
externalPort: z.number().nullable(),
createdAt: z.string(),
description: z.string().optional(),
serverId: z.string().optional(),

View File

@ -126,7 +126,7 @@ const createSchema = createInsertSchema(redis, {
cpuLimit: z.string().optional(),
environmentId: z.string(),
applicationStatus: z.enum(["idle", "running", "done", "error"]),
externalPort: z.number(),
externalPort: z.number().nullable(),
description: z.string().optional(),
serverId: z.string().optional(),
healthCheckSwarm: HealthCheckSwarmSchema.nullable(),