Compare commits

..

32 Commits

Author SHA1 Message Date
dosubot[bot]
d9b41a258e docs: add comprehensive API and environment setup guide 2026-03-19 04:51:41 +00:00
Mauricio Siu
7c55eba506 Merge pull request #3923 from fdarian/feat/expose-drop-deployment-api
feat: expose drop deployment endpoint in public API
2026-03-18 22:49:57 -06:00
Mauricio Siu
7878bf29ba chore: update @dokploy/trpc-openapi to version 0.0.18
- Bumped the version of @dokploy/trpc-openapi in both package.json and pnpm-lock.yaml.
- Removed unnecessary metadata from the dropDeployment procedure in application.ts.
2026-03-18 22:49:08 -06:00
Mauricio Siu
1b70763ba5 Merge branch 'canary' into feat/expose-drop-deployment-api 2026-03-18 22:28:55 -06:00
Mauricio Siu
e47263ae5f Merge pull request #4033 from Dokploy/feat/improve-update-process-to-validate-dokploy-services
feat: enhance web server update process with health checks
2026-03-18 22:28:09 -06:00
autofix-ci[bot]
b139d6f277 [autofix.ci] apply automated fixes 2026-03-19 04:26:50 +00:00
Mauricio Siu
cddb06f515 feat: enhance web server update process with health checks
- Added health check functionality for PostgreSQL, Redis, and Traefik services before updating the web server.
- Introduced a modal state management system to guide users through the verification and update process.
- Updated UI components to display service health status and relevant messages during the update workflow.
- Refactored the update server button to reflect the latest version and availability of updates.
2026-03-18 22:26:12 -06:00
Mauricio Siu
d0c92d84ef fix: update API key deletion authorization check
- Changed the authorization check for deleting an API key to use referenceId instead of userId, ensuring proper validation against the current user's ID.
2026-03-18 16:33:19 -06:00
Mauricio Siu
72974e00a6 Merge pull request #4028 from Dokploy/4024-api-keys-not-working-and-unbale-to-generate-new-ones-after-upgrade-to-0287
feat: update apikey schema and relationships
2026-03-18 16:29:22 -06:00
Mauricio Siu
d96e2bbeb7 chore: bump version to v0.28.8 in package.json 2026-03-18 16:28:54 -06:00
Mauricio Siu
a45d8ee8f4 feat: update apikey schema and relationships
- Modified the apikey table to drop the user_id column and replace it with reference_id, establishing a foreign key relationship with the user table.
- Added config_id column with a default value to the apikey table.
- Updated related code in the account schema and user service to reflect these changes.
- Enhanced the journal and snapshot files to include the latest schema updates.
2026-03-18 16:26:05 -06:00
Mauricio Siu
9067452a38 feat: add role presets for custom role management
- Introduced predefined role presets with default permissions for viewer, developer, deployer, and devops roles to streamline custom role creation.
- Enhanced the UI to allow users to start from a preset role, improving the user experience in managing custom roles.
- Updated imports and adjusted component formatting for better readability.
2026-03-17 23:33:45 -06:00
Mauricio Siu
1fa4d5b2ba refactor: improve formatting and readability in billing and users components
- Enhanced code readability by adjusting formatting in the ShowBilling component, ensuring consistent line breaks and indentation.
- Updated the ShowUsers component to improve the layout of the warning message for users with custom roles without a valid license, maintaining clarity in the alert presentation.
2026-03-17 23:17:30 -06:00
Mauricio Siu
bade36ea9d feat: add alert for users with custom roles without a valid license
- Introduced an AlertBlock to notify users with custom roles that a valid Enterprise license is required for those roles to function.
- Implemented logic to check for users assigned to custom roles and display a warning if the license is invalid.
2026-03-17 23:16:17 -06:00
Mauricio Siu
0c22041623 refactor: update billing component to manage server quantities for hobby and startup tiers
- Replaced single server quantity state with separate states for hobby and startup server quantities.
- Adjusted calculations and UI elements to reflect the new state management for each tier.
- Ensured proper handling of server quantity in pricing calculations and button states.
2026-03-17 23:11:50 -06:00
Mauricio Siu
cccee05173 Merge pull request #4023 from Dokploy/4021-discord-error-notifications-fail-due-to-content-exceeding-max-embed-length
fix: truncate error message in backup notifications to 1010 characters
2026-03-17 22:47:35 -06:00
Mauricio Siu
9f9c8fccf2 Update packages/server/src/utils/notifications/database-backup.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-17 22:47:26 -06:00
Mauricio Siu
ad2e53a67a fix: truncate error message in backup notifications to 1010 characters
- Updated the error message formatting in both database and volume backup notification functions to limit the displayed message length, ensuring better readability and preventing overflow.
2026-03-17 22:17:36 -06:00
Mauricio Siu
00f3853bd7 chore: remove settings.json file for command permissions in Claude
- Deleted the settings.json file that defined permissions for various Bash commands and the default mode for Claude.
2026-03-17 18:19:37 -06:00
Mauricio Siu
2880327e94 feat: add settings configuration for command permissions in Claude
- Introduced a new settings.json file to define permissions for various Bash commands and set the default mode to bypassPermissions.
- Updated the version in package.json to v0.28.7.
2026-03-17 18:18:04 -06:00
Mauricio Siu
827b84f57e Merge pull request #4001 from WalidDevIO/fix/volume-backup-turn-off
fix(volume-backups): restart container before S3 upload in volume backup
2026-03-17 08:53:02 -06:00
Mauricio Siu
11aa8fe0c5 Update packages/server/src/utils/volume-backups/backup.ts 2026-03-17 08:51:31 -06:00
autofix-ci[bot]
b9ac720d99 [autofix.ci] apply automated fixes 2026-03-17 06:25:03 +00:00
Mauricio Siu
77b0ff7bbf Merge pull request #4016 from Dokploy/4003-first-application-deploy-to-swarm-worker-fails-with-unauthorized-no-such-image-retry-succeeds
fix: handle optional authConfig in mechanizeDockerContainer function
2026-03-17 00:18:14 -06:00
Mauricio Siu
e7af2c0ebd fix: handle optional authConfig in mechanizeDockerContainer function
- Updated the mechanizeDockerContainer function to conditionally use authConfig when creating a Docker service, ensuring proper service creation based on authentication settings.
2026-03-17 00:17:51 -06:00
Mauricio Siu
6a1bedb90f Merge pull request #4015 from Dokploy/3971-abnormal-webserver-backup-file-size-increase-500-kb-4-gb-overnight
fix: exclude volume-backups from web server backup rsync command
2026-03-16 23:27:16 -06:00
Mauricio Siu
a2f142174b fix: exclude volume-backups from web server backup rsync command
- Updated the rsync command in the runWebServerBackup function to exclude the 'volume-backups/' directory, ensuring that unnecessary data is not copied during the backup process.
2026-03-16 23:26:33 -06:00
EL OUAZIZI Walid
2f37235aea fix(volume-backups): restart container before S3 upload in volume backup 2026-03-15 06:46:33 +01:00
Farrel Darian
1203d0589b fix: use dedicated schema 2026-03-09 05:28:01 +07:00
Farrel Darian
653e5fa3a0 fix: validate applicationId
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-07 16:31:50 +07:00
Farrel Darian
66931fe24f feat: use zod-form-data schema for dropDeployment input
Switch from z.instanceof(FormData) to uploadFileSchema (zod-form-data)
so the OpenAPI generator produces a proper multipart/form-data spec
with typed fields (zip as binary, applicationId, dropBuildPath).

