From f5c21db1ef47b47f0a1bf3180eaed60373f13c4b Mon Sep 17 00:00:00 2001 From: Souvik Kumar Date: Sun, 9 Aug 2026 21:05:37 +0530 Subject: [PATCH 01/31] fix: mark session cookie as Secure when served over HTTPS Self-hosted better-auth is configured with secure cookies off so plain HTTP access (http://:3000) keeps working, but that also meant the session cookie never got the Secure attribute even behind HTTPS. A cookie without Secure can be replayed over plain HTTP. Add Secure to the auth response cookies per request when X-Forwarded-Proto is https (what Traefik sends on TLS), leaving plain-HTTP requests untouched. Fixes #4709 --- .../__test__/lib/secure-cookies.test.ts | 46 +++++++++++++++++++ apps/dokploy/lib/secure-cookies.ts | 25 ++++++++++ apps/dokploy/pages/api/auth/[...all].ts | 25 +++++++++- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/__test__/lib/secure-cookies.test.ts create mode 100644 apps/dokploy/lib/secure-cookies.ts diff --git a/apps/dokploy/__test__/lib/secure-cookies.test.ts b/apps/dokploy/__test__/lib/secure-cookies.test.ts new file mode 100644 index 000000000..c47633b4a --- /dev/null +++ b/apps/dokploy/__test__/lib/secure-cookies.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies"; + +describe("isHttpsRequest", () => { + it("returns true when X-Forwarded-Proto is https", () => { + expect(isHttpsRequest("https")).toBe(true); + }); + + it("returns false for http or a missing header", () => { + expect(isHttpsRequest("http")).toBe(false); + expect(isHttpsRequest(undefined)).toBe(false); + }); + + it("uses the left-most value of a proxy chain", () => { + expect(isHttpsRequest("https, http")).toBe(true); + expect(isHttpsRequest("http, https")).toBe(false); + }); + + it("handles an array header value and is case-insensitive", () => { + expect(isHttpsRequest(["HTTPS", "http"])).toBe(true); + }); +}); + +describe("secureSetCookie", () => { + it("adds Secure to a cookie that lacks it", () => { + expect( + secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"), + ).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure"); + }); + + it("does not duplicate Secure when already present", () => { + const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly"; + expect(secureSetCookie(cookie)).toBe(cookie); + }); + + it("applies to every cookie in an array", () => { + expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([ + "a=1; Path=/; Secure", + "b=2; Path=/; Secure", + ]); + }); + + it("passes a numeric header value through untouched", () => { + expect(secureSetCookie(123)).toBe(123); + }); +}); diff --git a/apps/dokploy/lib/secure-cookies.ts b/apps/dokploy/lib/secure-cookies.ts new file mode 100644 index 000000000..ab11b52ce --- /dev/null +++ b/apps/dokploy/lib/secure-cookies.ts @@ -0,0 +1,25 @@ +export const isHttpsRequest = ( + forwardedProto: string | string[] | undefined, +): boolean => { + if (!forwardedProto) return false; + const value = Array.isArray(forwardedProto) + ? forwardedProto[0] + : forwardedProto; + // take the first value in case a proxy chain sent a list + return value?.split(",")[0]?.trim().toLowerCase() === "https"; +}; + +const hasSecureAttribute = (cookie: string): boolean => + /;\s*secure(?:\s*;|\s*$)/i.test(cookie); + +const withSecure = (cookie: string): string => + hasSecureAttribute(cookie) ? cookie : `${cookie}; Secure`; + +// Add Secure to a Set-Cookie header value (string or string[]). +export const secureSetCookie = ( + value: string | number | readonly string[], +): string | number | string[] => { + if (typeof value === "number") return value; + if (Array.isArray(value)) return value.map(withSecure); + return withSecure(value as string); +}; diff --git a/apps/dokploy/pages/api/auth/[...all].ts b/apps/dokploy/pages/api/auth/[...all].ts index 48aa03706..7edbb9847 100644 --- a/apps/dokploy/pages/api/auth/[...all].ts +++ b/apps/dokploy/pages/api/auth/[...all].ts @@ -1,7 +1,30 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; import { auth } from "@dokploy/server/index"; import { toNodeHandler } from "better-auth/node"; +import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies"; // Disallow body parsing, we will parse it manually export const config = { api: { bodyParser: false } }; -export default toNodeHandler(auth.handler); +const handler = toNodeHandler(auth.handler); + +// Mark the session cookie as Secure when the request comes over HTTPS +export default function authHandler(req: IncomingMessage, res: ServerResponse) { + if (isHttpsRequest(req.headers["x-forwarded-proto"])) { + const setHeader = res.setHeader.bind(res); + res.setHeader = (name, value) => + String(name).toLowerCase() === "set-cookie" + ? setHeader(name, secureSetCookie(value)) + : setHeader(name, value); + + const appendHeader = res.appendHeader?.bind(res); + if (appendHeader) { + res.appendHeader = (name, value) => + String(name).toLowerCase() === "set-cookie" + ? appendHeader(name, secureSetCookie(value) as string | string[]) + : appendHeader(name, value); + } + } + + return handler(req, res); +} From 4cd4b835232141303d13276cf06f4b177b627e4f Mon Sep 17 00:00:00 2001 From: Narciso Date: Thu, 10 Sep 2026 18:23:38 -0400 Subject: [PATCH 02/31] fix(deps): bump next to 16.3.4 to patch critical RCE advisories GHSA-2xp9-vwfh-vxw4 and GHSA-p293-qw3h-jr36 --- apps/dokploy/package.json | 2 +- pnpm-lock.yaml | 486 +++++++++++++++++++------------------- 2 files changed, 244 insertions(+), 244 deletions(-) diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index 2ae67ad0d..74140a3cf 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -109,7 +109,7 @@ "lucide-react": "^0.469.0", "micromatch": "4.0.8", "nanoid": "3.3.11", - "next": "16.3.0", + "next": "16.3.4", "next-themes": "^0.2.1", "nextjs-toploader": "^3.9.17", "node-os-utils": "2.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3b37ef6b..83646af4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,7 +55,7 @@ importers: version: 4.12.2 inngest: specifier: 3.40.1 - version: 3.40.1(encoding@0.1.13)(express@5.2.1)(h3@1.15.1)(hono@4.12.2)(next@16.3.0(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2) + version: 3.40.1(encoding@0.1.13)(express@5.2.1)(h3@1.15.1)(hono@4.12.2)(next@16.3.4(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2) pino: specifier: 9.4.0 version: 9.4.0 @@ -128,16 +128,16 @@ importers: version: 3.1108.0 '@better-auth/api-key': specifier: 1.6.23 - version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(165e0d14ffc9aaf8b4cab70e414c9093))(better-call@1.3.7(zod@4.3.6)) + version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(9540743d27d6a8e6f2915ad16dc18aab))(better-call@1.3.7(zod@4.3.6)) '@better-auth/passkey': specifier: 1.6.23 - version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(165e0d14ffc9aaf8b4cab70e414c9093))(better-call@1.3.7(zod@4.3.6))(nanostores@1.1.1) + version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(9540743d27d6a8e6f2915ad16dc18aab))(better-call@1.3.7(zod@4.3.6))(nanostores@1.1.1) '@better-auth/scim': specifier: 1.6.23 - version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(165e0d14ffc9aaf8b4cab70e414c9093))(better-call@1.3.7(zod@4.3.6)) + version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(9540743d27d6a8e6f2915ad16dc18aab))(better-call@1.3.7(zod@4.3.6)) '@better-auth/sso': specifier: 1.6.23 - version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(165e0d14ffc9aaf8b4cab70e414c9093))(better-call@1.3.7(zod@4.3.6)) + version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(9540743d27d6a8e6f2915ad16dc18aab))(better-call@1.3.7(zod@4.3.6)) '@codemirror/autocomplete': specifier: ^6.18.6 version: 6.20.3 @@ -182,7 +182,7 @@ importers: version: 13.9.1 '@openpanel/nextjs': specifier: ^1.5.1 - version: 1.5.1(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.5.1(next@16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@react-email/components': specifier: ^1.0.12 version: 1.0.12(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -206,7 +206,7 @@ importers: version: 11.10.0(@trpc/server@11.10.0(typescript@7.0.2))(typescript@7.0.2) '@trpc/next': specifier: ^11.10.0 - version: 11.10.0(@tanstack/react-query@5.90.21(react@19.2.7))(@trpc/client@11.10.0(@trpc/server@11.10.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.10.0(@tanstack/react-query@5.90.21(react@19.2.7))(@trpc/client@11.10.0(@trpc/server@11.10.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.10.0(typescript@7.0.2))(react@19.2.7)(typescript@7.0.2))(@trpc/server@11.10.0(typescript@7.0.2))(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2) + version: 11.10.0(@tanstack/react-query@5.90.21(react@19.2.7))(@trpc/client@11.10.0(@trpc/server@11.10.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.10.0(@tanstack/react-query@5.90.21(react@19.2.7))(@trpc/client@11.10.0(@trpc/server@11.10.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.10.0(typescript@7.0.2))(react@19.2.7)(typescript@7.0.2))(@trpc/server@11.10.0(typescript@7.0.2))(next@16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2) '@trpc/react-query': specifier: ^11.10.0 version: 11.10.0(@tanstack/react-query@5.90.21(react@19.2.7))(@trpc/client@11.10.0(@trpc/server@11.10.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.10.0(typescript@7.0.2))(react@19.2.7)(typescript@7.0.2) @@ -242,7 +242,7 @@ importers: version: 5.1.1(encoding@0.1.13) better-auth: specifier: 1.6.23 - version: 1.6.23(165e0d14ffc9aaf8b4cab70e414c9093) + version: 1.6.23(9540743d27d6a8e6f2915ad16dc18aab) bl: specifier: 6.0.11 version: 6.0.11 @@ -301,14 +301,14 @@ importers: specifier: 3.3.11 version: 3.3.11 next: - specifier: 16.3.0 - version: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: 16.3.4 + version: 16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-themes: specifier: ^0.2.1 - version: 0.2.1(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 0.2.1(next@16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) nextjs-toploader: specifier: ^3.9.17 - version: 3.9.17(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 3.9.17(next@16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) node-os-utils: specifier: 2.0.1 version: 2.0.1 @@ -607,16 +607,16 @@ importers: version: 3.1108.0 '@better-auth/api-key': specifier: 1.6.23 - version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(e687b32eb00a1602e570e2163c94e965))(better-call@1.3.7(zod@4.3.6)) + version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(1f696af711ca01112337d810adfad25c))(better-call@1.3.7(zod@4.3.6)) '@better-auth/passkey': specifier: 1.6.23 - version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(e687b32eb00a1602e570e2163c94e965))(better-call@1.3.7(zod@4.3.6))(nanostores@1.1.1) + version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(1f696af711ca01112337d810adfad25c))(better-call@1.3.7(zod@4.3.6))(nanostores@1.1.1) '@better-auth/scim': specifier: 1.6.23 - version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(e687b32eb00a1602e570e2163c94e965))(better-call@1.3.7(zod@4.3.6)) + version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(1f696af711ca01112337d810adfad25c))(better-call@1.3.7(zod@4.3.6)) '@better-auth/sso': specifier: 1.6.23 - version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(e687b32eb00a1602e570e2163c94e965))(better-call@1.3.7(zod@4.3.6)) + version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(1f696af711ca01112337d810adfad25c))(better-call@1.3.7(zod@4.3.6)) '@better-auth/utils': specifier: 0.4.2 version: 0.4.2 @@ -655,7 +655,7 @@ importers: version: 5.1.1(encoding@0.1.13) better-auth: specifier: 1.6.23 - version: 1.6.23(e687b32eb00a1602e570e2163c94e965) + version: 1.6.23(1f696af711ca01112337d810adfad25c) bl: specifier: 6.0.11 version: 6.0.11 @@ -1572,144 +1572,144 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.3': - resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.3': - resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.3': - resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.2': - resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.2': - resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.2': - resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.3.2': - resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.3.2': - resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-riscv64@1.3.2': - resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.3.2': - resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.3.2': - resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': - resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.3.2': - resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.35.3': - resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.35.3': - resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.35.3': - resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - '@img/sharp-linux-riscv64@0.35.3': - resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - '@img/sharp-linux-s390x@0.35.3': - resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.35.3': - resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.35.3': - resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.35.3': - resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.35.3': - resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.3': - resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.3': - resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.3': - resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.3': - resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -1947,53 +1947,53 @@ packages: cpu: [x64] os: [win32] - '@next/env@16.3.0': - resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==} + '@next/env@16.3.4': + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} - '@next/swc-darwin-arm64@16.3.0': - resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==} + '@next/swc-darwin-arm64@16.3.4': + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.0': - resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==} + '@next/swc-darwin-x64@16.3.4': + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.0': - resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==} + '@next/swc-linux-arm64-gnu@16.3.4': + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.3.0': - resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==} + '@next/swc-linux-arm64-musl@16.3.4': + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.3.0': - resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==} + '@next/swc-linux-x64-gnu@16.3.4': + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.3.0': - resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==} + '@next/swc-linux-x64-musl@16.3.4': + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.3.0': - resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==} + '@next/swc-win32-arm64-msvc@16.3.4': + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.0': - resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==} + '@next/swc-win32-x64-msvc@16.3.4': + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -4099,8 +4099,8 @@ packages: resolution: {integrity: sha512-qMx1nOrzoB+PF+pzb26Q4Tc2sOlrx9Ba2UBNX9hB31Omrq+QoZ2Gly0KLrQWw4Of1AQ4J9lnD+XOdwOdcdXqqw==} engines: {node: '>=12.20.0'} - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} '@szmarczak/http-timer@5.0.1': resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} @@ -7233,8 +7233,8 @@ packages: react: '*' react-dom: '*' - next@16.3.0: - resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==} + next@16.3.4: + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -8285,8 +8285,8 @@ packages: resolution: {integrity: sha512-UV0cchFea9hO7poV1CuEP0wvmYjpAqcxCKdy23bndl2Du2ARtDs8A4xdzfhUjDBeOW1nNpJ6lXmsEpsply2SfQ==} hasBin: true - sharp@0.35.3: - resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -9683,19 +9683,19 @@ snapshots: '@balena/dockerignore@1.0.2': {} - '@better-auth/api-key@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(165e0d14ffc9aaf8b4cab70e414c9093))(better-call@1.3.7(zod@4.3.6))': + '@better-auth/api-key@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(1f696af711ca01112337d810adfad25c))(better-call@1.3.7(zod@4.3.6))': dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 - better-auth: 1.6.23(165e0d14ffc9aaf8b4cab70e414c9093) + better-auth: 1.6.23(1f696af711ca01112337d810adfad25c) better-call: 1.3.7(zod@4.3.6) zod: 4.3.6 - '@better-auth/api-key@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(e687b32eb00a1602e570e2163c94e965))(better-call@1.3.7(zod@4.3.6))': + '@better-auth/api-key@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(9540743d27d6a8e6f2915ad16dc18aab))(better-call@1.3.7(zod@4.3.6))': dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 - better-auth: 1.6.23(e687b32eb00a1602e570e2163c94e965) + better-auth: 1.6.23(9540743d27d6a8e6f2915ad16dc18aab) better-call: 1.3.7(zod@4.3.6) zod: 4.3.6 @@ -9739,26 +9739,26 @@ snapshots: optionalDependencies: mongodb: 7.1.0(socks@2.8.8) - '@better-auth/passkey@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(165e0d14ffc9aaf8b4cab70e414c9093))(better-call@1.3.7(zod@4.3.6))(nanostores@1.1.1)': + '@better-auth/passkey@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(1f696af711ca01112337d810adfad25c))(better-call@1.3.7(zod@4.3.6))(nanostores@1.1.1)': dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@simplewebauthn/browser': 13.3.0 '@simplewebauthn/server': 13.3.2 - better-auth: 1.6.23(165e0d14ffc9aaf8b4cab70e414c9093) + better-auth: 1.6.23(1f696af711ca01112337d810adfad25c) better-call: 1.3.7(zod@4.3.6) nanostores: 1.1.1 zod: 4.3.6 - '@better-auth/passkey@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(e687b32eb00a1602e570e2163c94e965))(better-call@1.3.7(zod@4.3.6))(nanostores@1.1.1)': + '@better-auth/passkey@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(9540743d27d6a8e6f2915ad16dc18aab))(better-call@1.3.7(zod@4.3.6))(nanostores@1.1.1)': dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@simplewebauthn/browser': 13.3.0 '@simplewebauthn/server': 13.3.2 - better-auth: 1.6.23(e687b32eb00a1602e570e2163c94e965) + better-auth: 1.6.23(9540743d27d6a8e6f2915ad16dc18aab) better-call: 1.3.7(zod@4.3.6) nanostores: 1.1.1 zod: 4.3.6 @@ -9771,28 +9771,28 @@ snapshots: '@prisma/client': 5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)) prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2) - '@better-auth/scim@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(165e0d14ffc9aaf8b4cab70e414c9093))(better-call@1.3.7(zod@4.3.6))': + '@better-auth/scim@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(1f696af711ca01112337d810adfad25c))(better-call@1.3.7(zod@4.3.6))': dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 - better-auth: 1.6.23(165e0d14ffc9aaf8b4cab70e414c9093) + better-auth: 1.6.23(1f696af711ca01112337d810adfad25c) better-call: 1.3.7(zod@4.3.6) zod: 4.3.6 - '@better-auth/scim@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(e687b32eb00a1602e570e2163c94e965))(better-call@1.3.7(zod@4.3.6))': + '@better-auth/scim@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(9540743d27d6a8e6f2915ad16dc18aab))(better-call@1.3.7(zod@4.3.6))': dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 - better-auth: 1.6.23(e687b32eb00a1602e570e2163c94e965) + better-auth: 1.6.23(9540743d27d6a8e6f2915ad16dc18aab) better-call: 1.3.7(zod@4.3.6) zod: 4.3.6 - '@better-auth/sso@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(165e0d14ffc9aaf8b4cab70e414c9093))(better-call@1.3.7(zod@4.3.6))': + '@better-auth/sso@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(1f696af711ca01112337d810adfad25c))(better-call@1.3.7(zod@4.3.6))': dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.6.23(165e0d14ffc9aaf8b4cab70e414c9093) + better-auth: 1.6.23(1f696af711ca01112337d810adfad25c) better-call: 1.3.7(zod@4.3.6) fast-xml-parser: 5.9.3 jose: 6.1.3 @@ -9800,12 +9800,12 @@ snapshots: tldts: 6.1.86 zod: 4.3.6 - '@better-auth/sso@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(e687b32eb00a1602e570e2163c94e965))(better-call@1.3.7(zod@4.3.6))': + '@better-auth/sso@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(9540743d27d6a8e6f2915ad16dc18aab))(better-call@1.3.7(zod@4.3.6))': dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.6.23(e687b32eb00a1602e570e2163c94e965) + better-auth: 1.6.23(9540743d27d6a8e6f2915ad16dc18aab) better-call: 1.3.7(zod@4.3.6) fast-xml-parser: 5.9.3 jose: 6.1.3 @@ -10160,108 +10160,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.35.3': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.35.3': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.3': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.2': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.2': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.2': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.2': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.2': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.2': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.2': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.2': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.2': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.35.3': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.35.3': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.35.3': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.35.3': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.35.3': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.35.3': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.3': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.3': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.35.3': + '@img/sharp-wasm32@0.35.4': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.3': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.35.3': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.35.3': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.35.3': + '@img/sharp-win32-x64@0.35.4': optional: true '@inngest/ai@0.1.7': @@ -10527,30 +10527,30 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true - '@next/env@16.3.0': {} + '@next/env@16.3.4': {} - '@next/swc-darwin-arm64@16.3.0': + '@next/swc-darwin-arm64@16.3.4': optional: true - '@next/swc-darwin-x64@16.3.0': + '@next/swc-darwin-x64@16.3.4': optional: true - '@next/swc-linux-arm64-gnu@16.3.0': + '@next/swc-linux-arm64-gnu@16.3.4': optional: true - '@next/swc-linux-arm64-musl@16.3.0': + '@next/swc-linux-arm64-musl@16.3.4': optional: true - '@next/swc-linux-x64-gnu@16.3.0': + '@next/swc-linux-x64-gnu@16.3.4': optional: true - '@next/swc-linux-x64-musl@16.3.0': + '@next/swc-linux-x64-musl@16.3.4': optional: true - '@next/swc-win32-arm64-msvc@16.3.0': + '@next/swc-win32-arm64-msvc@16.3.4': optional: true - '@next/swc-win32-x64-msvc@16.3.0': + '@next/swc-win32-x64-msvc@16.3.4': optional: true '@noble/ciphers@1.3.0': {} @@ -10780,10 +10780,10 @@ snapshots: '@octokit/request-error': 6.1.8 '@octokit/webhooks-methods': 5.1.1 - '@openpanel/nextjs@1.5.1(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@openpanel/nextjs@1.5.1(next@16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@openpanel/web': 1.4.1 - next: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -13294,7 +13294,7 @@ snapshots: dependencies: apg-lite: 1.0.5 - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -13407,11 +13407,11 @@ snapshots: '@trpc/server': 11.10.0(typescript@7.0.2) typescript: 7.0.2 - '@trpc/next@11.10.0(@tanstack/react-query@5.90.21(react@19.2.7))(@trpc/client@11.10.0(@trpc/server@11.10.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.10.0(@tanstack/react-query@5.90.21(react@19.2.7))(@trpc/client@11.10.0(@trpc/server@11.10.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.10.0(typescript@7.0.2))(react@19.2.7)(typescript@7.0.2))(@trpc/server@11.10.0(typescript@7.0.2))(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)': + '@trpc/next@11.10.0(@tanstack/react-query@5.90.21(react@19.2.7))(@trpc/client@11.10.0(@trpc/server@11.10.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.10.0(@tanstack/react-query@5.90.21(react@19.2.7))(@trpc/client@11.10.0(@trpc/server@11.10.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.10.0(typescript@7.0.2))(react@19.2.7)(typescript@7.0.2))(@trpc/server@11.10.0(typescript@7.0.2))(next@16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)': dependencies: '@trpc/client': 11.10.0(@trpc/server@11.10.0(typescript@7.0.2))(typescript@7.0.2) '@trpc/server': 11.10.0(typescript@7.0.2) - next: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) typescript: 7.0.2 @@ -14020,7 +14020,7 @@ snapshots: before-after-hook@2.2.3: {} - better-auth@1.6.23(165e0d14ffc9aaf8b4cab70e414c9093): + better-auth@1.6.23(1f696af711ca01112337d810adfad25c): dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1) '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.29.3)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2))) @@ -14046,43 +14046,7 @@ snapshots: drizzle-orm: 0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.29.3)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)) mongodb: 7.1.0(socks@2.8.8) mysql2: 3.15.3 - next: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - pg: 8.18.0 - prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.8.1)) - transitivePeerDependencies: - - '@cloudflare/workers-types' - - '@opentelemetry/api' - - better-auth@1.6.23(e687b32eb00a1602e570e2163c94e965): - dependencies: - '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1) - '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.29.3)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2))) - '@better-auth/kysely-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(kysely@0.29.3) - '@better-auth/memory-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(mongodb@7.1.0(socks@2.8.8)) - '@better-auth/prisma-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)) - '@better-auth/telemetry': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@noble/ciphers': 2.1.1 - '@noble/hashes': 2.0.1 - better-call: 1.3.7(zod@4.3.6) - defu: 6.1.7 - jose: 6.1.3 - kysely: 0.29.3 - nanostores: 1.1.1 - zod: 4.3.6 - optionalDependencies: - '@prisma/client': 5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)) - better-sqlite3: 12.6.2 - drizzle-kit: 0.31.9 - drizzle-orm: 0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.29.3)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)) - mongodb: 7.1.0(socks@2.8.8) - mysql2: 3.15.3 - next: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) pg: 8.18.0 prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2) react: 19.2.7 @@ -14092,6 +14056,42 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' + better-auth@1.6.23(9540743d27d6a8e6f2915ad16dc18aab): + dependencies: + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1) + '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.29.3)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2))) + '@better-auth/kysely-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(kysely@0.29.3) + '@better-auth/memory-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(mongodb@7.1.0(socks@2.8.8)) + '@better-auth/prisma-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)) + '@better-auth/telemetry': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.1.1 + '@noble/hashes': 2.0.1 + better-call: 1.3.7(zod@4.3.6) + defu: 6.1.7 + jose: 6.1.3 + kysely: 0.29.3 + nanostores: 1.1.1 + zod: 4.3.6 + optionalDependencies: + '@prisma/client': 5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)) + better-sqlite3: 12.6.2 + drizzle-kit: 0.31.9 + drizzle-orm: 0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.29.3)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)) + mongodb: 7.1.0(socks@2.8.8) + mysql2: 3.15.3 + next: 16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + pg: 8.18.0 + prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(vite@7.3.1(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.8.1)) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + better-call@1.3.7(zod@4.3.6): dependencies: '@better-auth/utils': 0.4.2 @@ -15501,7 +15501,7 @@ snapshots: inline-style-parser@0.2.7: {} - inngest@3.40.1(encoding@0.1.13)(express@5.2.1)(h3@1.15.1)(hono@4.12.2)(next@16.3.0(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2): + inngest@3.40.1(encoding@0.1.13)(express@5.2.1)(h3@1.15.1)(hono@4.12.2)(next@16.3.4(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2): dependencies: '@bufbuild/protobuf': 2.11.0 '@inngest/ai': 0.1.7 @@ -15529,7 +15529,7 @@ snapshots: express: 5.2.1 h3: 1.15.1 hono: 4.12.2 - next: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) typescript: 7.0.2 transitivePeerDependencies: - encoding @@ -16331,16 +16331,16 @@ snapshots: neotraverse@0.6.18: {} - next-themes@0.2.1(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next-themes@0.2.1(next@16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - next: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@next/env': 16.3.0 - '@swc/helpers': 0.5.15 + '@next/env': 16.3.4 + '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001774 postcss: 8.5.23 @@ -16348,24 +16348,24 @@ snapshots: react-dom: 19.2.7(react@19.2.7) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.7) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.0 - '@next/swc-darwin-x64': 16.3.0 - '@next/swc-linux-arm64-gnu': 16.3.0 - '@next/swc-linux-arm64-musl': 16.3.0 - '@next/swc-linux-x64-gnu': 16.3.0 - '@next/swc-linux-x64-musl': 16.3.0 - '@next/swc-win32-arm64-msvc': 16.3.0 - '@next/swc-win32-x64-msvc': 16.3.0 + '@next/swc-darwin-arm64': 16.3.4 + '@next/swc-darwin-x64': 16.3.4 + '@next/swc-linux-arm64-gnu': 16.3.4 + '@next/swc-linux-arm64-musl': 16.3.4 + '@next/swc-linux-x64-gnu': 16.3.4 + '@next/swc-linux-x64-musl': 16.3.4 + '@next/swc-win32-arm64-msvc': 16.3.4 + '@next/swc-win32-x64-msvc': 16.3.4 '@opentelemetry/api': 1.9.0 - sharp: 0.35.3(@types/node@24.10.13) + sharp: 0.35.4(@types/node@24.10.13) transitivePeerDependencies: - '@babel/core' - '@types/node' - babel-plugin-macros - nextjs-toploader@3.9.17(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + nextjs-toploader@3.9.17(next@16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - next: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.3.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) nprogress: 0.2.0 prop-types: 15.8.1 react: 19.2.7 @@ -17576,37 +17576,37 @@ snapshots: - supports-color - typescript - sharp@0.35.3(@types/node@24.10.13): + sharp@0.35.4(@types/node@24.10.13): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.3 - '@img/sharp-darwin-x64': 0.35.3 - '@img/sharp-freebsd-wasm32': 0.35.3 - '@img/sharp-libvips-darwin-arm64': 1.3.2 - '@img/sharp-libvips-darwin-x64': 1.3.2 - '@img/sharp-libvips-linux-arm': 1.3.2 - '@img/sharp-libvips-linux-arm64': 1.3.2 - '@img/sharp-libvips-linux-ppc64': 1.3.2 - '@img/sharp-libvips-linux-riscv64': 1.3.2 - '@img/sharp-libvips-linux-s390x': 1.3.2 - '@img/sharp-libvips-linux-x64': 1.3.2 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 - '@img/sharp-linux-arm': 0.35.3 - '@img/sharp-linux-arm64': 0.35.3 - '@img/sharp-linux-ppc64': 0.35.3 - '@img/sharp-linux-riscv64': 0.35.3 - '@img/sharp-linux-s390x': 0.35.3 - '@img/sharp-linux-x64': 0.35.3 - '@img/sharp-linuxmusl-arm64': 0.35.3 - '@img/sharp-linuxmusl-x64': 0.35.3 - '@img/sharp-webcontainers-wasm32': 0.35.3 - '@img/sharp-win32-arm64': 0.35.3 - '@img/sharp-win32-ia32': 0.35.3 - '@img/sharp-win32-x64': 0.35.3 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 '@types/node': 24.10.13 optional: true From 29cdc815db49c52125b4a9f026cbc3e829485fbb Mon Sep 17 00:00:00 2001 From: EgerDev <285544328+EgerDev@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:28:28 -0700 Subject: [PATCH 03/31] feat(compose): support AI models in Compose specification --- .../__test__/compose/model/model.test.ts | 164 ++++++++++++++++++ .../dokploy/components/shared/code-editor.tsx | 6 + .../components/shared/compose-spec.json | 51 ++++++ packages/server/src/utils/docker/types.ts | 36 ++++ 4 files changed, 257 insertions(+) create mode 100644 apps/dokploy/__test__/compose/model/model.test.ts diff --git a/apps/dokploy/__test__/compose/model/model.test.ts b/apps/dokploy/__test__/compose/model/model.test.ts new file mode 100644 index 000000000..e8fe6f3eb --- /dev/null +++ b/apps/dokploy/__test__/compose/model/model.test.ts @@ -0,0 +1,164 @@ +import type { Compose, ComposeSpecification } from "@dokploy/server"; +import { + addAppNameToPreventCollision, + addSuffixToAllProperties, +} from "@dokploy/server"; +import { addDomainToCompose } from "@dokploy/server/utils/docker/domain"; +import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { expect, test, vi } from "vitest"; +import { parse, stringify } from "yaml"; + +vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/utils/process/execAsync") + >()), + execAsyncRemote: vi.fn(), +})); + +const modelsComposeFile = ` +services: + chat: + image: my-chat-app + models: + - llm + worker: + image: my-worker + models: + embed: + endpoint_var: EMBED_URL + model_var: EMBED_MODEL +models: + llm: + model: ai/smollm2 + context_size: 2048 + runtime_flags: + - "--verbose" + embed: + model: ai/all-minilm + name: embeddings +`; + +const expectedModels = { + llm: { + model: "ai/smollm2", + context_size: 2048, + runtime_flags: ["--verbose"], + }, + embed: { + model: "ai/all-minilm", + name: "embeddings", + }, +}; + +const expectedChatModels = ["llm"]; +const expectedWorkerModels = { + embed: { + endpoint_var: "EMBED_URL", + model_var: "EMBED_MODEL", + }, +}; + +const assertModelsPreserved = ( + spec: ComposeSpecification | null, + serviceNames: { chat: string; worker: string } = { + chat: "chat", + worker: "worker", + }, +) => { + expect(spec?.models).toEqual(expectedModels); + expect(spec?.services?.[serviceNames.chat]?.models).toEqual( + expectedChatModels, + ); + expect(spec?.services?.[serviceNames.worker]?.models).toEqual( + expectedWorkerModels, + ); +}; + +const rawCompose = (overrides?: Record) => + ({ + appName: "chat-app", + composeFile: modelsComposeFile, + composePath: "./docker-compose.yml", + composeType: "docker-compose", + isolatedDeployment: false, + isolatedDeploymentsVolume: false, + randomize: false, + serverId: null, + sourceType: "raw", + suffix: "", + ...overrides, + }) as unknown as Compose; + +test("compose without models is unchanged besides existing suffix behavior", () => { + const composeData = parse(` +services: + web: + image: nginx:latest + volumes: + - web_data:/data +volumes: + web_data: +`) as ComposeSpecification; + const updated = addSuffixToAllProperties(composeData, "testhash"); + + expect(updated.models).toBeUndefined(); + expect(updated.services).toEqual({ + "web-testhash": { + image: "nginx:latest", + volumes: ["web_data-testhash:/data"], + }, + }); + expect(updated.volumes).toEqual({ + "web_data-testhash": null, + }); +}); + +test("suffixing does not rename model identifiers or drop model config", () => { + const updated = addSuffixToAllProperties( + parse(modelsComposeFile) as ComposeSpecification, + "testhash", + ); + + assertModelsPreserved(updated, { + chat: "chat-testhash", + worker: "worker-testhash", + }); + expect(updated.services).not.toHaveProperty("chat"); + expect(updated.models).not.toHaveProperty("llm-testhash"); +}); + +test("isolated deployment preserves model identifiers", () => { + assertModelsPreserved( + addAppNameToPreventCollision( + parse(modelsComposeFile) as ComposeSpecification, + "chat-app", + false, + ), + ); +}); + +test("raw remote compose conversion preserves models", async () => { + vi.mocked(execAsyncRemote).mockResolvedValue({ + stdout: "services:\n dropped:\n image: alpine:latest\n", + stderr: "", + }); + + const converted = await addDomainToCompose( + rawCompose({ serverId: "remote-server" }), + [], + ); + + assertModelsPreserved(converted); + expect(execAsyncRemote).not.toHaveBeenCalled(); + + const written = parse( + stringify(converted, { lineWidth: 1000 }), + ) as ComposeSpecification; + assertModelsPreserved(written); +}); + +test("invalid YAML still fails at parse", async () => { + await expect( + addDomainToCompose(rawCompose({ composeFile: "services: [" }), []), + ).rejects.toThrow(); +}); diff --git a/apps/dokploy/components/shared/code-editor.tsx b/apps/dokploy/components/shared/code-editor.tsx index 9640e3428..47b52b2ec 100644 --- a/apps/dokploy/components/shared/code-editor.tsx +++ b/apps/dokploy/components/shared/code-editor.tsx @@ -30,6 +30,7 @@ const dockerComposeServices = [ { label: "networks", type: "keyword", info: "Define networks" }, { label: "configs", type: "keyword", info: "Define configuration files" }, { label: "secrets", type: "keyword", info: "Define secrets" }, + { label: "models", type: "keyword", info: "Define AI models" }, ].map((opt) => ({ ...opt, apply: ( @@ -83,6 +84,11 @@ const dockerComposeServiceOptions = [ }, { label: "restart", type: "keyword", info: "Restart policy" }, { label: "networks", type: "keyword", info: "Networks to join" }, + { + label: "models", + type: "keyword", + info: "AI models to use, referencing top-level models", + }, ].map((opt) => ({ ...opt, apply: ( diff --git a/apps/dokploy/components/shared/compose-spec.json b/apps/dokploy/components/shared/compose-spec.json index 92036daa5..e5bd752a9 100644 --- a/apps/dokploy/components/shared/compose-spec.json +++ b/apps/dokploy/components/shared/compose-spec.json @@ -35,6 +35,16 @@ "additionalProperties": false }, + "models": { + "id": "#/properties/models", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/model" + } + } + }, + "networks": { "id": "#/properties/networks", "type": "object", @@ -335,6 +345,30 @@ "mem_swappiness": { "type": ["integer", "string"] }, "memswap_limit": { "type": ["number", "string"] }, "network_mode": { "type": "string" }, + "models": { + "oneOf": [ + { "$ref": "#/definitions/list_of_strings" }, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "endpoint_var": { "type": "string" }, + "model_var": { "type": "string" } + }, + "additionalProperties": false, + "patternProperties": { "^x-": {} } + }, + { "type": "null" } + ] + } + } + } + ] + }, "networks": { "oneOf": [ { "$ref": "#/definitions/list_of_strings" }, @@ -890,6 +924,23 @@ "patternProperties": { "^x-": {} } }, + "model": { + "id": "#/definitions/model", + "type": "object", + "properties": { + "name": { "type": "string" }, + "model": { "type": "string" }, + "context_size": { "type": "integer" }, + "runtime_flags": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["model"], + "additionalProperties": false, + "patternProperties": { "^x-": {} } + }, + "command": { "oneOf": [ { "type": "null" }, diff --git a/packages/server/src/utils/docker/types.ts b/packages/server/src/utils/docker/types.ts index 8f93f9cf5..03eb8bbff 100644 --- a/packages/server/src/utils/docker/types.ts +++ b/packages/server/src/utils/docker/types.ts @@ -497,6 +497,7 @@ export interface ComposeSpecification { */ include?: DefinitionsInclude[]; services?: PropertiesServices; + models?: PropertiesModels; networks?: PropertiesNetworks; volumes?: PropertiesVolumes; secrets?: PropertiesSecrets; @@ -648,6 +649,23 @@ export interface DefinitionsService { mem_swappiness?: number; memswap_limit?: number | string; network_mode?: string; + models?: + | ListOfStrings + | { + /** + * This interface was referenced by `undefined`'s JSON-Schema definition + * via the `patternProperty` "^[a-zA-Z0-9._-]+$". + */ + [k: string]: { + endpoint_var?: string; + model_var?: string; + /** + * This interface was referenced by `undefined`'s JSON-Schema definition + * via the `patternProperty` "^x-". + */ + [k: string]: unknown; + } | null; + }; networks?: | ListOfStrings | { @@ -877,3 +895,21 @@ export interface DefinitionsConfig { */ [k: string]: unknown; } +export interface PropertiesModels { + [k: string]: DefinitionsModel; +} +/** + * This interface was referenced by `PropertiesModels`'s JSON-Schema definition + * via the `patternProperty` "^[a-zA-Z0-9._-]+$". + */ +export interface DefinitionsModel { + name?: string; + model: string; + context_size?: number; + runtime_flags?: string[]; + /** + * This interface was referenced by `DefinitionsModel`'s JSON-Schema definition + * via the `patternProperty` "^x-". + */ + [k: string]: unknown; +} From 45dcdfc5be28317cc5e954f56911953acf65e5e7 Mon Sep 17 00:00:00 2001 From: EgerDev <285544328+EgerDev@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:11:40 -0700 Subject: [PATCH 04/31] test(compose): tighten models types and regression tests --- .../__test__/compose/model/model.test.ts | 199 +++++++----------- packages/server/src/utils/docker/types.ts | 20 +- 2 files changed, 76 insertions(+), 143 deletions(-) diff --git a/apps/dokploy/__test__/compose/model/model.test.ts b/apps/dokploy/__test__/compose/model/model.test.ts index e8fe6f3eb..d808dcc00 100644 --- a/apps/dokploy/__test__/compose/model/model.test.ts +++ b/apps/dokploy/__test__/compose/model/model.test.ts @@ -6,7 +6,8 @@ import { import { addDomainToCompose } from "@dokploy/server/utils/docker/domain"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { expect, test, vi } from "vitest"; -import { parse, stringify } from "yaml"; +import { stringify } from "yaml"; +import composeSpec from "../../../components/shared/compose-spec.json"; vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({ ...(await importOriginal< @@ -15,150 +16,98 @@ vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({ execAsyncRemote: vi.fn(), })); -const modelsComposeFile = ` -services: - chat: - image: my-chat-app - models: - - llm - worker: - image: my-worker - models: - embed: - endpoint_var: EMBED_URL - model_var: EMBED_MODEL -models: - llm: - model: ai/smollm2 - context_size: 2048 - runtime_flags: - - "--verbose" - embed: - model: ai/all-minilm - name: embeddings -`; - -const expectedModels = { - llm: { - model: "ai/smollm2", - context_size: 2048, - runtime_flags: ["--verbose"], - }, - embed: { - model: "ai/all-minilm", - name: "embeddings", - }, -}; - -const expectedChatModels = ["llm"]; -const expectedWorkerModels = { - embed: { - endpoint_var: "EMBED_URL", - model_var: "EMBED_MODEL", - }, -}; - -const assertModelsPreserved = ( - spec: ComposeSpecification | null, - serviceNames: { chat: string; worker: string } = { - chat: "chat", - worker: "worker", - }, -) => { - expect(spec?.models).toEqual(expectedModels); - expect(spec?.services?.[serviceNames.chat]?.models).toEqual( - expectedChatModels, - ); - expect(spec?.services?.[serviceNames.worker]?.models).toEqual( - expectedWorkerModels, - ); -}; - -const rawCompose = (overrides?: Record) => - ({ - appName: "chat-app", - composeFile: modelsComposeFile, - composePath: "./docker-compose.yml", - composeType: "docker-compose", - isolatedDeployment: false, - isolatedDeploymentsVolume: false, - randomize: false, - serverId: null, - sourceType: "raw", - suffix: "", - ...overrides, - }) as unknown as Compose; - -test("compose without models is unchanged besides existing suffix behavior", () => { - const composeData = parse(` -services: - web: - image: nginx:latest - volumes: - - web_data:/data -volumes: - web_data: -`) as ComposeSpecification; - const updated = addSuffixToAllProperties(composeData, "testhash"); - - expect(updated.models).toBeUndefined(); - expect(updated.services).toEqual({ - "web-testhash": { - image: "nginx:latest", - volumes: ["web_data-testhash:/data"], +const modelCompose = { + services: { + app: { + image: "my-chat-app", + models: ["llm"], }, - }); - expect(updated.volumes).toEqual({ - "web_data-testhash": null, - }); + worker: { + image: "my-worker", + models: { + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }, + }, + }, + models: { + llm: { + model: "ai/smollm2", + context_size: 2048, + runtime_flags: ["--verbose"], + }, + }, +} satisfies ComposeSpecification; + +test("bundled compose schema declares models", () => { + expect(composeSpec.properties).toHaveProperty("models"); + expect(composeSpec.definitions.model.required).toEqual(["model"]); + expect(composeSpec.definitions.service.properties).toHaveProperty("models"); }); test("suffixing does not rename model identifiers or drop model config", () => { const updated = addSuffixToAllProperties( - parse(modelsComposeFile) as ComposeSpecification, + structuredClone(modelCompose), "testhash", ); - assertModelsPreserved(updated, { - chat: "chat-testhash", - worker: "worker-testhash", + expect(updated.models?.llm?.model).toBe("ai/smollm2"); + expect(updated.models?.llm?.context_size).toBe(2048); + expect(updated.models?.llm?.runtime_flags).toEqual(["--verbose"]); + expect(updated.services?.["app-testhash"]?.models).toEqual(["llm"]); + expect(updated.services?.["worker-testhash"]?.models).toEqual({ + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, }); - expect(updated.services).not.toHaveProperty("chat"); + expect(updated.services).not.toHaveProperty("app"); expect(updated.models).not.toHaveProperty("llm-testhash"); }); test("isolated deployment preserves model identifiers", () => { - assertModelsPreserved( - addAppNameToPreventCollision( - parse(modelsComposeFile) as ComposeSpecification, - "chat-app", - false, - ), + const updated = addAppNameToPreventCollision( + structuredClone(modelCompose), + "chat-app", + false, ); + + expect(updated.models?.llm?.model).toBe("ai/smollm2"); + expect(updated.services?.app?.models).toEqual(["llm"]); + expect(updated.services?.worker?.models).toEqual({ + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }); }); test("raw remote compose conversion preserves models", async () => { - vi.mocked(execAsyncRemote).mockResolvedValue({ - stdout: "services:\n dropped:\n image: alpine:latest\n", - stderr: "", - }); - const converted = await addDomainToCompose( - rawCompose({ serverId: "remote-server" }), + { + appName: "chat-app", + composeFile: stringify(modelCompose, { lineWidth: 1000 }), + composePath: "./docker-compose.yml", + composeType: "docker-compose", + isolatedDeployment: false, + isolatedDeploymentsVolume: false, + randomize: false, + serverId: "remote-server", + sourceType: "raw", + suffix: "", + } as unknown as Compose, [], ); - assertModelsPreserved(converted); expect(execAsyncRemote).not.toHaveBeenCalled(); - - const written = parse( - stringify(converted, { lineWidth: 1000 }), - ) as ComposeSpecification; - assertModelsPreserved(written); -}); - -test("invalid YAML still fails at parse", async () => { - await expect( - addDomainToCompose(rawCompose({ composeFile: "services: [" }), []), - ).rejects.toThrow(); + expect(converted?.models?.llm?.model).toBe("ai/smollm2"); + expect(converted?.services?.app?.models).toEqual(["llm"]); + expect(converted?.services?.worker?.models).toEqual({ + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }); }); diff --git a/packages/server/src/utils/docker/types.ts b/packages/server/src/utils/docker/types.ts index 03eb8bbff..9abd8a108 100644 --- a/packages/server/src/utils/docker/types.ts +++ b/packages/server/src/utils/docker/types.ts @@ -652,18 +652,10 @@ export interface DefinitionsService { models?: | ListOfStrings | { - /** - * This interface was referenced by `undefined`'s JSON-Schema definition - * via the `patternProperty` "^[a-zA-Z0-9._-]+$". - */ [k: string]: { endpoint_var?: string; model_var?: string; - /** - * This interface was referenced by `undefined`'s JSON-Schema definition - * via the `patternProperty` "^x-". - */ - [k: string]: unknown; + [key: `x-${string}`]: unknown; } | null; }; networks?: @@ -898,18 +890,10 @@ export interface DefinitionsConfig { export interface PropertiesModels { [k: string]: DefinitionsModel; } -/** - * This interface was referenced by `PropertiesModels`'s JSON-Schema definition - * via the `patternProperty` "^[a-zA-Z0-9._-]+$". - */ export interface DefinitionsModel { name?: string; model: string; context_size?: number; runtime_flags?: string[]; - /** - * This interface was referenced by `DefinitionsModel`'s JSON-Schema definition - * via the `patternProperty` "^x-". - */ - [k: string]: unknown; + [key: `x-${string}`]: unknown; } From 26317e9126d20eca1aed1b0d095fbacc5b1c1075 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Sat, 12 Sep 2026 15:50:08 +0530 Subject: [PATCH 05/31] feat(ui): inline deployments filter bar and fix layout jumping --- .../deployments/show-deployments-table.tsx | 295 ++++++++---------- .../overview/show-overview-deployments.tsx | 62 +++- 2 files changed, 188 insertions(+), 169 deletions(-) diff --git a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx index e479d9275..ad860745a 100644 --- a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx +++ b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx @@ -1,10 +1,8 @@ "use client"; import { - type ColumnFiltersState, flexRender, getCoreRowModel, - getFilteredRowModel, getPaginationRowModel, getSortedRowModel, type PaginationState, @@ -27,7 +25,6 @@ import Link from "next/link"; import { useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -97,14 +94,21 @@ function getServiceInfo(d: DeploymentRow) { return null; } -export function ShowDeploymentsTable() { +interface ShowDeploymentsTableProps { + globalFilter: string; + statusFilter: string; + typeFilter: string; +} + +export function ShowDeploymentsTable({ + globalFilter, + statusFilter, + typeFilter, +}: ShowDeploymentsTableProps) { const [sorting, setSorting] = useState([ { id: "createdAt", desc: true }, ]); - const [columnFilters, setColumnFilters] = useState([]); - const [globalFilter, setGlobalFilter] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [typeFilter, setTypeFilter] = useState("all"); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50, @@ -446,177 +450,140 @@ export function ShowDeploymentsTable() { columns, state: { sorting, - columnFilters, - globalFilter, pagination, }, onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - onGlobalFilterChange: setGlobalFilter, onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), }); return ( -
-
- setGlobalFilter(e.target.value)} - className="max-w-xs" - /> - - -
-
- {isLoading ? ( -
- - Loading deployments... -
- ) : ( - <> -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - +
+ {isLoading ? ( +
+ + Loading deployments... +
+ ) : ( + <> +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + ))} + )) + ) : ( + + +
+ +

No deployments found

+

+ Deployments from applications and compose will appear + here. +

+
+
+
+ )} +
+
+
+
+
+ + Rows per page + + + + Showing{" "} + {filteredData.length === 0 + ? 0 + : pagination.pageIndex * pagination.pageSize + 1}{" "} + to{" "} + {Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + filteredData.length, + )}{" "} + of {filteredData.length} entries +
-
-
- - Rows per page - - - - Showing{" "} - {filteredData.length === 0 - ? 0 - : pagination.pageIndex * pagination.pageSize + 1}{" "} - to{" "} - {Math.min( - (pagination.pageIndex + 1) * pagination.pageSize, - filteredData.length, - )}{" "} - of {filteredData.length} entries - -
-
- - -
+
+ +
- - )} -
+
+ + )}
); } diff --git a/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx index be12da353..28b433fa6 100644 --- a/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx +++ b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx @@ -1,5 +1,6 @@ import { Rocket } from "lucide-react"; import { useRouter } from "next/router"; +import { useState } from "react"; import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table"; import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table"; import { @@ -8,6 +9,14 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; const SUBTAB_VALUES = ["deployments", "queue"] as const; @@ -26,6 +35,10 @@ export const ShowOverviewDeployments = () => { ? router.query.subtab : DEFAULT_SUBTAB; + const [globalFilter, setGlobalFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const setSubtab = (value: string) => { if (!isValidSubtab(value)) return; const { subtab: _current, ...query } = router.query; @@ -59,12 +72,51 @@ export const ShowOverviewDeployments = () => { onValueChange={setSubtab} className="w-full min-w-0" > - - Deployments - Queue - + {/* Responsive layout: Tabs on top and filters wrap below on mobile, inline single-row on md+ */} +
+ + Deployments + Queue + + {subtab === "deployments" && ( +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + + +
+ )} +
- + From 1e9873b3e4fcb6a3dbf2231f17cf3dac796f8170 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Sat, 12 Sep 2026 15:59:18 +0530 Subject: [PATCH 06/31] fix(ui): change filter layout breakpoint to lg to prevent overflow on tablets --- .../dashboard/overview/show-overview-deployments.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx index 28b433fa6..7075f6bc6 100644 --- a/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx +++ b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx @@ -72,14 +72,14 @@ export const ShowOverviewDeployments = () => { onValueChange={setSubtab} className="w-full min-w-0" > - {/* Responsive layout: Tabs on top and filters wrap below on mobile, inline single-row on md+ */} -
+ {/* Responsive layout: Tabs on top and filters wrap below on mobile/tablet, inline single-row on lg+ */} +
Deployments Queue {subtab === "deployments" && ( -
+
Date: Sun, 13 Sep 2026 22:22:36 +0530 Subject: [PATCH 07/31] fix: preserve application status when saving provider settings --- .../application/provider-status.test.ts | 73 +++++++++++++++++++ .../dokploy/server/api/routers/application.ts | 7 -- 2 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 apps/dokploy/__test__/application/provider-status.test.ts diff --git a/apps/dokploy/__test__/application/provider-status.test.ts b/apps/dokploy/__test__/application/provider-status.test.ts new file mode 100644 index 000000000..c4190a1d6 --- /dev/null +++ b/apps/dokploy/__test__/application/provider-status.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockUpdateApplication = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/services/permission", () => { + return { + checkServicePermissionAndAccess: vi.fn(async () => undefined), + }; +}); + +vi.mock("@dokploy/server/index", () => ({ + IS_CLOUD: true, + hasValidLicense: vi.fn(async () => false), + updateApplication: mockUpdateApplication, + findApplicationById: vi.fn(async () => ({ + applicationId: "app-1", + appName: "example-app", + })), +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +const { applicationRouter } = await import("@/server/api/routers/application"); + +const caller = applicationRouter.createCaller({ + session: { activeOrganizationId: "org-1" }, + user: { id: "user-1", email: "user@example.com", role: "owner" }, +} as Parameters[0]); + +describe("saving an application provider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(["done", "running"])( + "keeps the %s application status when GitHub settings change", + async (status) => { + let savedStatus = status; + mockUpdateApplication.mockImplementation(async (_id, update) => { + if (update.applicationStatus !== undefined) { + savedStatus = update.applicationStatus; + } + }); + + await caller.saveGithubProvider({ + applicationId: "app-1", + repository: "example-repo", + owner: "example-owner", + branch: "main", + buildPath: "/", + githubId: "provider-1", + watchPaths: ["src/**"], + triggerType: "push", + enableSubmodules: false, + }); + + expect(savedStatus).toBe(status); + expect(mockUpdateApplication).toHaveBeenCalledWith( + "app-1", + expect.objectContaining({ + watchPaths: ["src/**"], + sourceType: "github", + }), + ); + }, + ); +}); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 01a9534d9..79f029360 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -545,7 +545,6 @@ export const applicationRouter = createTRPCRouter({ sourceType: "github", owner: input.owner, buildPath: input.buildPath, - applicationStatus: "idle", githubId: input.githubId, watchPaths: input.watchPaths, triggerType: input.triggerType, @@ -572,7 +571,6 @@ export const applicationRouter = createTRPCRouter({ gitlabBranch: input.gitlabBranch, gitlabBuildPath: input.gitlabBuildPath, sourceType: "gitlab", - applicationStatus: "idle", gitlabId: input.gitlabId, gitlabProjectId: input.gitlabProjectId, gitlabPathNamespace: input.gitlabPathNamespace, @@ -601,7 +599,6 @@ export const applicationRouter = createTRPCRouter({ bitbucketBranch: input.bitbucketBranch, bitbucketBuildPath: input.bitbucketBuildPath, sourceType: "bitbucket", - applicationStatus: "idle", bitbucketId: input.bitbucketId, watchPaths: input.watchPaths, enableSubmodules: input.enableSubmodules, @@ -627,7 +624,6 @@ export const applicationRouter = createTRPCRouter({ giteaBranch: input.giteaBranch, giteaBuildPath: input.giteaBuildPath, sourceType: "gitea", - applicationStatus: "idle", giteaId: input.giteaId, watchPaths: input.watchPaths, enableSubmodules: input.enableSubmodules, @@ -652,7 +648,6 @@ export const applicationRouter = createTRPCRouter({ username: input.username, password: input.password, sourceType: "docker", - applicationStatus: "idle", registryUrl: input.registryUrl, }); const application = await findApplicationById(input.applicationId); @@ -676,7 +671,6 @@ export const applicationRouter = createTRPCRouter({ customGitUrl: input.customGitUrl, customGitSSHKeyId: input.customGitSSHKeyId, sourceType: "git", - applicationStatus: "idle", watchPaths: input.watchPaths, enableSubmodules: input.enableSubmodules, }); @@ -729,7 +723,6 @@ export const applicationRouter = createTRPCRouter({ customGitSSHKeyId: null, sourceType: "github", // Reset to default - applicationStatus: "idle", watchPaths: null, enableSubmodules: false, }); From e120340e77ed180ae58b6ecf1c6dada5e21021f1 Mon Sep 17 00:00:00 2001 From: Roshan Singh Date: Sun, 13 Sep 2026 22:28:06 +0530 Subject: [PATCH 08/31] test: cover status preservation for every provider save --- .../application/provider-status.test.ts | 133 ++++++++++++++---- 1 file changed, 108 insertions(+), 25 deletions(-) diff --git a/apps/dokploy/__test__/application/provider-status.test.ts b/apps/dokploy/__test__/application/provider-status.test.ts index c4190a1d6..427b6e6dd 100644 --- a/apps/dokploy/__test__/application/provider-status.test.ts +++ b/apps/dokploy/__test__/application/provider-status.test.ts @@ -33,22 +33,12 @@ const caller = applicationRouter.createCaller({ user: { id: "user-1", email: "user@example.com", role: "owner" }, } as Parameters[0]); -describe("saving an application provider", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it.each(["done", "running"])( - "keeps the %s application status when GitHub settings change", - async (status) => { - let savedStatus = status; - mockUpdateApplication.mockImplementation(async (_id, update) => { - if (update.applicationStatus !== undefined) { - savedStatus = update.applicationStatus; - } - }); - - await caller.saveGithubProvider({ +const providerChanges = [ + { + name: "GitHub", + sourceType: "github", + save: () => + caller.saveGithubProvider({ applicationId: "app-1", repository: "example-repo", owner: "example-owner", @@ -58,16 +48,109 @@ describe("saving an application provider", () => { watchPaths: ["src/**"], triggerType: "push", enableSubmodules: false, - }); + }), + }, + { + name: "GitLab", + sourceType: "gitlab", + save: () => + caller.saveGitlabProvider({ + applicationId: "app-1", + gitlabRepository: "example-repo", + gitlabOwner: "example-owner", + gitlabBranch: "main", + gitlabBuildPath: "/", + gitlabId: "provider-1", + gitlabProjectId: 1, + gitlabPathNamespace: "example-owner/example-repo", + }), + }, + { + name: "Bitbucket", + sourceType: "bitbucket", + save: () => + caller.saveBitbucketProvider({ + applicationId: "app-1", + bitbucketRepository: "example-repo", + bitbucketRepositorySlug: "example-repo", + bitbucketOwner: "example-owner", + bitbucketBranch: "main", + bitbucketBuildPath: "/", + bitbucketId: "provider-1", + }), + }, + { + name: "Gitea", + sourceType: "gitea", + save: () => + caller.saveGiteaProvider({ + applicationId: "app-1", + giteaRepository: "example-repo", + giteaOwner: "example-owner", + giteaBranch: "main", + giteaBuildPath: "/", + giteaId: "provider-1", + }), + }, + { + name: "Docker", + sourceType: "docker", + save: () => + caller.saveDockerProvider({ + applicationId: "app-1", + dockerImage: "nginx:alpine", + username: "", + password: "", + registryUrl: "", + }), + }, + { + name: "custom Git", + sourceType: "git", + save: () => + caller.saveGitProvider({ + applicationId: "app-1", + customGitUrl: "https://example.com/repo.git", + customGitBranch: "main", + customGitBuildPath: "/", + customGitSSHKeyId: "key-1", + watchPaths: ["src/**"], + enableSubmodules: false, + }), + }, + { + name: "disconnect Git provider", + sourceType: "github", + save: () => caller.disconnectGitProvider({ applicationId: "app-1" }), + }, +]; - expect(savedStatus).toBe(status); - expect(mockUpdateApplication).toHaveBeenCalledWith( - "app-1", - expect.objectContaining({ - watchPaths: ["src/**"], - sourceType: "github", - }), - ); +describe("saving an application provider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(providerChanges)( + "keeps the status when $name settings change", + async ({ save, sourceType }) => { + for (const status of ["done", "running"]) { + mockUpdateApplication.mockClear(); + let savedStatus = status; + mockUpdateApplication.mockImplementation(async (_id, update) => { + if (update.applicationStatus !== undefined) { + savedStatus = update.applicationStatus; + } + }); + + await save(); + + expect(savedStatus).toBe(status); + expect(mockUpdateApplication).toHaveBeenCalledTimes(1); + expect(mockUpdateApplication).toHaveBeenCalledWith( + "app-1", + expect.objectContaining({ sourceType }), + ); + } }, ); }); From 95b2712c26ac5dd3fc0d94e16a8edbbe1a6a8f7e Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:35:27 -0600 Subject: [PATCH 09/31] fix(billing): pick a plan for the trial and warn before it expires Trials were always Hobby, so a trial user who wanted Startup had to go through Checkout, which created a second subscription on the same customer. Seven days later Stripe cancelled the trial sub and the webhook, matching only on customer id, zeroed serversQuantity and deactivated the servers of someone who was already paying. The trial now starts on the plan the user chooses and switching plans mutates that same subscription, so a customer never holds two. - startFreeTrial takes a tier; TRIAL_SERVER_LIMIT becomes TRIAL_SERVER_LIMITS (Hobby 1 server, Startup 3) - onboarding offers both plans with a trial and a subscribe button - billing exposes Switch to Startup/Hobby during the trial and hides the checkout CTAs and server steppers that would create a second subscription - upgradeSubscription caps the server count to the plan's included servers while trialing and skips proration, which emitted an orphan invoice - webhook treats trialing as active instead of disabling servers, and guards invoices that carry no subscription - getBillingStatus reports hasPaymentMethod, so the banners stop asking for a card once one is on file - new cloud-only daily job emails trial users without a payment method at 3 and 1 days left, deduped through subscription metadata claimed before the send so concurrent replicas cannot double-send --- .../dashboard/billing/trial-banner.tsx | 23 +- .../onboarding/onboarding-wizard.tsx | 5 +- .../dashboard/onboarding/steps/plan-step.tsx | 237 +++++----- .../onboarding/steps/project-step.tsx | 2 +- .../onboarding/steps/server-step.tsx | 24 +- .../onboarding/steps/welcome-step.tsx | 4 +- .../settings/billing/show-billing.tsx | 435 ++++++++++-------- apps/dokploy/pages/api/stripe/webhook.ts | 47 +- apps/dokploy/server/api/routers/stripe.ts | 201 +++++--- apps/dokploy/server/server.ts | 5 + apps/dokploy/server/utils/billing.ts | 20 +- .../server/utils/trial-notifications.ts | 157 +++++++ .../src/emails/emails/trial-expiring.tsx | 141 ++++++ 13 files changed, 874 insertions(+), 427 deletions(-) create mode 100644 apps/dokploy/server/utils/trial-notifications.ts create mode 100644 packages/server/src/emails/emails/trial-expiring.tsx diff --git a/apps/dokploy/components/dashboard/billing/trial-banner.tsx b/apps/dokploy/components/dashboard/billing/trial-banner.tsx index 0c5bfdffb..caf3634f1 100644 --- a/apps/dokploy/components/dashboard/billing/trial-banner.tsx +++ b/apps/dokploy/components/dashboard/billing/trial-banner.tsx @@ -6,28 +6,41 @@ import { api } from "@/utils/api"; export const TrialBanner = () => { const router = useRouter(); const { data: billingStatus } = api.stripe.getBillingStatus.useQuery(); + const { mutateAsync: createCustomerPortalSession, isPending } = + api.stripe.createCustomerPortalSession.useMutation(); - if (!billingStatus?.isOnTrial) { + const handleAddPaymentMethod = async () => { + const session = await createCustomerPortalSession().catch(() => null); + if (session?.url) { + window.open(session.url); + return; + } + router.push("/dashboard/settings/billing"); + }; + + if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) { return null; } const daysRemaining = billingStatus.trialDaysRemaining ?? 0; + const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`; return (
{daysRemaining > 0 - ? `You have ${daysRemaining} day${daysRemaining === 1 ? "" : "s"} left in your free trial.` - : "Your free trial ends today."} + ? `You have ${daysLabel} left in your free trial. Add a payment method to keep your servers.` + : "Your free trial ends today. Add a payment method to keep your servers."}
); diff --git a/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx index da29cae7c..95780bf63 100644 --- a/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx +++ b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx @@ -278,8 +278,9 @@ export const OnboardingWizard = ({ onClose }: Props) => { Skip onboarding? If this is your first time using Dokploy, we recommend going - through these steps — it only takes a couple of minutes and gives - you a feel for how projects, servers and deployments fit together. + through these steps – it only takes a couple of minutes and gives + you a feel for how projects, servers, and deployments fit + together. diff --git a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx index 9fe8a4947..9863aaf38 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx @@ -15,13 +15,50 @@ const stripePromise = loadStripe( process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, ); +type Tier = "hobby" | "startup"; + +const PLANS: { + tier: Tier; + name: string; + description: string; + price: number; + features: string[]; + recommended?: boolean; +}[] = [ + { + tier: "hobby", + name: "Hobby", + description: "For individual developers", + price: calculatePriceHobby(1, false), + features: [ + "Setup 1 server", + "Unlimited apps & databases", + "2 environments", + "Community support", + ], + }, + { + tier: "startup", + name: "Startup", + description: "For small to mid-size teams", + price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false), + recommended: true, + features: [ + `Setup up to ${STARTUP_SERVERS_INCLUDED} servers`, + "Unlimited users & environments", + "Basic RBAC + 2FA", + "Email & chat support", + ], + }, +]; + interface Props { onNext: () => void; } export const PlanStep = ({ onNext }: Props) => { - const [loadingTier, setLoadingTier] = useState< - "hobby" | "startup" | "trial" | null + const [loading, setLoading] = useState< + `${"trial" | "checkout"}:${Tier}` | null >(null); const { data } = api.stripe.getProducts.useQuery(); const { mutateAsync: createCheckoutSession } = @@ -30,12 +67,12 @@ export const PlanStep = ({ onNext }: Props) => { api.stripe.startFreeTrial.useMutation(); const utils = api.useUtils(); - const handleCheckout = async (tier: "hobby" | "startup") => { + const handleCheckout = async (tier: Tier) => { if (!data) return; const productId = tier === "hobby" ? data.hobbyProductId : data.startupProductId; if (!productId) return; - setLoadingTier(tier); + setLoading(`checkout:${tier}`); try { const stripe = await stripePromise; const session = await createCheckoutSession({ @@ -47,14 +84,14 @@ export const PlanStep = ({ onNext }: Props) => { await stripe?.redirectToCheckout({ sessionId: session.sessionId }); } catch { toast.error("Error starting checkout"); - setLoadingTier(null); + setLoading(null); } }; - const handleTrial = async () => { - setLoadingTier("trial"); + const handleTrial = async (tier: Tier) => { + setLoading(`trial:${tier}`); try { - await startFreeTrial(); + await startFreeTrial({ tier }); await utils.project.onboardingStatus.invalidate(); toast.success("Your 7-day trial has started"); onNext(); @@ -63,7 +100,7 @@ export const PlanStep = ({ onNext }: Props) => { error instanceof Error ? error.message : "Error starting trial", ); } finally { - setLoadingTier(null); + setLoading(null); } }; @@ -79,134 +116,72 @@ export const PlanStep = ({ onNext }: Props) => { Start free, upgrade when ready.

- No credit card for the trial — add one only if you decide to stay. + Try any plan free for 7 days. No credit card required — add one only + if you decide to stay.

-
-
-
- - Recommended - -

- 7-day free trial -

-

- No card required — cancel anytime. -

-
    - {[ - "Setup 1 server", - "Unlimited apps & databases", - "Community support", - ].map((f) => ( -
  • - - {f} -
  • - ))} -
-
- -
-
-
-
-
-

Hobby

-

- For individual developers -

-

- ${calculatePriceHobby(1, false).toFixed(2)} - - {" "} - /mo - -

-
    - {[ - "Setup 1 server", - "Unlimited apps & databases", - "2 environments", - "Community support", - ].map((f) => ( -
  • { + const productId = + plan.tier === "hobby" + ? data?.hobbyProductId + : data?.startupProductId; + return ( +
    +
    + {plan.recommended && ( + + Recommended + + )} +

    {plan.name}

    +

    + {plan.description} +

    +

    + ${plan.price.toFixed(2)} + + {" "} + /mo + +

    +
      + {plan.features.map((f) => ( +
    • + + {f} +
    • + ))} +
    +
    +
    +
  • - ))} -
-
- -
- -
-
-

Startup

-

- For small to mid-size teams -

-

- $ - {calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed( - 2, - )} - - {" "} - /mo - -

-
    - {[ - `Setup up to ${STARTUP_SERVERS_INCLUDED} servers`, - "Unlimited users & environments", - "Basic RBAC + 2FA", - "Email & chat support", - ].map((f) => ( -
  • + +
  • - ))} -
-
- -
+ Subscribe now + +
+
+ ); + })}
); diff --git a/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx index 24c37d9d9..ccc43b030 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx @@ -65,7 +65,7 @@ export const ProjectStep = ({ onNext, plainTitle }: Props) => {

Create your first project.

- Projects group your apps, databases and environments together. + Projects group your apps, databases, and environments together.

diff --git a/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx index 08efb12c3..7af9b6ee3 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx @@ -1,6 +1,12 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import copy from "copy-to-clipboard"; -import { CheckCircle2, CopyIcon, Loader2, XCircle } from "lucide-react"; +import { + CheckCircle2, + CopyIcon, + InfoIcon, + Loader2, + XCircle, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -200,9 +206,19 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {

Connect a server.

- Dokploy deploys to servers you own — buy one from any VPS provider + Dokploy deploys to servers you own. Buy one from any VPS provider (Hetzner, DigitalOcean, Hostinger...) and paste its IP below.

+
+ +
+ Requirements + + To ensure a smooth experience with Dokploy, your server should + have at least 1GB of RAM and 30GB of disk space. + +
+
{!createdServerId ? ( @@ -287,7 +303,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => { {isSettingUp ? (
- Setting up your server — installing Docker and dependencies... + Setting up your server – installing Docker and dependencies…
) : isValidating && !validation ? (
@@ -311,7 +327,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
{!isReady && !isSettingUp && ( - Setup usually takes a minute after the server boots — this checks + Setup runs after the server boots and this page checks for it automatically. You can retry setup, or use "Skip for now" above to continue and come back to this later. diff --git a/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx index 967075452..6f5ef9a1e 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx @@ -39,8 +39,8 @@ export const WelcomeStep = ({ onNext }: Props) => { Let's get your first app live.

- A few steps — pick a plan, connect a server, ship something. You'll - have a working URL by the end. + It just takes a few steps: pick a plan, connect a server, ship + something. You'll have a working URL by the end.

diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index a85b3242e..b66a23ca4 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -105,13 +105,47 @@ export const ShowBilling = () => { api.stripe.upgradeSubscription.useMutation(); const { mutateAsync: updateInvoiceNotifications } = api.stripe.updateInvoiceNotifications.useMutation(); - const { mutateAsync: startFreeTrial, isPending: isStartingTrial } = + const { mutateAsync: startFreeTrial } = api.stripe.startFreeTrial.useMutation(); const utils = api.useUtils(); + const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null); + const [switchingTrial, setSwitchingTrial] = useState(false); - const handleStartTrial = async () => { + const handleSwitchTrialPlan = async (tier: "hobby" | "startup") => { + const planName = tier === "startup" ? "Startup" : "Hobby"; + + const switchPlan = async () => { + await upgradeSubscription({ + tier, + serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1, + isAnnual: false, + }); + await new Promise((resolve) => setTimeout(resolve, 3000)); + await Promise.all([ + utils.stripe.getBillingStatus.invalidate(), + utils.stripe.getProducts.invalidate(), + utils.user.get.invalidate(), + utils.server.count.invalidate(), + ]); + }; + + setSwitchingTrial(true); + await toast + .promise(switchPlan(), { + loading: `Switching to ${planName}...`, + success: `Your trial is now on the ${planName} plan`, + error: (error) => + error instanceof Error ? error.message : "Error switching plan", + }) + .unwrap() + .catch(() => undefined); + setSwitchingTrial(false); + }; + + const handleStartTrial = async (tier: "hobby" | "startup") => { + setTrialTier(tier); try { - await startFreeTrial(); + await startFreeTrial({ tier }); await Promise.all([ utils.stripe.getBillingStatus.invalidate(), utils.stripe.getProducts.invalidate(), @@ -122,6 +156,8 @@ export const ShowBilling = () => { toast.error( error instanceof Error ? error.message : "Error starting trial", ); + } finally { + setTrialTier(null); } }; @@ -170,12 +206,23 @@ export const ShowBilling = () => { const useNewPricing = data?.hobbyProductId && data?.startupProductId; const products = data?.products.filter((product) => { - // @ts-ignore + // @ts-expect-error const interval = product?.default_price?.recurring?.interval; return isAnnual ? interval === "year" : interval === "month"; }); const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false; + const canStartTrial = + !isEnterpriseCloud && + !!billingStatus && + !billingStatus.plan && + !billingStatus.isOnTrial && + !billingStatus.hasUsedTrial; + /** Checkout would create a second subscription alongside a running trial. */ + const canSubscribe = + !isEnterpriseCloud && + (data?.subscriptions?.length ?? 0) === 0 && + !billingStatus?.isOnTrial; const maxServers = admin?.user.serversQuantity ?? 1; const percentage = ((servers ?? 0) / maxServers) * 100; const safePercentage = Math.min(percentage, 100); @@ -296,67 +343,72 @@ export const ShowBilling = () => { billingStatus.trialEndsAt ? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}` : "" + }${ + billingStatus.hasPaymentMethod + ? " · billing starts automatically" + : " · add a card to keep your servers" }` : "You're subscribed and billed automatically."} - {billingStatus.isOnTrial && - admin?.user.stripeCustomerId && ( + {admin?.user.stripeCustomerId && ( +
+ {billingStatus.isOnTrial && + useNewPricing && + billingStatus.plan && ( + + )} - )} -
- )} - {!isEnterpriseCloud && - billingStatus && - !billingStatus.plan && - !billingStatus.isOnTrial && - !billingStatus.hasUsedTrial && ( -
-
- -
- - 7-day free trial - - - No credit card required — cancel anytime. - -
    - {[ - "Setup 1 server", - "Unlimited apps & databases", - "Community support", - ].map((feature) => ( -
  • - - {feature} -
  • - ))} -
-
- + )}
)} + {canStartTrial && ( +
+ +
+ + 7-day free trial on any plan + + + No credit card required — pick Hobby or Startup below. + +
+
+ )} {(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (

Servers Plan

@@ -933,68 +985,71 @@ export const ShowBilling = () => { ))}
-
- - Servers: - - - - setHobbyServerQuantity( - Math.max( - 1, - Number( - (e.target as HTMLInputElement).value, - ) || 1, - ), - ) - } - className="text-center" - /> - -
-
- {admin?.user.stripeCustomerId && ( + {canSubscribe && ( +
+ + Servers: + + + setHobbyServerQuantity( + Math.max( + 1, + Number( + (e.target as HTMLInputElement).value, + ) || 1, + ), + ) + } + className="text-center" + /> + +
+ )} +
+ {canStartTrial && ( + + )} + {canSubscribe && ( + )} - {!isEnterpriseCloud && - (data?.subscriptions?.length ?? 0) === 0 && ( - - )}
@@ -1067,85 +1122,83 @@ export const ShowBilling = () => { ))}
-
- - Servers (min. {STARTUP_SERVERS_INCLUDED} included) - -
- - - setStartupServerQuantity( - Math.max( - STARTUP_SERVERS_INCLUDED, - Number( - (e.target as HTMLInputElement).value, - ) || STARTUP_SERVERS_INCLUDED, - ), - ) - } - className="h-8 text-center" - /> - -
-
-
- {admin?.user.stripeCustomerId && ( - - )} - {!isEnterpriseCloud && - (data?.subscriptions?.length ?? 0) === 0 && ( + {canSubscribe && ( +
+ + Servers (min. {STARTUP_SERVERS_INCLUDED} included) + +
- )} + + setStartupServerQuantity( + Math.max( + STARTUP_SERVERS_INCLUDED, + Number( + (e.target as HTMLInputElement).value, + ) || STARTUP_SERVERS_INCLUDED, + ), + ) + } + className="h-8 text-center" + /> + +
+
+ )} +
+ {canStartTrial && ( + + )} + {canSubscribe && ( + + )}
@@ -1337,31 +1390,17 @@ export const ShowBilling = () => {
- {admin?.user.stripeCustomerId && ( + {canSubscribe && ( )} - {!isEnterpriseCloud && - (data?.subscriptions?.length ?? 0) === 0 && ( - - )}
diff --git a/apps/dokploy/pages/api/stripe/webhook.ts b/apps/dokploy/pages/api/stripe/webhook.ts index ef8f90632..703bd9fc7 100644 --- a/apps/dokploy/pages/api/stripe/webhook.ts +++ b/apps/dokploy/pages/api/stripe/webhook.ts @@ -176,7 +176,10 @@ export default async function handler( break; } - if (newSubscription.status === "active") { + if ( + newSubscription.status === "active" || + newSubscription.status === "trialing" + ) { const serversQuantity = getSubscriptionServersQuantity( newSubscription?.items?.data ?? [], ); @@ -209,9 +212,12 @@ export default async function handler( case "invoice.payment_succeeded": { const newInvoice = event.data.object as Stripe.Invoice; - const subscription = await stripe.subscriptions.retrieve( - newInvoice.subscription as string, - ); + const subscriptionId = getInvoiceSubscriptionId(newInvoice); + if (!subscriptionId) { + break; + } + + const subscription = await stripe.subscriptions.retrieve(subscriptionId); if (subscription.status !== "active") { console.log( @@ -220,6 +226,14 @@ export default async function handler( break; } + const admin = await findUserByStripeCustomerId( + subscription.customer as string, + ); + + if (!admin) { + return res.status(400).send("Webhook Error: Admin not found"); + } + const serversQuantity = getSubscriptionServersQuantity( subscription?.items?.data ?? [], ); @@ -233,18 +247,10 @@ export default async function handler( ), ); - const admin = await findUserByStripeCustomerId( - subscription.customer as string, - ); - - if (!admin) { - return res.status(400).send("Webhook Error: Admin not found"); - } if (admin.isEnterpriseCloud) { break; } - const newServersQuantity = admin.serversQuantity; - await updateServersBasedOnQuantity(admin.id, newServersQuantity); + await updateServersBasedOnQuantity(admin.id, serversQuantity); if (admin.sendInvoiceNotifications) { await sendInvoiceEmail(newInvoice, admin); @@ -255,9 +261,12 @@ export default async function handler( case "invoice.payment_failed": { const newInvoice = event.data.object as Stripe.Invoice; - const subscription = await stripe.subscriptions.retrieve( - newInvoice.subscription as string, - ); + const subscriptionId = getInvoiceSubscriptionId(newInvoice); + if (!subscriptionId) { + break; + } + + const subscription = await stripe.subscriptions.retrieve(subscriptionId); if (subscription.status !== "active") { const admin = await findUserByStripeCustomerId( newInvoice.customer as string, @@ -351,6 +360,12 @@ const findUserByStripeCustomerId = async (stripeCustomerId: string) => { return userResult; }; +const getInvoiceSubscriptionId = (invoice: Stripe.Invoice): string | null => { + const subscription = invoice.subscription; + if (typeof subscription === "string") return subscription; + return subscription?.id ?? null; +}; + const activateServer = async (serverId: string) => { await db .update(server) diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts index ce5e15151..fc2abf842 100644 --- a/apps/dokploy/server/api/routers/stripe.ts +++ b/apps/dokploy/server/api/routers/stripe.ts @@ -12,7 +12,7 @@ import { getCurrentPlan as getCurrentPlanForOrganization, getStripeClient, TRIAL_DURATION_DAYS, - TRIAL_SERVER_LIMIT, + TRIAL_SERVER_LIMITS, } from "@/server/utils/billing"; import { type BillingTier, @@ -28,6 +28,10 @@ import { STARTUP_PRODUCT_ID, WEBSITE_URL, } from "@/server/utils/stripe"; +import { + processTrialExpirations, + sendTrialExpiringEmail, +} from "@/server/utils/trial-notifications"; import { adminProcedure, createTRPCRouter, @@ -35,6 +39,27 @@ import { withPermission, } from "../trpc"; +// These reach every trialing customer on the platform, so an org admin is not +// enough; only the Dokploy Cloud root account may run them. +const assertRootAccess = (ctx: { + user: { id: string }; + session?: { impersonatedBy?: string | null } | null; +}) => { + if (!IS_CLOUD) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This feature is only available in Dokploy Cloud", + }); + } + const rootId = process.env.USER_ADMIN_ID; + if ( + !rootId || + (ctx.user.id !== rootId && ctx.session?.impersonatedBy !== rootId) + ) { + throw new TRPCError({ code: "FORBIDDEN", message: "Root access required" }); + } +}; + export const stripeRouter = createTRPCRouter({ /** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */ getCurrentPlan: protectedProcedure.query(async ({ ctx }) => { @@ -45,72 +70,82 @@ export const stripeRouter = createTRPCRouter({ return getBillingStatus(ctx.user.ownerId); }), - startFreeTrial: adminProcedure.mutation(async ({ ctx }) => { - if (!IS_CLOUD) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This feature is only available in Dokploy Cloud", - }); - } - - if (!HOBBY_PRICE_MONTHLY_ID) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Trials are not configured", - }); - } - - const owner = await findUserById(ctx.user.ownerId); - const billingStatus = await getBillingStatus(owner.id); - - if (billingStatus.hasActiveAccess) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You already have an active plan or trial", - }); - } - - if (billingStatus.hasUsedTrial) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You have already used your free trial", - }); - } - - const stripe = getStripeClient(); - - let stripeCustomerId = owner.stripeCustomerId; - if (stripeCustomerId) { - const customer = await stripe.customers.retrieve(stripeCustomerId); - if (customer.deleted) { - stripeCustomerId = null; + startFreeTrial: adminProcedure + .input(z.object({ tier: z.enum(["hobby", "startup"]) })) + .mutation(async ({ ctx, input }) => { + if (!IS_CLOUD) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This feature is only available in Dokploy Cloud", + }); } - } - if (!stripeCustomerId) { - const customer = await stripe.customers.create({ email: owner.email }); - stripeCustomerId = customer.id; - } - const subscription = await stripe.subscriptions.create({ - customer: stripeCustomerId, - items: [{ price: HOBBY_PRICE_MONTHLY_ID, quantity: 1 }], - trial_period_days: TRIAL_DURATION_DAYS, - trial_settings: { end_behavior: { missing_payment_method: "cancel" } }, - metadata: { source: "onboarding_trial", adminId: owner.id }, - }); + const trialPriceId = + input.tier === "startup" + ? STARTUP_BASE_PRICE_MONTHLY_ID + : HOBBY_PRICE_MONTHLY_ID; + if (!trialPriceId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Trials are not configured", + }); + } - await updateUser(owner.id, { - stripeCustomerId, - stripeSubscriptionId: subscription.id, - serversQuantity: TRIAL_SERVER_LIMIT, - }); + const owner = await findUserById(ctx.user.ownerId); + const billingStatus = await getBillingStatus(owner.id); - return { - trialEndsAt: subscription.trial_end - ? new Date(subscription.trial_end * 1000) - : null, - }; - }), + if (billingStatus.hasActiveAccess) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You already have an active plan or trial", + }); + } + + if (billingStatus.hasUsedTrial) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You have already used your free trial", + }); + } + + const stripe = getStripeClient(); + + let stripeCustomerId = owner.stripeCustomerId; + if (stripeCustomerId) { + const customer = await stripe.customers.retrieve(stripeCustomerId); + if (customer.deleted) { + stripeCustomerId = null; + } + } + if (!stripeCustomerId) { + const customer = await stripe.customers.create({ email: owner.email }); + stripeCustomerId = customer.id; + } + + const subscription = await stripe.subscriptions.create({ + customer: stripeCustomerId, + items: [{ price: trialPriceId, quantity: 1 }], + trial_period_days: TRIAL_DURATION_DAYS, + trial_settings: { end_behavior: { missing_payment_method: "cancel" } }, + metadata: { + source: "onboarding_trial", + adminId: owner.id, + tier: input.tier, + }, + }); + + await updateUser(owner.id, { + stripeCustomerId, + stripeSubscriptionId: subscription.id, + serversQuantity: TRIAL_SERVER_LIMITS[input.tier], + }); + + return { + trialEndsAt: subscription.trial_end + ? new Date(subscription.trial_end * 1000) + : null, + }; + }), getProducts: adminProcedure.query(async ({ ctx }) => { const user = await findUserById(ctx.user.ownerId); @@ -343,9 +378,15 @@ export const stripeRouter = createTRPCRouter({ }); } + const isTrialing = subscription.status === "trialing"; + // Trials are capped at the plan's included servers; paid plans can scale. + const serverQuantity = isTrialing + ? TRIAL_SERVER_LIMITS[input.tier] + : input.serverQuantity; + const newItems = getStripeItems( input.tier as BillingTier, - input.serverQuantity, + serverQuantity, input.isAnnual, ); const currentItems = subscription.items.data; @@ -371,7 +412,7 @@ export const stripeRouter = createTRPCRouter({ await stripe.subscriptions.update(owner.stripeSubscriptionId, { items: updateItems, - proration_behavior: "create_prorations", + proration_behavior: isTrialing ? "none" : "create_prorations", }); return { ok: true }; @@ -406,6 +447,36 @@ export const stripeRouter = createTRPCRouter({ return { ok: true }; }), + sendTestTrialEmail: adminProcedure + .input(z.object({ daysRemaining: z.union([z.literal(1), z.literal(3)]) })) + .mutation(async ({ ctx, input }) => { + assertRootAccess(ctx); + + const owner = await findUserById(ctx.user.ownerId); + const billingStatus = await getBillingStatus(owner.id); + + await sendTrialExpiringEmail({ + email: ctx.user.email, + firstName: owner.firstName, + planName: billingStatus.plan + ? billingStatus.plan.charAt(0).toUpperCase() + + billingStatus.plan.slice(1) + : "Hobby", + daysRemaining: input.daysRemaining, + trialEndsAt: + billingStatus.trialEndsAt ?? + new Date(Date.now() + input.daysRemaining * 24 * 60 * 60 * 1000), + }); + + return { sentTo: ctx.user.email }; + }), + + runTrialExpirationJob: adminProcedure.mutation(async ({ ctx }) => { + assertRootAccess(ctx); + + return processTrialExpirations(); + }), + getInvoices: adminProcedure.query(async ({ ctx }) => { const user = await findUserById(ctx.user.ownerId); const stripeCustomerId = user.stripeCustomerId; diff --git a/apps/dokploy/server/server.ts b/apps/dokploy/server/server.ts index 5fe048c92..f94839f8f 100644 --- a/apps/dokploy/server/server.ts +++ b/apps/dokploy/server/server.ts @@ -16,6 +16,7 @@ import { import { config } from "dotenv"; import next from "next"; import packageInfo from "../package.json"; +import { initTrialNotificationsCronJob } from "./utils/trial-notifications"; import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs"; import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal"; import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats"; @@ -69,6 +70,10 @@ void app.prepare().then(async () => { } await initEnterpriseBackupCronJobs(); + if (IS_CLOUD) { + initTrialNotificationsCronJob(); + } + if (!IS_CLOUD) { console.log("Starting Deployment Worker"); const { startDeploymentWorker } = await import("./queues/queueSetup"); diff --git a/apps/dokploy/server/utils/billing.ts b/apps/dokploy/server/utils/billing.ts index f2a98fa02..527ba4194 100644 --- a/apps/dokploy/server/utils/billing.ts +++ b/apps/dokploy/server/utils/billing.ts @@ -16,7 +16,7 @@ export const getStripeClient = () => apiVersion: "2024-09-30.acacia", }); -const planFromPriceIds = (priceIds: string[]): BillingPlan | null => { +export const planFromPriceIds = (priceIds: string[]): BillingPlan | null => { if ( priceIds.some( (id) => @@ -78,7 +78,11 @@ export const getCurrentPlan = async ( }; export const TRIAL_DURATION_DAYS = 7; -export const TRIAL_SERVER_LIMIT = 1; +export type TrialTier = "hobby" | "startup"; +export const TRIAL_SERVER_LIMITS: Record = { + hobby: 1, + startup: 3, +}; export interface BillingStatus { plan: BillingPlan | null; @@ -87,6 +91,7 @@ export interface BillingStatus { trialDaysRemaining: number | null; hasUsedTrial: boolean; hasActiveAccess: boolean; + hasPaymentMethod: boolean; } export const getBillingStatus = async ( @@ -100,6 +105,7 @@ export const getBillingStatus = async ( trialDaysRemaining: null, hasUsedTrial: false, hasActiveAccess: true, + hasPaymentMethod: true, }; } @@ -112,6 +118,7 @@ export const getBillingStatus = async ( trialDaysRemaining: null, hasUsedTrial: false, hasActiveAccess: false, + hasPaymentMethod: false, }; } @@ -119,7 +126,7 @@ export const getBillingStatus = async ( const subscriptions = await stripe.subscriptions.list({ customer: owner.stripeCustomerId, status: "all", - expand: ["data.items.data.price"], + expand: ["data.items.data.price", "data.customer"], }); const relevantSubs = subscriptions.data.filter( @@ -133,6 +140,12 @@ export const getBillingStatus = async ( const trialingSub = subscriptions.data.find( (sub) => sub.status === "trialing", ); + + const currentSub = trialingSub ?? relevantSubs[0]; + const customer = currentSub?.customer as Stripe.Customer | undefined; + const hasPaymentMethod = + !!currentSub?.default_payment_method || + !!customer?.invoice_settings?.default_payment_method; const trialEndsAt = trialingSub?.trial_end ? new Date(trialingSub.trial_end * 1000) : null; @@ -148,6 +161,7 @@ export const getBillingStatus = async ( isOnTrial: !!trialingSub, trialEndsAt, trialDaysRemaining, + hasPaymentMethod, hasUsedTrial: subscriptions.data.length > 0, hasActiveAccess: plan !== null || !!trialingSub, }; diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts new file mode 100644 index 000000000..e12c055c6 --- /dev/null +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -0,0 +1,157 @@ +import { IS_CLOUD } from "@dokploy/server"; +import { db } from "@dokploy/server/db"; +import TrialExpiringEmail from "@dokploy/server/emails/emails/trial-expiring"; +import { sendEmail } from "@dokploy/server/verification/send-verification-email"; +import { render } from "@react-email/components"; +import { format } from "date-fns"; +import { eq } from "drizzle-orm"; +import { scheduleJob } from "node-schedule"; +import type Stripe from "stripe"; +import { user } from "@/server/db/schema"; +import { getStripeClient, planFromPriceIds } from "@/server/utils/billing"; +import { WEBSITE_URL } from "@/server/utils/stripe"; + +const REMINDER_THRESHOLDS = [ + { days: 3, metadataKey: "trialReminder3Sent" }, + { days: 1, metadataKey: "trialReminder1Sent" }, +] as const; + +const PLAN_LABELS: Record = { + hobby: "Hobby", + startup: "Startup", + legacy: "Legacy", +}; + +const daysUntil = (timestamp: number) => + Math.ceil((timestamp * 1000 - Date.now()) / (1000 * 60 * 60 * 24)); + +export const sendTrialExpiringEmail = async ({ + email, + firstName, + planName, + daysRemaining, + trialEndsAt, +}: { + email: string; + firstName: string; + planName: string; + daysRemaining: number; + trialEndsAt: Date; +}) => { + const htmlContent = await render( + TrialExpiringEmail({ + userName: firstName || "User", + planName, + daysRemaining, + endsOn: format(trialEndsAt, "MMM dd, yyyy"), + billingUrl: `${WEBSITE_URL}/dashboard/settings/billing`, + }), + ); + + await sendEmail({ + email, + subject: + daysRemaining === 1 + ? "Your Dokploy trial ends tomorrow" + : `Your Dokploy trial ends in ${daysRemaining} days`, + text: htmlContent, + }); +}; + +const hasPaymentMethod = (subscription: Stripe.Subscription) => { + if (subscription.default_payment_method) return true; + const customer = subscription.customer as Stripe.Customer | string; + if (typeof customer === "string") return false; + return !!customer.invoice_settings?.default_payment_method; +}; + +const processSubscription = async ( + stripe: Stripe, + subscription: Stripe.Subscription, +) => { + if (!subscription.trial_end || hasPaymentMethod(subscription)) return false; + + const daysRemaining = daysUntil(subscription.trial_end); + const threshold = REMINDER_THRESHOLDS.find( + (candidate) => + daysRemaining <= candidate.days && + !subscription.metadata?.[candidate.metadataKey], + ); + if (!threshold) return false; + + const customerId = + typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer.id; + + const owner = await db.query.user.findFirst({ + where: eq(user.stripeCustomerId, customerId), + }); + if (!owner || owner.isEnterpriseCloud) return false; + + const priceIds = subscription.items.data.map( + (item) => (item.price as Stripe.Price).id, + ); + const plan = planFromPriceIds(priceIds); + + // Claim the reminder before sending so concurrent replicas don't double-send. + await stripe.subscriptions.update(subscription.id, { + metadata: { ...subscription.metadata, [threshold.metadataKey]: "sent" }, + }); + + try { + await sendTrialExpiringEmail({ + email: owner.email, + firstName: owner.firstName, + planName: plan ? (PLAN_LABELS[plan] ?? plan) : "Trial", + daysRemaining: Math.max(daysRemaining, 1), + trialEndsAt: new Date(subscription.trial_end * 1000), + }); + } catch (error) { + await stripe.subscriptions.update(subscription.id, { + metadata: { ...subscription.metadata, [threshold.metadataKey]: "" }, + }); + throw error; + } + + return true; +}; + +export const processTrialExpirations = async () => { + const stripe = getStripeClient(); + let sent = 0; + let scanned = 0; + + for await (const subscription of stripe.subscriptions.list({ + status: "trialing", + limit: 100, + expand: ["data.customer", "data.items.data.price"], + })) { + scanned += 1; + try { + if (await processSubscription(stripe, subscription)) sent += 1; + } catch (error) { + console.error( + `Trial reminder failed for subscription ${subscription.id}:`, + error instanceof Error ? error.message : error, + ); + } + } + + return { scanned, sent }; +}; + +export const initTrialNotificationsCronJob = () => { + if (!IS_CLOUD) return; + + scheduleJob("trial-expiration-reminders", "0 14 * * *", async () => { + try { + await processTrialExpirations(); + } catch (error) { + console.error( + "Trial reminder job failed:", + error instanceof Error ? error.message : error, + ); + } + }); +}; diff --git a/packages/server/src/emails/emails/trial-expiring.tsx b/packages/server/src/emails/emails/trial-expiring.tsx new file mode 100644 index 000000000..68dd48c95 --- /dev/null +++ b/packages/server/src/emails/emails/trial-expiring.tsx @@ -0,0 +1,141 @@ +import { + Body, + Button, + Column, + Container, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Row, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { emailTailwindConfig } from "../tailwind-config"; + +export type TemplateProps = { + userName: string; + planName: string; + daysRemaining: number; + endsOn: string; + billingUrl: string; +}; + +export const TrialExpiringEmail = ({ + userName = "User", + planName = "Hobby", + daysRemaining = 3, + endsOn = "2024-01-01", + billingUrl = "https://app.dokploy.com/dashboard/settings/billing", +}: TemplateProps) => { + const dayLabel = daysRemaining === 1 ? "day" : "days"; + const previewText = + daysRemaining === 1 + ? "Your Dokploy trial ends tomorrow" + : `Your Dokploy trial ends in ${daysRemaining} ${dayLabel}`; + + return ( + + + {previewText} + + + +
+ Dokploy +
+ +
+ + {daysRemaining === 1 + ? "Your trial ends tomorrow" + : `Your trial ends in ${daysRemaining} ${dayLabel}`} + + + Hello {userName}, add a payment method to keep your servers and + applications running once the trial ends. + + +
+ + + + Plan + + + {planName} + + + + + Trial ends + + + {endsOn} + + + +
+ +
+ + Without a payment method your subscription is cancelled when + the trial ends and your servers are deactivated. + +
+ +
+ +
+ +
+ + + You are billed only when the trial ends. You can cancel at any + time before then. + +
+ +
+ + This is an automated email from{" "} + + Dokploy Cloud + + . If you have any questions about your billing, please contact + our{" "} + + support team + + . + +
+
+ +
+ + ); +}; + +export default TrialExpiringEmail; From d00ae319208e5d72bac494e56e2934ae4aeed5b3 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:38:02 -0600 Subject: [PATCH 10/31] refactor(billing): drop the sendTestTrialEmail endpoint The scan endpoint already covers manual runs, so the one-off sender was redundant. sendTrialExpiringEmail goes back to being module private. --- apps/dokploy/server/api/routers/stripe.ts | 29 +------------------ .../server/utils/trial-notifications.ts | 2 +- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts index fc2abf842..76d07eaf4 100644 --- a/apps/dokploy/server/api/routers/stripe.ts +++ b/apps/dokploy/server/api/routers/stripe.ts @@ -28,10 +28,7 @@ import { STARTUP_PRODUCT_ID, WEBSITE_URL, } from "@/server/utils/stripe"; -import { - processTrialExpirations, - sendTrialExpiringEmail, -} from "@/server/utils/trial-notifications"; +import { processTrialExpirations } from "@/server/utils/trial-notifications"; import { adminProcedure, createTRPCRouter, @@ -447,30 +444,6 @@ export const stripeRouter = createTRPCRouter({ return { ok: true }; }), - sendTestTrialEmail: adminProcedure - .input(z.object({ daysRemaining: z.union([z.literal(1), z.literal(3)]) })) - .mutation(async ({ ctx, input }) => { - assertRootAccess(ctx); - - const owner = await findUserById(ctx.user.ownerId); - const billingStatus = await getBillingStatus(owner.id); - - await sendTrialExpiringEmail({ - email: ctx.user.email, - firstName: owner.firstName, - planName: billingStatus.plan - ? billingStatus.plan.charAt(0).toUpperCase() + - billingStatus.plan.slice(1) - : "Hobby", - daysRemaining: input.daysRemaining, - trialEndsAt: - billingStatus.trialEndsAt ?? - new Date(Date.now() + input.daysRemaining * 24 * 60 * 60 * 1000), - }); - - return { sentTo: ctx.user.email }; - }), - runTrialExpirationJob: adminProcedure.mutation(async ({ ctx }) => { assertRootAccess(ctx); diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts index e12c055c6..e66307d6c 100644 --- a/apps/dokploy/server/utils/trial-notifications.ts +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -25,7 +25,7 @@ const PLAN_LABELS: Record = { const daysUntil = (timestamp: number) => Math.ceil((timestamp * 1000 - Date.now()) / (1000 * 60 * 60 * 24)); -export const sendTrialExpiringEmail = async ({ +const sendTrialExpiringEmail = async ({ email, firstName, planName, From f34151bc1267b17e663098522ddcd1c2891e08b4 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:39:33 -0600 Subject: [PATCH 11/31] refactor(billing): drop the runTrialExpirationJob endpoint Removes the manual trigger and the root guard it needed. The cron job is the only caller now, so processTrialExpirations goes back to module private. --- apps/dokploy/server/api/routers/stripe.ts | 28 ------------------- .../server/utils/trial-notifications.ts | 2 +- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts index 76d07eaf4..944c86c8f 100644 --- a/apps/dokploy/server/api/routers/stripe.ts +++ b/apps/dokploy/server/api/routers/stripe.ts @@ -28,7 +28,6 @@ import { STARTUP_PRODUCT_ID, WEBSITE_URL, } from "@/server/utils/stripe"; -import { processTrialExpirations } from "@/server/utils/trial-notifications"; import { adminProcedure, createTRPCRouter, @@ -36,27 +35,6 @@ import { withPermission, } from "../trpc"; -// These reach every trialing customer on the platform, so an org admin is not -// enough; only the Dokploy Cloud root account may run them. -const assertRootAccess = (ctx: { - user: { id: string }; - session?: { impersonatedBy?: string | null } | null; -}) => { - if (!IS_CLOUD) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This feature is only available in Dokploy Cloud", - }); - } - const rootId = process.env.USER_ADMIN_ID; - if ( - !rootId || - (ctx.user.id !== rootId && ctx.session?.impersonatedBy !== rootId) - ) { - throw new TRPCError({ code: "FORBIDDEN", message: "Root access required" }); - } -}; - export const stripeRouter = createTRPCRouter({ /** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */ getCurrentPlan: protectedProcedure.query(async ({ ctx }) => { @@ -444,12 +422,6 @@ export const stripeRouter = createTRPCRouter({ return { ok: true }; }), - runTrialExpirationJob: adminProcedure.mutation(async ({ ctx }) => { - assertRootAccess(ctx); - - return processTrialExpirations(); - }), - getInvoices: adminProcedure.query(async ({ ctx }) => { const user = await findUserById(ctx.user.ownerId); const stripeCustomerId = user.stripeCustomerId; diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts index e66307d6c..2a7b9c90e 100644 --- a/apps/dokploy/server/utils/trial-notifications.ts +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -117,7 +117,7 @@ const processSubscription = async ( return true; }; -export const processTrialExpirations = async () => { +const processTrialExpirations = async () => { const stripe = getStripeClient(); let sent = 0; let scanned = 0; From 14e059784cdde4212d94f92154f7b304b78c7e39 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:41:28 -0600 Subject: [PATCH 12/31] fix(billing): stop the reminder claim from rewriting stale metadata Stripe merges metadata on update, so spreading the snapshot read during the list call re-asserted stale values over anything written since. Send only the key being claimed. The claim is not exclusive and the comment no longer says it is: two replicas can still read the same unset marker. Marking before the send only keeps a crash mid-send from resending on the next run. --- apps/dokploy/server/utils/trial-notifications.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts index 2a7b9c90e..7e2fc20e7 100644 --- a/apps/dokploy/server/utils/trial-notifications.ts +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -94,9 +94,11 @@ const processSubscription = async ( ); const plan = planFromPriceIds(priceIds); - // Claim the reminder before sending so concurrent replicas don't double-send. + // Marked before sending so a crash mid-send cannot resend on the next run. + // Stripe merges metadata, so only the claimed key travels; spreading the + // snapshot read at list time would rewrite concurrent changes. await stripe.subscriptions.update(subscription.id, { - metadata: { ...subscription.metadata, [threshold.metadataKey]: "sent" }, + metadata: { [threshold.metadataKey]: "sent" }, }); try { @@ -109,7 +111,7 @@ const processSubscription = async ( }); } catch (error) { await stripe.subscriptions.update(subscription.id, { - metadata: { ...subscription.metadata, [threshold.metadataKey]: "" }, + metadata: { [threshold.metadataKey]: "" }, }); throw error; } From 037230496c1880ed4ff1463ffc812dd7a01165b2 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:45:46 -0600 Subject: [PATCH 13/31] fix(billing): one reminder at 1 day, guarded by an advisory lock Cloud runs two frontend replicas and node-schedule fires on each, so both would read the same unset marker and every trial user would get the email twice. A Postgres advisory lock lets one replica run and the other skip; it lives in the connection rather than the schema, so no migration. Drops the 3-day reminder, leaving the single notice before billing starts. --- .../server/utils/trial-notifications.ts | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts index 7e2fc20e7..090be2748 100644 --- a/apps/dokploy/server/utils/trial-notifications.ts +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -1,21 +1,42 @@ import { IS_CLOUD } from "@dokploy/server"; -import { db } from "@dokploy/server/db"; +import { db, dbUrl } from "@dokploy/server/db"; import TrialExpiringEmail from "@dokploy/server/emails/emails/trial-expiring"; import { sendEmail } from "@dokploy/server/verification/send-verification-email"; import { render } from "@react-email/components"; import { format } from "date-fns"; import { eq } from "drizzle-orm"; import { scheduleJob } from "node-schedule"; +import postgres from "postgres"; import type Stripe from "stripe"; import { user } from "@/server/db/schema"; import { getStripeClient, planFromPriceIds } from "@/server/utils/billing"; import { WEBSITE_URL } from "@/server/utils/stripe"; const REMINDER_THRESHOLDS = [ - { days: 3, metadataKey: "trialReminder3Sent" }, { days: 1, metadataKey: "trialReminder1Sent" }, ] as const; +// Cloud runs several replicas and node-schedule fires on each one, so without +// this every trial user would get one email per replica. Advisory locks live +// in the connection rather than the schema, so no migration is needed. +const ADVISORY_LOCK_KEY = 4820260918; + +const withAdvisoryLock = async (run: () => Promise) => { + const sql = postgres(dbUrl, { max: 1 }); + try { + const [row] = + await sql`select pg_try_advisory_lock(${ADVISORY_LOCK_KEY}) as locked`; + if (!row?.locked) return null; + try { + return await run(); + } finally { + await sql`select pg_advisory_unlock(${ADVISORY_LOCK_KEY})`; + } + } finally { + await sql.end({ timeout: 5 }); + } +}; + const PLAN_LABELS: Record = { hobby: "Hobby", startup: "Startup", @@ -148,7 +169,14 @@ export const initTrialNotificationsCronJob = () => { scheduleJob("trial-expiration-reminders", "0 14 * * *", async () => { try { - await processTrialExpirations(); + const result = await withAdvisoryLock(processTrialExpirations); + if (!result) { + console.log("Trial reminders: another replica holds the lock"); + return; + } + console.log( + `Trial reminders: scanned ${result.scanned}, sent ${result.sent}`, + ); } catch (error) { console.error( "Trial reminder job failed:", From 8236c31fcdda0eb50338167c2f90ce14ad62ab6a Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:49:18 -0600 Subject: [PATCH 14/31] Revert "fix(billing): one reminder at 1 day, guarded by an advisory lock" Revert "fix(billing): stop the reminder claim from rewriting stale metadata" This reverts commits 037230496 and 14e059784. --- .../server/utils/trial-notifications.ts | 42 +++---------------- 1 file changed, 6 insertions(+), 36 deletions(-) diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts index 090be2748..2a7b9c90e 100644 --- a/apps/dokploy/server/utils/trial-notifications.ts +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -1,42 +1,21 @@ import { IS_CLOUD } from "@dokploy/server"; -import { db, dbUrl } from "@dokploy/server/db"; +import { db } from "@dokploy/server/db"; import TrialExpiringEmail from "@dokploy/server/emails/emails/trial-expiring"; import { sendEmail } from "@dokploy/server/verification/send-verification-email"; import { render } from "@react-email/components"; import { format } from "date-fns"; import { eq } from "drizzle-orm"; import { scheduleJob } from "node-schedule"; -import postgres from "postgres"; import type Stripe from "stripe"; import { user } from "@/server/db/schema"; import { getStripeClient, planFromPriceIds } from "@/server/utils/billing"; import { WEBSITE_URL } from "@/server/utils/stripe"; const REMINDER_THRESHOLDS = [ + { days: 3, metadataKey: "trialReminder3Sent" }, { days: 1, metadataKey: "trialReminder1Sent" }, ] as const; -// Cloud runs several replicas and node-schedule fires on each one, so without -// this every trial user would get one email per replica. Advisory locks live -// in the connection rather than the schema, so no migration is needed. -const ADVISORY_LOCK_KEY = 4820260918; - -const withAdvisoryLock = async (run: () => Promise) => { - const sql = postgres(dbUrl, { max: 1 }); - try { - const [row] = - await sql`select pg_try_advisory_lock(${ADVISORY_LOCK_KEY}) as locked`; - if (!row?.locked) return null; - try { - return await run(); - } finally { - await sql`select pg_advisory_unlock(${ADVISORY_LOCK_KEY})`; - } - } finally { - await sql.end({ timeout: 5 }); - } -}; - const PLAN_LABELS: Record = { hobby: "Hobby", startup: "Startup", @@ -115,11 +94,9 @@ const processSubscription = async ( ); const plan = planFromPriceIds(priceIds); - // Marked before sending so a crash mid-send cannot resend on the next run. - // Stripe merges metadata, so only the claimed key travels; spreading the - // snapshot read at list time would rewrite concurrent changes. + // Claim the reminder before sending so concurrent replicas don't double-send. await stripe.subscriptions.update(subscription.id, { - metadata: { [threshold.metadataKey]: "sent" }, + metadata: { ...subscription.metadata, [threshold.metadataKey]: "sent" }, }); try { @@ -132,7 +109,7 @@ const processSubscription = async ( }); } catch (error) { await stripe.subscriptions.update(subscription.id, { - metadata: { [threshold.metadataKey]: "" }, + metadata: { ...subscription.metadata, [threshold.metadataKey]: "" }, }); throw error; } @@ -169,14 +146,7 @@ export const initTrialNotificationsCronJob = () => { scheduleJob("trial-expiration-reminders", "0 14 * * *", async () => { try { - const result = await withAdvisoryLock(processTrialExpirations); - if (!result) { - console.log("Trial reminders: another replica holds the lock"); - return; - } - console.log( - `Trial reminders: scanned ${result.scanned}, sent ${result.sent}`, - ); + await processTrialExpirations(); } catch (error) { console.error( "Trial reminder job failed:", From a0e1001808817dfc6950b42aaf8f883dc21d81e1 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 03:03:51 -0600 Subject: [PATCH 15/31] feat(trial-notifications): add email notification for expiring trials and integrate with webhook --- apps/dokploy/pages/api/stripe/webhook.ts | 8 + apps/dokploy/server/server.ts | 5 - .../server/utils/trial-notifications.ts | 165 +++++------------- 3 files changed, 51 insertions(+), 127 deletions(-) diff --git a/apps/dokploy/pages/api/stripe/webhook.ts b/apps/dokploy/pages/api/stripe/webhook.ts index 703bd9fc7..e495a40d4 100644 --- a/apps/dokploy/pages/api/stripe/webhook.ts +++ b/apps/dokploy/pages/api/stripe/webhook.ts @@ -9,6 +9,7 @@ import { sendInvoiceEmail, sendPaymentFailedEmail, } from "@/server/utils/stripe-notifications"; +import { sendTrialExpiringEmail } from "@/server/utils/trial-notifications"; const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!; @@ -72,6 +73,7 @@ export default async function handler( "invoice.payment_failed", "customer.deleted", "checkout.session.completed", + "customer.subscription.trial_will_end", ]; if (!webhooksAllowed.includes(event.type)) { @@ -110,6 +112,12 @@ export default async function handler( await updateServersBasedOnQuantity(admin.id, newServersQuantity); break; } + case "customer.subscription.trial_will_end": { + const trialingSubscription = event.data.object as Stripe.Subscription; + + await sendTrialExpiringEmail(stripe, trialingSubscription, event.created); + break; + } case "customer.subscription.created": { const newSubscription = event.data.object as Stripe.Subscription; diff --git a/apps/dokploy/server/server.ts b/apps/dokploy/server/server.ts index f94839f8f..5fe048c92 100644 --- a/apps/dokploy/server/server.ts +++ b/apps/dokploy/server/server.ts @@ -16,7 +16,6 @@ import { import { config } from "dotenv"; import next from "next"; import packageInfo from "../package.json"; -import { initTrialNotificationsCronJob } from "./utils/trial-notifications"; import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs"; import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal"; import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats"; @@ -70,10 +69,6 @@ void app.prepare().then(async () => { } await initEnterpriseBackupCronJobs(); - if (IS_CLOUD) { - initTrialNotificationsCronJob(); - } - if (!IS_CLOUD) { console.log("Starting Deployment Worker"); const { startDeploymentWorker } = await import("./queues/queueSetup"); diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts index 2a7b9c90e..1c7e0e515 100644 --- a/apps/dokploy/server/utils/trial-notifications.ts +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -1,47 +1,64 @@ -import { IS_CLOUD } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import TrialExpiringEmail from "@dokploy/server/emails/emails/trial-expiring"; import { sendEmail } from "@dokploy/server/verification/send-verification-email"; import { render } from "@react-email/components"; import { format } from "date-fns"; import { eq } from "drizzle-orm"; -import { scheduleJob } from "node-schedule"; import type Stripe from "stripe"; import { user } from "@/server/db/schema"; -import { getStripeClient, planFromPriceIds } from "@/server/utils/billing"; +import { planFromPriceIds } from "@/server/utils/billing"; import { WEBSITE_URL } from "@/server/utils/stripe"; -const REMINDER_THRESHOLDS = [ - { days: 3, metadataKey: "trialReminder3Sent" }, - { days: 1, metadataKey: "trialReminder1Sent" }, -] as const; - const PLAN_LABELS: Record = { hobby: "Hobby", startup: "Startup", legacy: "Legacy", }; -const daysUntil = (timestamp: number) => - Math.ceil((timestamp * 1000 - Date.now()) / (1000 * 60 * 60 * 24)); +const customerIdOf = (subscription: Stripe.Subscription) => + typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer.id; + +const hasPaymentMethod = async ( + stripe: Stripe, + subscription: Stripe.Subscription, +) => { + if (subscription.default_payment_method) return true; + + const customer = await stripe.customers.retrieve(customerIdOf(subscription)); + if (customer.deleted) return true; + return !!customer.invoice_settings?.default_payment_method; +}; + +export const sendTrialExpiringEmail = async ( + stripe: Stripe, + subscription: Stripe.Subscription, + // Stripe retries a failing webhook for days, so the countdown is measured + // from when Stripe emitted the event rather than from the current time. + notifiedAtSeconds: number, +) => { + if (!subscription.trial_end) return false; + if (await hasPaymentMethod(stripe, subscription)) return false; + + const owner = await db.query.user.findFirst({ + where: eq(user.stripeCustomerId, customerIdOf(subscription)), + }); + if (!owner || owner.isEnterpriseCloud) return false; + + const trialEndsAt = new Date(subscription.trial_end * 1000); + const daysRemaining = Math.max( + 1, + Math.ceil((subscription.trial_end - notifiedAtSeconds) / (60 * 60 * 24)), + ); + const plan = planFromPriceIds( + subscription.items.data.map((item) => (item.price as Stripe.Price).id), + ); -const sendTrialExpiringEmail = async ({ - email, - firstName, - planName, - daysRemaining, - trialEndsAt, -}: { - email: string; - firstName: string; - planName: string; - daysRemaining: number; - trialEndsAt: Date; -}) => { const htmlContent = await render( TrialExpiringEmail({ - userName: firstName || "User", - planName, + userName: owner.firstName || "User", + planName: plan ? (PLAN_LABELS[plan] ?? plan) : "Trial", daysRemaining, endsOn: format(trialEndsAt, "MMM dd, yyyy"), billingUrl: `${WEBSITE_URL}/dashboard/settings/billing`, @@ -49,109 +66,13 @@ const sendTrialExpiringEmail = async ({ ); await sendEmail({ - email, + email: owner.email, subject: daysRemaining === 1 ? "Your Dokploy trial ends tomorrow" : `Your Dokploy trial ends in ${daysRemaining} days`, text: htmlContent, }); -}; - -const hasPaymentMethod = (subscription: Stripe.Subscription) => { - if (subscription.default_payment_method) return true; - const customer = subscription.customer as Stripe.Customer | string; - if (typeof customer === "string") return false; - return !!customer.invoice_settings?.default_payment_method; -}; - -const processSubscription = async ( - stripe: Stripe, - subscription: Stripe.Subscription, -) => { - if (!subscription.trial_end || hasPaymentMethod(subscription)) return false; - - const daysRemaining = daysUntil(subscription.trial_end); - const threshold = REMINDER_THRESHOLDS.find( - (candidate) => - daysRemaining <= candidate.days && - !subscription.metadata?.[candidate.metadataKey], - ); - if (!threshold) return false; - - const customerId = - typeof subscription.customer === "string" - ? subscription.customer - : subscription.customer.id; - - const owner = await db.query.user.findFirst({ - where: eq(user.stripeCustomerId, customerId), - }); - if (!owner || owner.isEnterpriseCloud) return false; - - const priceIds = subscription.items.data.map( - (item) => (item.price as Stripe.Price).id, - ); - const plan = planFromPriceIds(priceIds); - - // Claim the reminder before sending so concurrent replicas don't double-send. - await stripe.subscriptions.update(subscription.id, { - metadata: { ...subscription.metadata, [threshold.metadataKey]: "sent" }, - }); - - try { - await sendTrialExpiringEmail({ - email: owner.email, - firstName: owner.firstName, - planName: plan ? (PLAN_LABELS[plan] ?? plan) : "Trial", - daysRemaining: Math.max(daysRemaining, 1), - trialEndsAt: new Date(subscription.trial_end * 1000), - }); - } catch (error) { - await stripe.subscriptions.update(subscription.id, { - metadata: { ...subscription.metadata, [threshold.metadataKey]: "" }, - }); - throw error; - } return true; }; - -const processTrialExpirations = async () => { - const stripe = getStripeClient(); - let sent = 0; - let scanned = 0; - - for await (const subscription of stripe.subscriptions.list({ - status: "trialing", - limit: 100, - expand: ["data.customer", "data.items.data.price"], - })) { - scanned += 1; - try { - if (await processSubscription(stripe, subscription)) sent += 1; - } catch (error) { - console.error( - `Trial reminder failed for subscription ${subscription.id}:`, - error instanceof Error ? error.message : error, - ); - } - } - - return { scanned, sent }; -}; - -export const initTrialNotificationsCronJob = () => { - if (!IS_CLOUD) return; - - scheduleJob("trial-expiration-reminders", "0 14 * * *", async () => { - try { - await processTrialExpirations(); - } catch (error) { - console.error( - "Trial reminder job failed:", - error instanceof Error ? error.message : error, - ); - } - }); -}; From 16ac4633f3428c0602f3046e22333412322ed00c Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:04:07 -0600 Subject: [PATCH 16/31] Merge pull request #5477 from Dokploy/feat/trial-expiration-emails fix(billing): pick a plan for the trial and warn before it expires (cherry picked from commit d7d75f7c5c91bf3240bdb9cb35a5708b09b16c4a) [skip ci] --- .../dashboard/billing/trial-banner.tsx | 23 +- .../onboarding/onboarding-wizard.tsx | 5 +- .../dashboard/onboarding/steps/plan-step.tsx | 237 +++++----- .../onboarding/steps/project-step.tsx | 2 +- .../onboarding/steps/server-step.tsx | 24 +- .../onboarding/steps/welcome-step.tsx | 4 +- .../settings/billing/show-billing.tsx | 435 ++++++++++-------- apps/dokploy/pages/api/stripe/webhook.ts | 55 ++- apps/dokploy/server/api/routers/stripe.ts | 146 +++--- apps/dokploy/server/utils/billing.ts | 20 +- .../server/utils/trial-notifications.ts | 78 ++++ .../src/emails/emails/trial-expiring.tsx | 141 ++++++ 12 files changed, 743 insertions(+), 427 deletions(-) create mode 100644 apps/dokploy/server/utils/trial-notifications.ts create mode 100644 packages/server/src/emails/emails/trial-expiring.tsx diff --git a/apps/dokploy/components/dashboard/billing/trial-banner.tsx b/apps/dokploy/components/dashboard/billing/trial-banner.tsx index 0c5bfdffb..caf3634f1 100644 --- a/apps/dokploy/components/dashboard/billing/trial-banner.tsx +++ b/apps/dokploy/components/dashboard/billing/trial-banner.tsx @@ -6,28 +6,41 @@ import { api } from "@/utils/api"; export const TrialBanner = () => { const router = useRouter(); const { data: billingStatus } = api.stripe.getBillingStatus.useQuery(); + const { mutateAsync: createCustomerPortalSession, isPending } = + api.stripe.createCustomerPortalSession.useMutation(); - if (!billingStatus?.isOnTrial) { + const handleAddPaymentMethod = async () => { + const session = await createCustomerPortalSession().catch(() => null); + if (session?.url) { + window.open(session.url); + return; + } + router.push("/dashboard/settings/billing"); + }; + + if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) { return null; } const daysRemaining = billingStatus.trialDaysRemaining ?? 0; + const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`; return (
{daysRemaining > 0 - ? `You have ${daysRemaining} day${daysRemaining === 1 ? "" : "s"} left in your free trial.` - : "Your free trial ends today."} + ? `You have ${daysLabel} left in your free trial. Add a payment method to keep your servers.` + : "Your free trial ends today. Add a payment method to keep your servers."}
); diff --git a/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx index da29cae7c..95780bf63 100644 --- a/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx +++ b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx @@ -278,8 +278,9 @@ export const OnboardingWizard = ({ onClose }: Props) => { Skip onboarding? If this is your first time using Dokploy, we recommend going - through these steps — it only takes a couple of minutes and gives - you a feel for how projects, servers and deployments fit together. + through these steps – it only takes a couple of minutes and gives + you a feel for how projects, servers, and deployments fit + together. diff --git a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx index 9fe8a4947..9863aaf38 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx @@ -15,13 +15,50 @@ const stripePromise = loadStripe( process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, ); +type Tier = "hobby" | "startup"; + +const PLANS: { + tier: Tier; + name: string; + description: string; + price: number; + features: string[]; + recommended?: boolean; +}[] = [ + { + tier: "hobby", + name: "Hobby", + description: "For individual developers", + price: calculatePriceHobby(1, false), + features: [ + "Setup 1 server", + "Unlimited apps & databases", + "2 environments", + "Community support", + ], + }, + { + tier: "startup", + name: "Startup", + description: "For small to mid-size teams", + price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false), + recommended: true, + features: [ + `Setup up to ${STARTUP_SERVERS_INCLUDED} servers`, + "Unlimited users & environments", + "Basic RBAC + 2FA", + "Email & chat support", + ], + }, +]; + interface Props { onNext: () => void; } export const PlanStep = ({ onNext }: Props) => { - const [loadingTier, setLoadingTier] = useState< - "hobby" | "startup" | "trial" | null + const [loading, setLoading] = useState< + `${"trial" | "checkout"}:${Tier}` | null >(null); const { data } = api.stripe.getProducts.useQuery(); const { mutateAsync: createCheckoutSession } = @@ -30,12 +67,12 @@ export const PlanStep = ({ onNext }: Props) => { api.stripe.startFreeTrial.useMutation(); const utils = api.useUtils(); - const handleCheckout = async (tier: "hobby" | "startup") => { + const handleCheckout = async (tier: Tier) => { if (!data) return; const productId = tier === "hobby" ? data.hobbyProductId : data.startupProductId; if (!productId) return; - setLoadingTier(tier); + setLoading(`checkout:${tier}`); try { const stripe = await stripePromise; const session = await createCheckoutSession({ @@ -47,14 +84,14 @@ export const PlanStep = ({ onNext }: Props) => { await stripe?.redirectToCheckout({ sessionId: session.sessionId }); } catch { toast.error("Error starting checkout"); - setLoadingTier(null); + setLoading(null); } }; - const handleTrial = async () => { - setLoadingTier("trial"); + const handleTrial = async (tier: Tier) => { + setLoading(`trial:${tier}`); try { - await startFreeTrial(); + await startFreeTrial({ tier }); await utils.project.onboardingStatus.invalidate(); toast.success("Your 7-day trial has started"); onNext(); @@ -63,7 +100,7 @@ export const PlanStep = ({ onNext }: Props) => { error instanceof Error ? error.message : "Error starting trial", ); } finally { - setLoadingTier(null); + setLoading(null); } }; @@ -79,134 +116,72 @@ export const PlanStep = ({ onNext }: Props) => { Start free, upgrade when ready.

- No credit card for the trial — add one only if you decide to stay. + Try any plan free for 7 days. No credit card required — add one only + if you decide to stay.

-
-
-
- - Recommended - -

- 7-day free trial -

-

- No card required — cancel anytime. -

-
    - {[ - "Setup 1 server", - "Unlimited apps & databases", - "Community support", - ].map((f) => ( -
  • - - {f} -
  • - ))} -
-
- -
-
-
-
-
-

Hobby

-

- For individual developers -

-

- ${calculatePriceHobby(1, false).toFixed(2)} - - {" "} - /mo - -

-
    - {[ - "Setup 1 server", - "Unlimited apps & databases", - "2 environments", - "Community support", - ].map((f) => ( -
  • { + const productId = + plan.tier === "hobby" + ? data?.hobbyProductId + : data?.startupProductId; + return ( +
    +
    + {plan.recommended && ( + + Recommended + + )} +

    {plan.name}

    +

    + {plan.description} +

    +

    + ${plan.price.toFixed(2)} + + {" "} + /mo + +

    +
      + {plan.features.map((f) => ( +
    • + + {f} +
    • + ))} +
    +
    +
    +
  • - ))} -
-
- -
- -
-
-

Startup

-

- For small to mid-size teams -

-

- $ - {calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed( - 2, - )} - - {" "} - /mo - -

-
    - {[ - `Setup up to ${STARTUP_SERVERS_INCLUDED} servers`, - "Unlimited users & environments", - "Basic RBAC + 2FA", - "Email & chat support", - ].map((f) => ( -
  • + +
  • - ))} -
-
- -
+ Subscribe now + +
+ + ); + })} ); diff --git a/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx index 24c37d9d9..ccc43b030 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx @@ -65,7 +65,7 @@ export const ProjectStep = ({ onNext, plainTitle }: Props) => {

Create your first project.

- Projects group your apps, databases and environments together. + Projects group your apps, databases, and environments together.

diff --git a/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx index 08efb12c3..7af9b6ee3 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx @@ -1,6 +1,12 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import copy from "copy-to-clipboard"; -import { CheckCircle2, CopyIcon, Loader2, XCircle } from "lucide-react"; +import { + CheckCircle2, + CopyIcon, + InfoIcon, + Loader2, + XCircle, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -200,9 +206,19 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {

Connect a server.

- Dokploy deploys to servers you own — buy one from any VPS provider + Dokploy deploys to servers you own. Buy one from any VPS provider (Hetzner, DigitalOcean, Hostinger...) and paste its IP below.

+
+ +
+ Requirements + + To ensure a smooth experience with Dokploy, your server should + have at least 1GB of RAM and 30GB of disk space. + +
+
{!createdServerId ? ( @@ -287,7 +303,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => { {isSettingUp ? (
- Setting up your server — installing Docker and dependencies... + Setting up your server – installing Docker and dependencies…
) : isValidating && !validation ? (
@@ -311,7 +327,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
{!isReady && !isSettingUp && ( - Setup usually takes a minute after the server boots — this checks + Setup runs after the server boots and this page checks for it automatically. You can retry setup, or use "Skip for now" above to continue and come back to this later. diff --git a/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx index 967075452..6f5ef9a1e 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx @@ -39,8 +39,8 @@ export const WelcomeStep = ({ onNext }: Props) => { Let's get your first app live.

- A few steps — pick a plan, connect a server, ship something. You'll - have a working URL by the end. + It just takes a few steps: pick a plan, connect a server, ship + something. You'll have a working URL by the end.

diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index a85b3242e..b66a23ca4 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -105,13 +105,47 @@ export const ShowBilling = () => { api.stripe.upgradeSubscription.useMutation(); const { mutateAsync: updateInvoiceNotifications } = api.stripe.updateInvoiceNotifications.useMutation(); - const { mutateAsync: startFreeTrial, isPending: isStartingTrial } = + const { mutateAsync: startFreeTrial } = api.stripe.startFreeTrial.useMutation(); const utils = api.useUtils(); + const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null); + const [switchingTrial, setSwitchingTrial] = useState(false); - const handleStartTrial = async () => { + const handleSwitchTrialPlan = async (tier: "hobby" | "startup") => { + const planName = tier === "startup" ? "Startup" : "Hobby"; + + const switchPlan = async () => { + await upgradeSubscription({ + tier, + serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1, + isAnnual: false, + }); + await new Promise((resolve) => setTimeout(resolve, 3000)); + await Promise.all([ + utils.stripe.getBillingStatus.invalidate(), + utils.stripe.getProducts.invalidate(), + utils.user.get.invalidate(), + utils.server.count.invalidate(), + ]); + }; + + setSwitchingTrial(true); + await toast + .promise(switchPlan(), { + loading: `Switching to ${planName}...`, + success: `Your trial is now on the ${planName} plan`, + error: (error) => + error instanceof Error ? error.message : "Error switching plan", + }) + .unwrap() + .catch(() => undefined); + setSwitchingTrial(false); + }; + + const handleStartTrial = async (tier: "hobby" | "startup") => { + setTrialTier(tier); try { - await startFreeTrial(); + await startFreeTrial({ tier }); await Promise.all([ utils.stripe.getBillingStatus.invalidate(), utils.stripe.getProducts.invalidate(), @@ -122,6 +156,8 @@ export const ShowBilling = () => { toast.error( error instanceof Error ? error.message : "Error starting trial", ); + } finally { + setTrialTier(null); } }; @@ -170,12 +206,23 @@ export const ShowBilling = () => { const useNewPricing = data?.hobbyProductId && data?.startupProductId; const products = data?.products.filter((product) => { - // @ts-ignore + // @ts-expect-error const interval = product?.default_price?.recurring?.interval; return isAnnual ? interval === "year" : interval === "month"; }); const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false; + const canStartTrial = + !isEnterpriseCloud && + !!billingStatus && + !billingStatus.plan && + !billingStatus.isOnTrial && + !billingStatus.hasUsedTrial; + /** Checkout would create a second subscription alongside a running trial. */ + const canSubscribe = + !isEnterpriseCloud && + (data?.subscriptions?.length ?? 0) === 0 && + !billingStatus?.isOnTrial; const maxServers = admin?.user.serversQuantity ?? 1; const percentage = ((servers ?? 0) / maxServers) * 100; const safePercentage = Math.min(percentage, 100); @@ -296,67 +343,72 @@ export const ShowBilling = () => { billingStatus.trialEndsAt ? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}` : "" + }${ + billingStatus.hasPaymentMethod + ? " · billing starts automatically" + : " · add a card to keep your servers" }` : "You're subscribed and billed automatically."} - {billingStatus.isOnTrial && - admin?.user.stripeCustomerId && ( + {admin?.user.stripeCustomerId && ( +
+ {billingStatus.isOnTrial && + useNewPricing && + billingStatus.plan && ( + + )} - )} -
- )} - {!isEnterpriseCloud && - billingStatus && - !billingStatus.plan && - !billingStatus.isOnTrial && - !billingStatus.hasUsedTrial && ( -
-
- -
- - 7-day free trial - - - No credit card required — cancel anytime. - -
    - {[ - "Setup 1 server", - "Unlimited apps & databases", - "Community support", - ].map((feature) => ( -
  • - - {feature} -
  • - ))} -
-
- + )}
)} + {canStartTrial && ( +
+ +
+ + 7-day free trial on any plan + + + No credit card required — pick Hobby or Startup below. + +
+
+ )} {(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (

Servers Plan

@@ -933,68 +985,71 @@ export const ShowBilling = () => { ))}
-
- - Servers: - - - - setHobbyServerQuantity( - Math.max( - 1, - Number( - (e.target as HTMLInputElement).value, - ) || 1, - ), - ) - } - className="text-center" - /> - -
-
- {admin?.user.stripeCustomerId && ( + {canSubscribe && ( +
+ + Servers: + + + setHobbyServerQuantity( + Math.max( + 1, + Number( + (e.target as HTMLInputElement).value, + ) || 1, + ), + ) + } + className="text-center" + /> + +
+ )} +
+ {canStartTrial && ( + + )} + {canSubscribe && ( + )} - {!isEnterpriseCloud && - (data?.subscriptions?.length ?? 0) === 0 && ( - - )}
@@ -1067,85 +1122,83 @@ export const ShowBilling = () => { ))}
-
- - Servers (min. {STARTUP_SERVERS_INCLUDED} included) - -
- - - setStartupServerQuantity( - Math.max( - STARTUP_SERVERS_INCLUDED, - Number( - (e.target as HTMLInputElement).value, - ) || STARTUP_SERVERS_INCLUDED, - ), - ) - } - className="h-8 text-center" - /> - -
-
-
- {admin?.user.stripeCustomerId && ( - - )} - {!isEnterpriseCloud && - (data?.subscriptions?.length ?? 0) === 0 && ( + {canSubscribe && ( +
+ + Servers (min. {STARTUP_SERVERS_INCLUDED} included) + +
- )} + + setStartupServerQuantity( + Math.max( + STARTUP_SERVERS_INCLUDED, + Number( + (e.target as HTMLInputElement).value, + ) || STARTUP_SERVERS_INCLUDED, + ), + ) + } + className="h-8 text-center" + /> + +
+
+ )} +
+ {canStartTrial && ( + + )} + {canSubscribe && ( + + )}
@@ -1337,31 +1390,17 @@ export const ShowBilling = () => {
- {admin?.user.stripeCustomerId && ( + {canSubscribe && ( )} - {!isEnterpriseCloud && - (data?.subscriptions?.length ?? 0) === 0 && ( - - )}
diff --git a/apps/dokploy/pages/api/stripe/webhook.ts b/apps/dokploy/pages/api/stripe/webhook.ts index ef8f90632..e495a40d4 100644 --- a/apps/dokploy/pages/api/stripe/webhook.ts +++ b/apps/dokploy/pages/api/stripe/webhook.ts @@ -9,6 +9,7 @@ import { sendInvoiceEmail, sendPaymentFailedEmail, } from "@/server/utils/stripe-notifications"; +import { sendTrialExpiringEmail } from "@/server/utils/trial-notifications"; const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!; @@ -72,6 +73,7 @@ export default async function handler( "invoice.payment_failed", "customer.deleted", "checkout.session.completed", + "customer.subscription.trial_will_end", ]; if (!webhooksAllowed.includes(event.type)) { @@ -110,6 +112,12 @@ export default async function handler( await updateServersBasedOnQuantity(admin.id, newServersQuantity); break; } + case "customer.subscription.trial_will_end": { + const trialingSubscription = event.data.object as Stripe.Subscription; + + await sendTrialExpiringEmail(stripe, trialingSubscription, event.created); + break; + } case "customer.subscription.created": { const newSubscription = event.data.object as Stripe.Subscription; @@ -176,7 +184,10 @@ export default async function handler( break; } - if (newSubscription.status === "active") { + if ( + newSubscription.status === "active" || + newSubscription.status === "trialing" + ) { const serversQuantity = getSubscriptionServersQuantity( newSubscription?.items?.data ?? [], ); @@ -209,9 +220,12 @@ export default async function handler( case "invoice.payment_succeeded": { const newInvoice = event.data.object as Stripe.Invoice; - const subscription = await stripe.subscriptions.retrieve( - newInvoice.subscription as string, - ); + const subscriptionId = getInvoiceSubscriptionId(newInvoice); + if (!subscriptionId) { + break; + } + + const subscription = await stripe.subscriptions.retrieve(subscriptionId); if (subscription.status !== "active") { console.log( @@ -220,6 +234,14 @@ export default async function handler( break; } + const admin = await findUserByStripeCustomerId( + subscription.customer as string, + ); + + if (!admin) { + return res.status(400).send("Webhook Error: Admin not found"); + } + const serversQuantity = getSubscriptionServersQuantity( subscription?.items?.data ?? [], ); @@ -233,18 +255,10 @@ export default async function handler( ), ); - const admin = await findUserByStripeCustomerId( - subscription.customer as string, - ); - - if (!admin) { - return res.status(400).send("Webhook Error: Admin not found"); - } if (admin.isEnterpriseCloud) { break; } - const newServersQuantity = admin.serversQuantity; - await updateServersBasedOnQuantity(admin.id, newServersQuantity); + await updateServersBasedOnQuantity(admin.id, serversQuantity); if (admin.sendInvoiceNotifications) { await sendInvoiceEmail(newInvoice, admin); @@ -255,9 +269,12 @@ export default async function handler( case "invoice.payment_failed": { const newInvoice = event.data.object as Stripe.Invoice; - const subscription = await stripe.subscriptions.retrieve( - newInvoice.subscription as string, - ); + const subscriptionId = getInvoiceSubscriptionId(newInvoice); + if (!subscriptionId) { + break; + } + + const subscription = await stripe.subscriptions.retrieve(subscriptionId); if (subscription.status !== "active") { const admin = await findUserByStripeCustomerId( newInvoice.customer as string, @@ -351,6 +368,12 @@ const findUserByStripeCustomerId = async (stripeCustomerId: string) => { return userResult; }; +const getInvoiceSubscriptionId = (invoice: Stripe.Invoice): string | null => { + const subscription = invoice.subscription; + if (typeof subscription === "string") return subscription; + return subscription?.id ?? null; +}; + const activateServer = async (serverId: string) => { await db .update(server) diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts index ce5e15151..944c86c8f 100644 --- a/apps/dokploy/server/api/routers/stripe.ts +++ b/apps/dokploy/server/api/routers/stripe.ts @@ -12,7 +12,7 @@ import { getCurrentPlan as getCurrentPlanForOrganization, getStripeClient, TRIAL_DURATION_DAYS, - TRIAL_SERVER_LIMIT, + TRIAL_SERVER_LIMITS, } from "@/server/utils/billing"; import { type BillingTier, @@ -45,72 +45,82 @@ export const stripeRouter = createTRPCRouter({ return getBillingStatus(ctx.user.ownerId); }), - startFreeTrial: adminProcedure.mutation(async ({ ctx }) => { - if (!IS_CLOUD) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This feature is only available in Dokploy Cloud", - }); - } - - if (!HOBBY_PRICE_MONTHLY_ID) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Trials are not configured", - }); - } - - const owner = await findUserById(ctx.user.ownerId); - const billingStatus = await getBillingStatus(owner.id); - - if (billingStatus.hasActiveAccess) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You already have an active plan or trial", - }); - } - - if (billingStatus.hasUsedTrial) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You have already used your free trial", - }); - } - - const stripe = getStripeClient(); - - let stripeCustomerId = owner.stripeCustomerId; - if (stripeCustomerId) { - const customer = await stripe.customers.retrieve(stripeCustomerId); - if (customer.deleted) { - stripeCustomerId = null; + startFreeTrial: adminProcedure + .input(z.object({ tier: z.enum(["hobby", "startup"]) })) + .mutation(async ({ ctx, input }) => { + if (!IS_CLOUD) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This feature is only available in Dokploy Cloud", + }); } - } - if (!stripeCustomerId) { - const customer = await stripe.customers.create({ email: owner.email }); - stripeCustomerId = customer.id; - } - const subscription = await stripe.subscriptions.create({ - customer: stripeCustomerId, - items: [{ price: HOBBY_PRICE_MONTHLY_ID, quantity: 1 }], - trial_period_days: TRIAL_DURATION_DAYS, - trial_settings: { end_behavior: { missing_payment_method: "cancel" } }, - metadata: { source: "onboarding_trial", adminId: owner.id }, - }); + const trialPriceId = + input.tier === "startup" + ? STARTUP_BASE_PRICE_MONTHLY_ID + : HOBBY_PRICE_MONTHLY_ID; + if (!trialPriceId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Trials are not configured", + }); + } - await updateUser(owner.id, { - stripeCustomerId, - stripeSubscriptionId: subscription.id, - serversQuantity: TRIAL_SERVER_LIMIT, - }); + const owner = await findUserById(ctx.user.ownerId); + const billingStatus = await getBillingStatus(owner.id); - return { - trialEndsAt: subscription.trial_end - ? new Date(subscription.trial_end * 1000) - : null, - }; - }), + if (billingStatus.hasActiveAccess) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You already have an active plan or trial", + }); + } + + if (billingStatus.hasUsedTrial) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You have already used your free trial", + }); + } + + const stripe = getStripeClient(); + + let stripeCustomerId = owner.stripeCustomerId; + if (stripeCustomerId) { + const customer = await stripe.customers.retrieve(stripeCustomerId); + if (customer.deleted) { + stripeCustomerId = null; + } + } + if (!stripeCustomerId) { + const customer = await stripe.customers.create({ email: owner.email }); + stripeCustomerId = customer.id; + } + + const subscription = await stripe.subscriptions.create({ + customer: stripeCustomerId, + items: [{ price: trialPriceId, quantity: 1 }], + trial_period_days: TRIAL_DURATION_DAYS, + trial_settings: { end_behavior: { missing_payment_method: "cancel" } }, + metadata: { + source: "onboarding_trial", + adminId: owner.id, + tier: input.tier, + }, + }); + + await updateUser(owner.id, { + stripeCustomerId, + stripeSubscriptionId: subscription.id, + serversQuantity: TRIAL_SERVER_LIMITS[input.tier], + }); + + return { + trialEndsAt: subscription.trial_end + ? new Date(subscription.trial_end * 1000) + : null, + }; + }), getProducts: adminProcedure.query(async ({ ctx }) => { const user = await findUserById(ctx.user.ownerId); @@ -343,9 +353,15 @@ export const stripeRouter = createTRPCRouter({ }); } + const isTrialing = subscription.status === "trialing"; + // Trials are capped at the plan's included servers; paid plans can scale. + const serverQuantity = isTrialing + ? TRIAL_SERVER_LIMITS[input.tier] + : input.serverQuantity; + const newItems = getStripeItems( input.tier as BillingTier, - input.serverQuantity, + serverQuantity, input.isAnnual, ); const currentItems = subscription.items.data; @@ -371,7 +387,7 @@ export const stripeRouter = createTRPCRouter({ await stripe.subscriptions.update(owner.stripeSubscriptionId, { items: updateItems, - proration_behavior: "create_prorations", + proration_behavior: isTrialing ? "none" : "create_prorations", }); return { ok: true }; diff --git a/apps/dokploy/server/utils/billing.ts b/apps/dokploy/server/utils/billing.ts index f2a98fa02..527ba4194 100644 --- a/apps/dokploy/server/utils/billing.ts +++ b/apps/dokploy/server/utils/billing.ts @@ -16,7 +16,7 @@ export const getStripeClient = () => apiVersion: "2024-09-30.acacia", }); -const planFromPriceIds = (priceIds: string[]): BillingPlan | null => { +export const planFromPriceIds = (priceIds: string[]): BillingPlan | null => { if ( priceIds.some( (id) => @@ -78,7 +78,11 @@ export const getCurrentPlan = async ( }; export const TRIAL_DURATION_DAYS = 7; -export const TRIAL_SERVER_LIMIT = 1; +export type TrialTier = "hobby" | "startup"; +export const TRIAL_SERVER_LIMITS: Record = { + hobby: 1, + startup: 3, +}; export interface BillingStatus { plan: BillingPlan | null; @@ -87,6 +91,7 @@ export interface BillingStatus { trialDaysRemaining: number | null; hasUsedTrial: boolean; hasActiveAccess: boolean; + hasPaymentMethod: boolean; } export const getBillingStatus = async ( @@ -100,6 +105,7 @@ export const getBillingStatus = async ( trialDaysRemaining: null, hasUsedTrial: false, hasActiveAccess: true, + hasPaymentMethod: true, }; } @@ -112,6 +118,7 @@ export const getBillingStatus = async ( trialDaysRemaining: null, hasUsedTrial: false, hasActiveAccess: false, + hasPaymentMethod: false, }; } @@ -119,7 +126,7 @@ export const getBillingStatus = async ( const subscriptions = await stripe.subscriptions.list({ customer: owner.stripeCustomerId, status: "all", - expand: ["data.items.data.price"], + expand: ["data.items.data.price", "data.customer"], }); const relevantSubs = subscriptions.data.filter( @@ -133,6 +140,12 @@ export const getBillingStatus = async ( const trialingSub = subscriptions.data.find( (sub) => sub.status === "trialing", ); + + const currentSub = trialingSub ?? relevantSubs[0]; + const customer = currentSub?.customer as Stripe.Customer | undefined; + const hasPaymentMethod = + !!currentSub?.default_payment_method || + !!customer?.invoice_settings?.default_payment_method; const trialEndsAt = trialingSub?.trial_end ? new Date(trialingSub.trial_end * 1000) : null; @@ -148,6 +161,7 @@ export const getBillingStatus = async ( isOnTrial: !!trialingSub, trialEndsAt, trialDaysRemaining, + hasPaymentMethod, hasUsedTrial: subscriptions.data.length > 0, hasActiveAccess: plan !== null || !!trialingSub, }; diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts new file mode 100644 index 000000000..1c7e0e515 --- /dev/null +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -0,0 +1,78 @@ +import { db } from "@dokploy/server/db"; +import TrialExpiringEmail from "@dokploy/server/emails/emails/trial-expiring"; +import { sendEmail } from "@dokploy/server/verification/send-verification-email"; +import { render } from "@react-email/components"; +import { format } from "date-fns"; +import { eq } from "drizzle-orm"; +import type Stripe from "stripe"; +import { user } from "@/server/db/schema"; +import { planFromPriceIds } from "@/server/utils/billing"; +import { WEBSITE_URL } from "@/server/utils/stripe"; + +const PLAN_LABELS: Record = { + hobby: "Hobby", + startup: "Startup", + legacy: "Legacy", +}; + +const customerIdOf = (subscription: Stripe.Subscription) => + typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer.id; + +const hasPaymentMethod = async ( + stripe: Stripe, + subscription: Stripe.Subscription, +) => { + if (subscription.default_payment_method) return true; + + const customer = await stripe.customers.retrieve(customerIdOf(subscription)); + if (customer.deleted) return true; + return !!customer.invoice_settings?.default_payment_method; +}; + +export const sendTrialExpiringEmail = async ( + stripe: Stripe, + subscription: Stripe.Subscription, + // Stripe retries a failing webhook for days, so the countdown is measured + // from when Stripe emitted the event rather than from the current time. + notifiedAtSeconds: number, +) => { + if (!subscription.trial_end) return false; + if (await hasPaymentMethod(stripe, subscription)) return false; + + const owner = await db.query.user.findFirst({ + where: eq(user.stripeCustomerId, customerIdOf(subscription)), + }); + if (!owner || owner.isEnterpriseCloud) return false; + + const trialEndsAt = new Date(subscription.trial_end * 1000); + const daysRemaining = Math.max( + 1, + Math.ceil((subscription.trial_end - notifiedAtSeconds) / (60 * 60 * 24)), + ); + const plan = planFromPriceIds( + subscription.items.data.map((item) => (item.price as Stripe.Price).id), + ); + + const htmlContent = await render( + TrialExpiringEmail({ + userName: owner.firstName || "User", + planName: plan ? (PLAN_LABELS[plan] ?? plan) : "Trial", + daysRemaining, + endsOn: format(trialEndsAt, "MMM dd, yyyy"), + billingUrl: `${WEBSITE_URL}/dashboard/settings/billing`, + }), + ); + + await sendEmail({ + email: owner.email, + subject: + daysRemaining === 1 + ? "Your Dokploy trial ends tomorrow" + : `Your Dokploy trial ends in ${daysRemaining} days`, + text: htmlContent, + }); + + return true; +}; diff --git a/packages/server/src/emails/emails/trial-expiring.tsx b/packages/server/src/emails/emails/trial-expiring.tsx new file mode 100644 index 000000000..68dd48c95 --- /dev/null +++ b/packages/server/src/emails/emails/trial-expiring.tsx @@ -0,0 +1,141 @@ +import { + Body, + Button, + Column, + Container, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Row, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { emailTailwindConfig } from "../tailwind-config"; + +export type TemplateProps = { + userName: string; + planName: string; + daysRemaining: number; + endsOn: string; + billingUrl: string; +}; + +export const TrialExpiringEmail = ({ + userName = "User", + planName = "Hobby", + daysRemaining = 3, + endsOn = "2024-01-01", + billingUrl = "https://app.dokploy.com/dashboard/settings/billing", +}: TemplateProps) => { + const dayLabel = daysRemaining === 1 ? "day" : "days"; + const previewText = + daysRemaining === 1 + ? "Your Dokploy trial ends tomorrow" + : `Your Dokploy trial ends in ${daysRemaining} ${dayLabel}`; + + return ( + + + {previewText} + + + +
+ Dokploy +
+ +
+ + {daysRemaining === 1 + ? "Your trial ends tomorrow" + : `Your trial ends in ${daysRemaining} ${dayLabel}`} + + + Hello {userName}, add a payment method to keep your servers and + applications running once the trial ends. + + +
+ + + + Plan + + + {planName} + + + + + Trial ends + + + {endsOn} + + + +
+ +
+ + Without a payment method your subscription is cancelled when + the trial ends and your servers are deactivated. + +
+ +
+ +
+ +
+ + + You are billed only when the trial ends. You can cancel at any + time before then. + +
+ +
+ + This is an automated email from{" "} + + Dokploy Cloud + + . If you have any questions about your billing, please contact + our{" "} + + support team + + . + +
+
+ +
+ + ); +}; + +export default TrialExpiringEmail; From f2ba262499025f22913c77461f12476eadc5d5b1 Mon Sep 17 00:00:00 2001 From: Saleh Alghusson Date: Fri, 18 Sep 2026 13:26:27 -0500 Subject: [PATCH 17/31] feat(ai): add Perplexity integration attribution --- packages/server/src/utils/ai/select-ai-provider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/utils/ai/select-ai-provider.ts b/packages/server/src/utils/ai/select-ai-provider.ts index 2d20075e5..e50cbe55f 100644 --- a/packages/server/src/utils/ai/select-ai-provider.ts +++ b/packages/server/src/utils/ai/select-ai-provider.ts @@ -65,6 +65,7 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) { baseURL: config.apiUrl, headers: { Authorization: `Bearer ${config.apiKey}`, + "X-Pplx-Integration": "dokploy", }, }); case "mistral": From 143dfcafe3e2fd9a8804f991362b9060b3135f50 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Mon, 21 Sep 2026 16:01:50 +0530 Subject: [PATCH 18/31] fix: correct add-invitation form reset logic and select state --- .../dashboard/settings/users/add-invitation.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index 1c8952d43..7dbe5ac66 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -124,8 +124,11 @@ export const AddInvitation = () => { const mode = form.watch("mode"); useEffect(() => { - form.reset(); - }, [form, form.formState.isSubmitSuccessful, form.reset]); + if (!open) { + form.reset(); + setError(null); + } + }, [open, form, form.reset]); useEffect(() => { if (isCloud && form.getValues("mode") === "credentials") { @@ -225,7 +228,7 @@ export const AddInvitation = () => { Invite Method From c453ac3a2f491c753d6fbdc3e25ba935c525bb97 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Wed, 23 Sep 2026 15:06:54 -0600 Subject: [PATCH 19/31] fix(billing): allow switching billing interval during trial Switching plan mid-trial always forced the monthly price and there was no way to move to annual. Preserve trial_end on the Stripe update so changing the interval does not reset the billing cycle and end the trial early. --- .../settings/billing/show-billing.tsx | 75 +++++++++++++------ apps/dokploy/server/api/routers/stripe.ts | 3 + apps/dokploy/server/utils/billing.ts | 7 ++ 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index b66a23ca4..fbb85695b 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -111,14 +111,20 @@ export const ShowBilling = () => { const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null); const [switchingTrial, setSwitchingTrial] = useState(false); - const handleSwitchTrialPlan = async (tier: "hobby" | "startup") => { - const planName = tier === "startup" ? "Startup" : "Hobby"; + const handleSwitchTrialPlan = async ( + tier: "hobby" | "startup", + isAnnualPlan: boolean, + label?: string, + ) => { + const planName = + label ?? + `${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`; const switchPlan = async () => { await upgradeSubscription({ tier, serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1, - isAnnual: false, + isAnnual: isAnnualPlan, }); await new Promise((resolve) => setTimeout(resolve, 3000)); await Promise.all([ @@ -133,7 +139,7 @@ export const ShowBilling = () => { await toast .promise(switchPlan(), { loading: `Switching to ${planName}...`, - success: `Your trial is now on the ${planName} plan`, + success: `Your trial is now on ${planName}`, error: (error) => error instanceof Error ? error.message : "Error switching plan", }) @@ -336,6 +342,11 @@ export const ShowBilling = () => { {billingStatus.plan ?? "Trial"} + {billingStatus.plan && ( + + {billingStatus.isAnnual ? "Annual" : "Monthly"} + + )}
{billingStatus.isOnTrial @@ -356,23 +367,45 @@ export const ShowBilling = () => {
{billingStatus.isOnTrial && useNewPricing && - billingStatus.plan && ( - + billingStatus.plan && + billingStatus.plan !== "legacy" && ( + <> + + + )}