Regenerate openapi.json with the drop-deployment endpoint included.
2026-03-07 16:22:05 +07:00
Farrel Darian
7feb4061f8 feat: expose dropDeployment endpoint in public API
Enable file upload deployments via the public API, unlocking CI/CD workflows
similar to `railway up`. Users can now programmatically deploy by uploading
zip archives.

Depends on: Dokploy/trpc-openapi multipart/form-data support
2026-03-07 15:41:43 +07:00
26 changed files with 65138 additions and 21339 deletions

View File

@@ -77,6 +77,29 @@ Returns an array of deployment job objects with the same shape as BullMQ queue j
This endpoint is used by the UI to display deployment queue information in the dashboard.
### POST /drop-deployment
Upload and deploy application code via ZIP file.
**Content-Type:** `multipart/form-data`
**Form Fields:**
- `applicationId` (required) - The ID of the application to deploy
- `zip` (required) - A ZIP file containing the application code
- `dropBuildPath` (optional) - Custom build path within the ZIP file
**Response:**
Initiates a deployment using the uploaded ZIP file.
**Example:**
```bash
curl -X POST https://your-dokploy-instance.com/api/drop-deployment \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-F "applicationId=YOUR_APP_ID" \
-F "zip=@/path/to/your/app.zip" \
-F "dropBuildPath=optional/build/path"
```
## Search Endpoints
The following search endpoints provide flexible querying capabilities with pagination support. All search endpoints respect member permissions, returning only resources the user has access to.
@@ -255,34 +278,21 @@ The following database services all share the same search interface:
## Database Service Update Endpoints
The following database services support update operations with similar parameters:
All database services support update operations with flexible configuration options. The following database services share a common update interface:
- **postgres.update** (apiUpdatePostgres)
- **mysql.update** (apiUpdateMySql)
- **mariadb.update** (apiUpdateMariaDB)
- **mongo.update** (apiUpdateMongo)
- **redis.update** (apiUpdateRedis)
**Input Parameters:**
**Common Parameters:**
Each update endpoint accepts the following parameters (all optional except the service ID):
All database update endpoints accept their respective ID field (e.g., `postgresId`, `mysqlId`, `mariadbId`, `mongoId`, `redisId`) as a required parameter, along with optional configuration fields.
- `postgresId` / `mysqlId` / `mariadbId` / `mongoId` / `redisId` (required) - The ID of the database service to update
- `dockerImage` (optional string) - Custom Docker image to use for the database service
- Other service-specific parameters as defined in the schema
**Optional Configuration:**
- `dockerImage` (optional string) - Specifies a custom Docker image for the database service. This allows users to use specific versions or custom-built images instead of the default image for the database type. Available for all five database services (PostgreSQL, MySQL, MariaDB, MongoDB, and Redis).
**Example Input:**
```json
{
"postgresId": "string",
"dockerImage": "postgres:16-alpine"
}
```
**Response:**
Returns the updated database service object.
**Purpose:**
The `dockerImage` parameter allows users to specify custom Docker images for database services, providing flexibility for version selection, custom builds, or alternative distributions.
Additional service-specific parameters are available depending on the database type. The `dockerImage` field provides enhanced configuration flexibility for advanced use cases such as version pinning or using specialized database distributions.
## Whitelabeling Endpoints

View File

@@ -91,7 +91,10 @@ export const ShowBilling = () => {
api.stripe.upgradeSubscription.useMutation();
const utils = api.useUtils();
const [serverQuantity, setServerQuantity] = useState(3);
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
const [startupServerQuantity, setStartupServerQuantity] = useState(
STARTUP_SERVERS_INCLUDED,
);
const [isAnnual, setIsAnnual] = useState(false);
const [upgradeTier, setUpgradeTier] = useState<"hobby" | "startup" | null>(
null,
@@ -111,6 +114,12 @@ export const ShowBilling = () => {
productId: string,
) => {
const stripe = await stripePromise;
const serverQuantity =
tier === "startup"
? startupServerQuantity
: tier === "hobby"
? hobbyServerQuantity
: hobbyServerQuantity;
if (data && data.subscriptions.length === 0) {
createCheckoutSession({
tier,
@@ -679,7 +688,7 @@ export const ShowBilling = () => {
<p className="text-2xl font-semibold text-foreground">
$
{calculatePriceHobby(
serverQuantity,
hobbyServerQuantity,
isAnnual,
).toFixed(2)}
/{isAnnual ? "yr" : "mo"}
@@ -692,7 +701,8 @@ export const ShowBilling = () => {
<p className="text-xs text-muted-foreground mt-2">
$
{(
calculatePriceHobby(serverQuantity, true) / 12
calculatePriceHobby(hobbyServerQuantity, true) /
12
).toFixed(2)}
/mo
</p>
@@ -724,19 +734,19 @@ export const ShowBilling = () => {
Servers:
</span>
<Button
disabled={serverQuantity <= 1}
disabled={hobbyServerQuantity <= 1}
variant="outline"
size="icon"
onClick={() =>
setServerQuantity((q) => Math.max(1, q - 1))
setHobbyServerQuantity((q) => Math.max(1, q - 1))
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
value={hobbyServerQuantity}
onChange={(e) =>
setServerQuantity(
setHobbyServerQuantity(
Math.max(
1,
Number(
@@ -750,7 +760,7 @@ export const ShowBilling = () => {
<Button
variant="outline"
size="icon"
onClick={() => setServerQuantity((q) => q + 1)}
onClick={() => setHobbyServerQuantity((q) => q + 1)}
>
<PlusIcon className="h-4 w-4" />
</Button>
@@ -775,7 +785,7 @@ export const ShowBilling = () => {
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={serverQuantity < 1}
disabled={hobbyServerQuantity < 1}
>
Get Started
</Button>
@@ -806,7 +816,7 @@ export const ShowBilling = () => {
<p className="text-2xl font-semibold text-foreground">
$
{calculatePriceStartup(
serverQuantity,
startupServerQuantity,
isAnnual,
).toFixed(2)}
/{isAnnual ? "yr" : "mo"}
@@ -819,7 +829,10 @@ export const ShowBilling = () => {
<p className="text-xs text-muted-foreground mt-2">
$
{(
calculatePriceStartup(serverQuantity, true) / 12
calculatePriceStartup(
startupServerQuantity,
true,
) / 12
).toFixed(2)}
/mo
</p>
@@ -856,13 +869,14 @@ export const ShowBilling = () => {
<div className="flex items-center gap-2">
<Button
disabled={
serverQuantity <= STARTUP_SERVERS_INCLUDED
startupServerQuantity <=
STARTUP_SERVERS_INCLUDED
}
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
setServerQuantity((q) =>
setStartupServerQuantity((q) =>
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
)
}
@@ -870,9 +884,9 @@ export const ShowBilling = () => {
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
value={startupServerQuantity}
onChange={(e) =>
setServerQuantity(
setStartupServerQuantity(
Math.max(
STARTUP_SERVERS_INCLUDED,
Number(
@@ -887,7 +901,9 @@ export const ShowBilling = () => {
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setServerQuantity((q) => q + 1)}
onClick={() =>
setStartupServerQuantity((q) => q + 1)
}
>
<PlusIcon className="h-4 w-4" />
</Button>
@@ -917,7 +933,7 @@ export const ShowBilling = () => {
)
}
disabled={
serverQuantity < STARTUP_SERVERS_INCLUDED
startupServerQuantity < STARTUP_SERVERS_INCLUDED
}
>
Get Started
@@ -1009,7 +1025,7 @@ export const ShowBilling = () => {
<p className="text-2xl font-semibold tracking-tight text-primary ">
${" "}
{calculatePrice(
serverQuantity,
hobbyServerQuantity,
isAnnual,
).toFixed(2)}{" "}
USD
@@ -1018,7 +1034,10 @@ export const ShowBilling = () => {
<p className="text-base font-semibold tracking-tight text-muted-foreground">
${" "}
{(
calculatePrice(serverQuantity, isAnnual) / 12
calculatePrice(
hobbyServerQuantity,
isAnnual,
) / 12
).toFixed(2)}{" "}
/ Month USD
</p>
@@ -1026,9 +1045,10 @@ export const ShowBilling = () => {
) : (
<p className="text-2xl font-semibold tracking-tight text-primary ">
${" "}
{calculatePrice(serverQuantity, isAnnual).toFixed(
2,
)}{" "}
{calculatePrice(
hobbyServerQuantity,
isAnnual,
).toFixed(2)}{" "}
USD
</p>
)}
@@ -1071,26 +1091,28 @@ export const ShowBilling = () => {
<div className="flex flex-col gap-2 mt-4">
<div className="flex items-center gap-2 justify-center">
<span className="text-sm text-muted-foreground">
{serverQuantity} Servers
{hobbyServerQuantity} Servers
</span>
</div>
<div className="flex items-center space-x-2">
<Button
disabled={serverQuantity <= 1}
disabled={hobbyServerQuantity <= 1}
variant="outline"
onClick={() => {
if (serverQuantity <= 1) return;
if (hobbyServerQuantity <= 1) return;
setServerQuantity(serverQuantity - 1);
setHobbyServerQuantity(
hobbyServerQuantity - 1,
);
}}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
value={hobbyServerQuantity}
onChange={(e) => {
setServerQuantity(
setHobbyServerQuantity(
e.target.value as unknown as number,
);
}}
@@ -1099,7 +1121,9 @@ export const ShowBilling = () => {
<Button
variant="outline"
onClick={() => {
setServerQuantity(serverQuantity + 1);
setHobbyServerQuantity(
hobbyServerQuantity + 1,
);
}}
>
<PlusIcon className="h-4 w-4" />
@@ -1125,7 +1149,7 @@ export const ShowBilling = () => {
onClick={async () => {
handleCheckout("legacy", product.id);
}}
disabled={serverQuantity < 1}
disabled={hobbyServerQuantity < 1}
>
Subscribe
</Button>

View File

@@ -1,6 +1,7 @@
import { format } from "date-fns";
import { Loader2, MoreHorizontal, Users } from "lucide-react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -36,10 +37,19 @@ export const ShowUsers = () => {
const { data, isPending, refetch } = api.user.all.useQuery();
const { mutateAsync } = api.user.remove.useMutation();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: hasValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const utils = api.useUtils();
const { data: session } = api.user.session.useQuery();
const FREE_ROLES = ["owner", "admin", "member"];
const membersWithCustomRoles = data?.filter(
(member) => !FREE_ROLES.includes(member.role),
);
const hasCustomRolesWithoutLicense =
!hasValidLicense && (membersWithCustomRoles?.length ?? 0) > 0;
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
@@ -70,6 +80,18 @@ export const ShowUsers = () => {
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
{hasCustomRolesWithoutLicense && (
<AlertBlock type="warning">
You have{" "}
{membersWithCustomRoles?.length === 1
? "1 user"
: `${membersWithCustomRoles?.length} users`}{" "}
assigned to custom roles. Custom roles will not work
without a valid Enterprise license. Please activate your
license or change these users to a free role (Admin or
Member).
</AlertBlock>
)}
<Table>
<TableHeader>
<TableRow>

View File

@@ -1,4 +1,11 @@
import { HardDriveDownload, Loader2 } from "lucide-react";
import {
AlertTriangle,
CheckCircle2,
HardDriveDownload,
Loader2,
RefreshCw,
XCircle,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
@@ -15,11 +22,70 @@ import {
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
type ServiceStatus = {
status: "healthy" | "unhealthy";
message?: string;
};
type HealthResult = {
postgres: ServiceStatus;
redis: ServiceStatus;
traefik: ServiceStatus;
};
type ModalState = "idle" | "checking" | "results" | "updating";
const ServiceStatusItem = ({
name,
service,
}: {
name: string;
service: ServiceStatus;
}) => (
<div className="flex items-center gap-2">
{service.status === "healthy" ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
<span className="text-sm font-medium">{name}</span>
{service.status === "unhealthy" && service.message && (
<span className="text-xs text-muted-foreground"> {service.message}</span>
)}
</div>
);
export const UpdateWebServer = () => {
const [updating, setUpdating] = useState(false);
const [modalState, setModalState] = useState<ModalState>("idle");
const [open, setOpen] = useState(false);
const [healthResult, setHealthResult] = useState<HealthResult | null>(null);
const { mutateAsync: updateServer } = api.settings.updateServer.useMutation();
const { refetch: checkHealth } =
api.settings.checkInfrastructureHealth.useQuery(undefined, {
enabled: false,
});
const handleVerify = async () => {
setModalState("checking");
setHealthResult(null);
try {
const result = await checkHealth();
if (result.data) {
setHealthResult(result.data);
}
} catch {
// checkHealth failed entirely
}
setModalState("results");
};
const allHealthy =
healthResult &&
healthResult.postgres.status === "healthy" &&
healthResult.redis.status === "healthy" &&
healthResult.traefik.status === "healthy";
const checkIsUpdateFinished = async () => {
try {
@@ -33,28 +99,24 @@ export const UpdateWebServer = () => {
);
setTimeout(() => {
// Allow seeing the toast before reloading
window.location.reload();
}, 2000);
} catch {
// Delay each request
await new Promise((resolve) => setTimeout(resolve, 2000));
// Keep running until it returns 200
void checkIsUpdateFinished();
}
};
const handleConfirm = async () => {
try {
setUpdating(true);
setModalState("updating");
await updateServer();
// Give some time for docker service restart before starting to check status
await new Promise((resolve) => setTimeout(resolve, 8000));
await checkIsUpdateFinished();
} catch (error) {
setUpdating(false);
setModalState("results");
console.error("Error updating server:", error);
toast.error(
"An error occurred while updating the server, please try again.",
@@ -62,6 +124,14 @@ export const UpdateWebServer = () => {
}
};
const handleClose = () => {
if (modalState !== "updating") {
setOpen(false);
setModalState("idle");
setHealthResult(null);
}
};
return (
<AlertDialog open={open}>
<AlertDialogTrigger asChild>
@@ -81,36 +151,111 @@ export const UpdateWebServer = () => {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{updating
? "Server update in progress"
: "Are you absolutely sure?"}
{modalState === "idle" && "Are you absolutely sure?"}
{modalState === "checking" && "Verifying Services..."}
{modalState === "results" &&
(allHealthy ? "Ready to Update" : "Service Issues Detected")}
{modalState === "updating" && "Server update in progress"}
</AlertDialogTitle>
<AlertDialogDescription>
{updating ? (
<span className="flex items-center gap-1">
<Loader2 className="animate-spin" />
The server is being updated, please wait...
</span>
) : (
<>
This action cannot be undone. This will update the web server to
the new version. You will not be able to use the panel during
the update process. The page will be reloaded once the update is
finished.
</>
)}
<AlertDialogDescription asChild>
<div>
{modalState === "idle" && (
<span>
This will update the web server to the new version. You will
not be able to use the panel during the update process. The
page will be reloaded once the update is finished.
<br />
<br />
We recommend verifying that all services are running before
updating.
</span>
)}
{modalState === "checking" && (
<span className="flex items-center gap-2">
<Loader2 className="animate-spin h-4 w-4" />
Checking PostgreSQL, Redis and Traefik...
</span>
)}
{modalState === "results" && healthResult && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<ServiceStatusItem
name="PostgreSQL"
service={healthResult.postgres}
/>
<ServiceStatusItem
name="Redis"
service={healthResult.redis}
/>
<ServiceStatusItem
name="Traefik"
service={healthResult.traefik}
/>
</div>
{!allHealthy && (
<div className="flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 p-3">
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-0.5 shrink-0" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
Some services are not healthy. You can still proceed
with the update.
</span>
</div>
)}
{allHealthy && (
<span className="text-sm text-muted-foreground">
All services are running. You can proceed with the update.
</span>
)}
</div>
)}
{modalState === "results" && !healthResult && (
<div className="flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 p-3">
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-0.5 shrink-0" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
Could not verify services. You can still proceed with the
update.
</span>
</div>
)}
{modalState === "updating" && (
<span className="flex items-center gap-2">
<Loader2 className="animate-spin h-4 w-4" />
The server is being updated, please wait...
</span>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
{!updating && (
{modalState === "idle" && (
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOpen(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogCancel onClick={handleClose}>Cancel</AlertDialogCancel>
<Button variant="secondary" onClick={handleVerify}>
<RefreshCw className="h-4 w-4" />
Verify Status
</Button>
<AlertDialogAction onClick={handleConfirm}>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
)}
{modalState === "results" && (
<AlertDialogFooter>
<AlertDialogCancel onClick={handleClose}>Cancel</AlertDialogCancel>
<Button variant="secondary" onClick={handleVerify}>
<RefreshCw className="h-4 w-4" />
Re-check
</Button>
<AlertDialogAction onClick={handleConfirm}>
{allHealthy ? "Confirm" : "Confirm Anyway"}
</AlertDialogAction>
</AlertDialogFooter>
)}
</AlertDialogContent>
</AlertDialog>
);

View File

@@ -1,13 +1,20 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Loader2, PlusIcon, ShieldCheck, TrashIcon, Users } from "lucide-react";
import {
Loader2,
PlusIcon,
ShieldCheck,
Sparkles,
TrashIcon,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { DialogAction } from "@/components/shared/dialog-action";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import { Button } from "@/components/ui/button";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -24,11 +31,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Form,
FormControl,
@@ -38,6 +40,11 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
@@ -407,6 +414,114 @@ const ACTION_META: Record<
/** Resources that should be hidden from the custom role editor (better-auth internals) */
const HIDDEN_RESOURCES = ["organization", "invitation", "team", "ac"];
/** Predefined role presets with sensible permission defaults */
const ROLE_PRESETS: {
name: string;
label: string;
description: string;
permissions: Record<string, string[]>;
}[] = [
{
name: "viewer",
label: "Viewer",
description: "Read-only access across all resources",
permissions: {
service: ["read"],
environment: ["read"],
docker: ["read"],
sshKeys: ["read"],
gitProviders: ["read"],
traefikFiles: ["read"],
api: ["read"],
volume: ["read"],
deployment: ["read"],
envVars: ["read"],
projectEnvVars: ["read"],
environmentEnvVars: ["read"],
server: ["read"],
registry: ["read"],
certificate: ["read"],
backup: ["read"],
volumeBackup: ["read"],
schedule: ["read"],
domain: ["read"],
destination: ["read"],
notification: ["read"],
member: ["read"],
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
},
},
{
name: "developer",
label: "Developer",
description: "Deploy services, manage env vars, domains, and view logs",
permissions: {
project: ["create"],
service: ["create", "read"],
environment: ["create", "read"],
docker: ["read"],
gitProviders: ["read"],
api: ["read"],
volume: ["read", "create", "delete"],
deployment: ["read", "create", "cancel"],
envVars: ["read", "write"],
projectEnvVars: ["read"],
environmentEnvVars: ["read"],
domain: ["read", "create", "delete"],
schedule: ["read", "create", "update", "delete"],
logs: ["read"],
monitoring: ["read"],
},
},
{
name: "deployer",
label: "Deployer",
description: "Trigger and manage deployments only",
permissions: {
service: ["read"],
environment: ["read"],
deployment: ["read", "create", "cancel"],
logs: ["read"],
monitoring: ["read"],
},
},
{
name: "devops",
label: "DevOps",
description:
"Full infrastructure access: servers, registries, certs, backups, and deployments",
permissions: {
project: ["create", "delete"],
service: ["create", "read", "delete"],
environment: ["create", "read", "delete"],
docker: ["read"],
sshKeys: ["read", "create", "delete"],
gitProviders: ["read", "create", "delete"],
traefikFiles: ["read", "write"],
api: ["read"],
volume: ["read", "create", "delete"],
deployment: ["read", "create", "cancel"],
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "delete"],
backup: ["read", "create", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"],
domain: ["read", "create", "delete"],
destination: ["read", "create", "delete"],
notification: ["read", "create", "delete"],
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
},
},
];
const createRoleSchema = z.object({
roleName: z
.string()
@@ -552,7 +667,7 @@ function HandleCustomRole({
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[85vh] sm:max-w-5xl overflow-y-auto">
<DialogContent className="max-h-[85vh] sm:max-w-5xl overflow-y-auto space-y-2">
<DialogHeader>
<DialogTitle>
{isEdit ? "Edit Role" : "Create Custom Role"}
@@ -587,6 +702,32 @@ function HandleCustomRole({
/>
</form>
</Form>
{!isEdit && (
<div className="space-y-2 mt-4">
<p className="text-sm font-medium flex items-center gap-1.5">
<Sparkles className="size-3.5 text-muted-foreground" />
Start from a preset
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{ROLE_PRESETS.map((preset) => (
<button
key={preset.name}
type="button"
className="rounded-lg border p-3 text-left hover:bg-muted/50 transition-colors cursor-pointer space-y-1"
onClick={() => {
form.setValue("roleName", preset.name);
setPermissions({ ...preset.permissions });
}}
>
<p className="text-sm font-medium">{preset.label}</p>
<p className="text-xs text-muted-foreground leading-snug">
{preset.description}
</p>
</button>
))}
</div>
</div>
)}
<PermissionEditor
resources={visibleResources}
permissions={permissions}
@@ -843,7 +984,7 @@ function PermissionEditor({
onToggle: (resource: string, action: string) => void;
}) {
return (
<div className="space-y-3">
<div className="space-y-3 mt-4">
<p className="text-sm font-medium">Permissions</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{resources.map(([resource, actions]) => {

View File

@@ -0,0 +1,5 @@
ALTER TABLE "apikey" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "apikey" ADD COLUMN "config_id" text DEFAULT 'default' NOT NULL;--> statement-breakpoint
ALTER TABLE "apikey" ADD COLUMN "reference_id" text;--> statement-breakpoint
UPDATE "apikey" SET "reference_id" = "user_id" WHERE "reference_id" IS NULL;--> statement-breakpoint
ALTER TABLE "apikey" ALTER COLUMN "reference_id" SET NOT NULL;

View File

@@ -0,0 +1,4 @@
ALTER TABLE "apikey" DROP CONSTRAINT "apikey_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "apikey" ADD CONSTRAINT "apikey_reference_id_user_id_fk" FOREIGN KEY ("reference_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "apikey" DROP COLUMN "user_id";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1051,6 +1051,20 @@
"when": 1773637297592,
"tag": "0149_rare_radioactive_man",
"breakpoints": true
},
{
"idx": 150,
"version": "7",
"when": 1773870095817,
"tag": "0150_nappy_blue_blade",
"breakpoints": true
},
{
"idx": 151,
"version": "7",
"when": 1773872561300,
"tag": "0151_modern_sunfire",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.28.6",
"version": "v0.28.8",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -57,7 +57,7 @@
"@codemirror/search": "^6.6.0",
"@codemirror/view": "^6.39.15",
"@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.17",
"@dokploy/trpc-openapi": "0.0.18",
"@faker-js/faker": "^8.4.1",
"@hookform/resolvers": "^5.2.2",
"@octokit/auth-app": "^6.1.3",

View File

@@ -38,6 +38,7 @@ import { TRPCError } from "@trpc/server";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import { z } from "zod";
import { zfd } from "zod-form-data";
import {
createTRPCRouter,
protectedProcedure,
@@ -769,21 +770,17 @@ export const applicationRouter = createTRPCRouter({
}),
dropDeployment: protectedProcedure
.meta({
openapi: {
path: "/drop-deployment",
method: "POST",
override: true,
enabled: false,
},
})
.input(z.instanceof(FormData))
.input(
zfd.formData({
applicationId: z.string(),
zip: zfd.file(),
dropBuildPath: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const formData = input;
const zipFile = formData.get("zip") as File;
const applicationId = formData.get("applicationId") as string;
const dropBuildPath = formData.get("dropBuildPath") as string | null;
const zipFile = input.zip;
const applicationId = input.applicationId;
const dropBuildPath = input.dropBuildPath ?? null;
await checkServicePermissionAndAccess(ctx, applicationId, {
deployment: ["create"],

View File

@@ -2,6 +2,9 @@ import {
CLEANUP_CRON_JOB,
checkGPUStatus,
checkPortInUse,
checkPostgresHealth,
checkRedisHealth,
checkTraefikHealth,
cleanupAll,
cleanupAllBackground,
cleanupBuilders,
@@ -44,8 +47,8 @@ import {
writeTraefikConfigInPath,
writeTraefikSetup,
} from "@dokploy/server";
import { checkPermission } from "@dokploy/server/services/permission";
import { db } from "@dokploy/server/db";
import { checkPermission } from "@dokploy/server/services/permission";
import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
import { TRPCError } from "@trpc/server";
import { eq, sql } from "drizzle-orm";
@@ -864,6 +867,23 @@ export const settingsRouter = createTRPCRouter({
throw error;
}
}),
checkInfrastructureHealth: adminProcedure.query(async () => {
if (IS_CLOUD) {
return {
postgres: { status: "healthy" as const },
redis: { status: "healthy" as const },
traefik: { status: "healthy" as const },
};
}
const [postgres, redis, traefik] = await Promise.all([
checkPostgresHealth(),
checkRedisHealth(),
checkTraefikHealth(),
]);
return { postgres, redis, traefik };
}),
setupGPU: adminProcedure
.input(
z.object({

View File

@@ -21,7 +21,12 @@ import {
STARTUP_PRODUCT_ID,
WEBSITE_URL,
} from "@/server/utils/stripe";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
withPermission,
} from "../trpc";
export const stripeRouter = createTRPCRouter({
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
@@ -314,16 +319,18 @@ export const stripeRouter = createTRPCRouter({
return { ok: true };
}),
canCreateMoreServers: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const servers = await findServersByUserId(user.id);
canCreateMoreServers: withPermission("server", "create").query(
async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const servers = await findServersByUserId(user.id);
if (!IS_CLOUD) {
return true;
}
if (!IS_CLOUD) {
return true;
}
return servers.length < user.serversQuantity;
}),
return servers.length < user.serversQuantity;
},
),
getInvoices: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);

View File

@@ -465,7 +465,7 @@ export const userRouter = createTRPCRouter({
});
}
if (apiKeyToDelete.userId !== ctx.user.id) {
if (apiKeyToDelete.referenceId !== ctx.user.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this API key",

70177
openapi.json

File diff suppressed because it is too large Load Diff

View File

@@ -214,7 +214,8 @@ export const apikey = pgTable("apikey", {
start: text("start"),
prefix: text("prefix"),
key: text("key").notNull(),
userId: text("user_id")
configId: text("config_id").default("default").notNull(),
referenceId: text("reference_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
refillInterval: integer("refill_interval"),
@@ -236,7 +237,7 @@ export const apikey = pgTable("apikey", {
export const apikeyRelations = relations(apikey, ({ one }) => ({
user: one(user, {
fields: [apikey.userId],
fields: [apikey.referenceId],
references: [user.id],
}),
}));

View File

@@ -116,7 +116,7 @@ const { handler, api } = betterAuth({
emailAndPassword: {
enabled: true,
autoSignIn: !IS_CLOUD,
requireEmailVerification: IS_CLOUD,
requireEmailVerification: IS_CLOUD && process.env.NODE_ENV === "production",
password: {
async hash(password) {
return bcrypt.hashSync(password, 10);
@@ -367,6 +367,7 @@ const { handler, api } = betterAuth({
plugins: [
apiKey({
enableMetadata: true,
references: "user",
}),
sso(),
twoFactor(),

View File

@@ -432,7 +432,7 @@ export const createApiKey = async (
refillInterval?: number;
},
) => {
const apiKey = await auth.createApiKey({
const result = await auth.createApiKey({
body: {
name: input.name,
expiresIn: input.expiresIn,
@@ -450,10 +450,9 @@ export const createApiKey = async (
if (input.metadata) {
await db
.update(apikey)
.set({
metadata: JSON.stringify(input.metadata),
})
.where(eq(apikey.id, apiKey.id));
.set({ metadata: JSON.stringify(input.metadata) })
.where(eq(apikey.id, result.id));
}
return apiKey;
return result;
};

View File

@@ -67,7 +67,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
await execAsync(cleanupCommand);
await execAsync(
`rsync -a --ignore-errors --no-specials --no-devices ${BASE_PATH}/ ${tempDir}/filesystem/`,
`rsync -a --ignore-errors --no-specials --no-devices --exclude='volume-backups/' ${BASE_PATH}/ ${tempDir}/filesystem/`,
);
writeStream.write("Copied filesystem to temp directory\n");

View File

@@ -182,7 +182,11 @@ export const mechanizeDockerContainer = async (
});
} catch (error) {
console.log(error);
await docker.createService(settings);
if (authConfig) {
await docker.createService(authConfig, settings);
} else {
await docker.createService(settings);
}
}
};

View File

@@ -741,3 +741,177 @@ export const getComposeContainer = async (
throw error;
}
};
type ServiceHealthStatus = {
status: "healthy" | "unhealthy";
message?: string;
};
const checkSwarmServiceRunning = async (
serviceName: string,
): Promise<ServiceHealthStatus> => {
try {
const service = docker.getService(serviceName);
const info = await service.inspect();
const replicas = info.Spec?.Mode?.Replicated?.Replicas ?? 0;
if (replicas === 0) {
return {
status: "unhealthy",
message: "Service has 0 replicas configured",
};
}
// Check that at least one task is actually running
const tasks = await docker.listTasks({
filters: JSON.stringify({
service: [serviceName],
"desired-state": ["running"],
}),
});
const runningTask = tasks.find((t) => t.Status?.State === "running");
if (!runningTask) {
const latestTask = tasks[0];
const taskState = latestTask?.Status?.State ?? "unknown";
return {
status: "unhealthy",
message: `No running tasks (current state: ${taskState})`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message: error instanceof Error ? error.message : "Service not found",
};
}
};
const getSwarmServiceContainerId = async (
serviceName: string,
): Promise<string | null> => {
try {
const tasks = await docker.listTasks({
filters: JSON.stringify({
service: [serviceName],
"desired-state": ["running"],
}),
});
const runningTask = tasks.find((t) => t.Status?.State === "running");
return runningTask?.Status?.ContainerStatus?.ContainerID ?? null;
} catch {
return null;
}
};
export const checkPostgresHealth = async (): Promise<ServiceHealthStatus> => {
const serviceCheck = await checkSwarmServiceRunning("dokploy-postgres");
if (serviceCheck.status === "unhealthy") {
return serviceCheck;
}
// Verify PostgreSQL actually accepts connections
const containerId = await getSwarmServiceContainerId("dokploy-postgres");
if (!containerId) {
return { status: "unhealthy", message: "Could not find running container" };
}
try {
const exec = await docker.getContainer(containerId).exec({
Cmd: ["pg_isready", "-U", "dokploy"],
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({});
const output = await new Promise<string>((resolve) => {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.on("end", () => resolve(data));
});
const inspectResult = await exec.inspect();
if (inspectResult.ExitCode !== 0) {
return {
status: "unhealthy",
message: `PostgreSQL not ready: ${output.trim()}`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message:
error instanceof Error ? error.message : "Failed to check PostgreSQL",
};
}
};
export const checkRedisHealth = async (): Promise<ServiceHealthStatus> => {
const serviceCheck = await checkSwarmServiceRunning("dokploy-redis");
if (serviceCheck.status === "unhealthy") {
return serviceCheck;
}
// Verify Redis actually responds to PING
const containerId = await getSwarmServiceContainerId("dokploy-redis");
if (!containerId) {
return { status: "unhealthy", message: "Could not find running container" };
}
try {
const exec = await docker.getContainer(containerId).exec({
Cmd: ["redis-cli", "ping"],
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({});
const output = await new Promise<string>((resolve) => {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.on("end", () => resolve(data));
});
if (!output.includes("PONG")) {
return {
status: "unhealthy",
message: `Redis did not respond with PONG: ${output.trim()}`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message: error instanceof Error ? error.message : "Failed to check Redis",
};
}
};
export const checkTraefikHealth = async (): Promise<ServiceHealthStatus> => {
// Traefik can run as a standalone container or a swarm service
try {
const container = docker.getContainer("dokploy-traefik");
const info = await container.inspect();
if (!info.State.Running) {
return {
status: "unhealthy",
message: "Container is not running",
};
}
return { status: "healthy" };
} catch {
// Not a standalone container, check as swarm service
return checkSwarmServiceRunning("dokploy-traefik");
}
};

View File

@@ -153,7 +153,7 @@ export const sendDatabaseBackupNotifications = async ({
? [
{
name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``,
value: `\`\`\`${errorMessage.length > 1010 ? `${errorMessage.substring(0, 1010)}...` : errorMessage}\`\`\``,
},
]
: []),

View File

@@ -161,7 +161,7 @@ export const sendVolumeBackupNotifications = async ({
? [
{
name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``,
value: `\`\`\`${errorMessage.substring(0, 1010)}\`\`\``,
},
]
: []),

View File

@@ -39,7 +39,7 @@ export const backupVolume = async (
const rcloneCommand = `rclone copyto ${rcloneFlags.join(" ")} "${volumeBackupPath}/${backupFileName}" "${rcloneDestination}"`;
const baseCommand = `
const backupCommand = `
set -e
echo "Volume name: ${volumeName}"
echo "Backup file name: ${backupFileName}"
@@ -52,6 +52,9 @@ export const backupVolume = async (
ubuntu \
bash -c "cd /volume_data && tar cvf /backup/${backupFileName} ."
echo "Volume backup done ✅"
`;
const uploadCommand = `
echo "Starting upload to S3..."
${rcloneCommand}
echo "Upload to S3 done ✅"
@@ -61,7 +64,10 @@ export const backupVolume = async (
`;
if (!turnOff) {
return baseCommand;
return `
${backupCommand}
${uploadCommand}
`;
}
const serviceLockId =
@@ -110,9 +116,10 @@ export const backupVolume = async (
ACTUAL_REPLICAS=$(docker service inspect ${volumeBackup.application?.appName} --format "{{.Spec.Mode.Replicated.Replicas}}")
echo "Actual replicas: $ACTUAL_REPLICAS"
docker service update --replicas=0 ${volumeBackup.application?.appName}
${baseCommand}
${backupCommand}
echo "Starting application to $ACTUAL_REPLICAS replicas"
docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${volumeBackup.application?.appName}
${uploadCommand}
`);
}
if (serviceType === "compose") {
@@ -147,8 +154,9 @@ export const backupVolume = async (
}
return lockWrapper(`
${stopCommand}
${baseCommand}
${backupCommand}
${startCommand}
${uploadCommand}
`);
}
};

10
pnpm-lock.yaml generated
View File

@@ -147,8 +147,8 @@ importers:
specifier: workspace:*
version: link:../../packages/server
'@dokploy/trpc-openapi':
specifier: 0.0.17
version: 0.0.17(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)
specifier: 0.0.18
version: 0.0.18(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)
'@faker-js/faker':
specifier: ^8.4.1
version: 8.4.1
@@ -1282,8 +1282,8 @@ packages:
'@codemirror/view@6.39.15':
resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==}
'@dokploy/trpc-openapi@0.0.17':
resolution: {integrity: sha512-pXWbqx2W0MoWav/wehEqcXzORLgn7PhnmLsZza1v6+lOSo0Vwuu47PrITbRYKQ2zZcR1nTL18TrgPuMzXK23Iw==}
'@dokploy/trpc-openapi@0.0.18':
resolution: {integrity: sha512-CbppvUEe8eK1fiNGQL5AH8KIRRlHk5bGPUEIyc2VBZE0un4kfUs5DXKSKsMLDomoES5ZEdrjT4nKpwYvhDha0w==}
peerDependencies:
'@trpc/server': ^11.1.0
zod: ^4.3.6
@@ -8926,7 +8926,7 @@ snapshots:
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@dokploy/trpc-openapi@0.0.17(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)':
'@dokploy/trpc-openapi@0.0.18(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)':
dependencies:
'@trpc/server': 11.10.0(typescript@5.9.3)
co-body: 6.2.0