From f5c21db1ef47b47f0a1bf3180eaed60373f13c4b Mon Sep 17 00:00:00 2001 From: Souvik Kumar Date: Sun, 9 Aug 2026 21:05:37 +0530 Subject: [PATCH 01/85] 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 1f1acacf585238f9a8f86f8b2a9ad1cb23834deb Mon Sep 17 00:00:00 2001 From: Bunlong Heng Date: Wed, 12 Aug 2026 17:38:48 -0400 Subject: [PATCH 02/85] fix(settings): confine Traefik file read/write to the Traefik config directory --- apps/dokploy/server/api/routers/settings.ts | 33 +++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index db2951ae4..7ae1a757a 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { CLEANUP_CRON_JOB, checkGPUStatus, @@ -81,6 +82,32 @@ import { publicProcedure, } from "../trpc"; +/** + * Confines a caller-supplied Traefik file path to the Traefik configuration + * directory (MAIN_TRAEFIK_PATH). The read/write Traefik file endpoints only + * expose files listed under this directory, but the raw path was passed + * straight to the filesystem helpers, letting a user with the `traefikFiles` + * permission read or overwrite arbitrary files on the host (for example + * `/etc/dokploy/.env` or `~/.ssh/authorized_keys`) via an absolute path or + * `../` traversal. Reject anything that escapes the directory. + */ +const resolveTraefikFilePath = ( + filePath: string, + serverId?: string | null, +): string => { + const { MAIN_TRAEFIK_PATH } = paths(!!serverId); + const base = path.resolve(MAIN_TRAEFIK_PATH); + const resolved = path.resolve(base, filePath); + if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Invalid path: file must be inside the Traefik configuration directory", + }); + } + return resolved; +}; + export const settingsRouter = createTRPCRouter({ getWebServerSettings: protectedProcedure.query(async () => { if (IS_CLOUD) { @@ -598,8 +625,9 @@ export const settingsRouter = createTRPCRouter({ .input(apiModifyTraefikConfig) .mutation(async ({ input, ctx }) => { await checkPermission(ctx, { traefikFiles: ["write"] }); + const safePath = resolveTraefikFilePath(input.path, input?.serverId); await writeTraefikConfigInPath( - input.path, + safePath, input.traefikConfig, input?.serverId, ); @@ -624,7 +652,8 @@ export const settingsRouter = createTRPCRouter({ } } - return readConfigInPath(input.path, input.serverId); + const safePath = resolveTraefikFilePath(input.path, input.serverId); + return readConfigInPath(safePath, input.serverId); }), getIp: protectedProcedure.query(async () => { if (IS_CLOUD) { From 341f38bba66e92bb4be70ee6f46f79910218dd95 Mon Sep 17 00:00:00 2001 From: Reza Rahemtola Date: Fri, 4 Sep 2026 21:29:07 +0200 Subject: [PATCH 03/85] fix(notifications): add missing newline to ntfy database backup message --- packages/server/src/utils/notifications/database-backup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/utils/notifications/database-backup.ts b/packages/server/src/utils/notifications/database-backup.ts index fb96dee48..7a838ff46 100644 --- a/packages/server/src/utils/notifications/database-backup.ts +++ b/packages/server/src/utils/notifications/database-backup.ts @@ -196,7 +196,7 @@ export const sendDatabaseBackupNotifications = async ({ `πŸ› Project: ${projectName}\n` + `βš™οΈApplication: ${applicationName}\n` + `❔Type: ${databaseType}\n` + - `πŸ“‚Database Name: ${databaseName}` + + `πŸ“‚Database Name: ${databaseName}\n` + `πŸ•’Date: ${date.toLocaleString()}\n` + `${type === "error" && errorMessage ? `❌Error:\n${errorMessage}` : ""}`, ); From f25810fa7273aeb944b541da3024c09be51250d8 Mon Sep 17 00:00:00 2001 From: Julian Speckmann Date: Sun, 6 Sep 2026 02:11:29 +0200 Subject: [PATCH 04/85] Update Railpack versions in show.tsx Added new Railpack versions to the list. --- .../dashboard/application/build/show.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/apps/dokploy/components/dashboard/application/build/show.tsx b/apps/dokploy/components/dashboard/application/build/show.tsx index ac2531f94..e55db5524 100644 --- a/apps/dokploy/components/dashboard/application/build/show.tsx +++ b/apps/dokploy/components/dashboard/application/build/show.tsx @@ -31,6 +31,45 @@ import { api } from "@/utils/api"; // Railpack versions from https://github.com/railwayapp/railpack/releases export const RAILPACK_VERSIONS = [ + "0.39.0", + "0.38.0", + "0.37.1", + "0.37.0", + "0.36.4", + "0.36.3", + "0.36.2", + "0.36.1", + "0.36.0", + "0.35.0", + "0.34.0", + "0.33.0", + "0.32.0", + "0.31.2", + "0.31.1", + "0.31.0", + "0.30.1", + "0.30.0", + "0.29.0", + "0.28.0", + "0.27.2", + "0.27.1", + "0.27.0", + "0.26.1", + "0.26.0", + "0.25.0", + "0.24.0", + "0.23.0", + "0.22.2", + "0.22.1", + "0.22.0", + "0.21.0", + "0.20.0", + "0.19.0", + "0.18.0", + "0.17.2", + "0.17.1", + "0.17.0", + "0.16.0", "0.15.4", "0.15.3", "0.15.2", @@ -45,12 +84,22 @@ export const RAILPACK_VERSIONS = [ "0.9.1", "0.9.0", "0.8.0", + "0.7.2", + "0.7.1", "0.7.0", + "0.6.1", "0.6.0", + "0.5.1", "0.5.0", "0.4.0", "0.3.0", + "0.2.3", "0.2.2", + "0.2.1", + "0.2.0", + "0.1.2", + "0.1.1", + "0.1.0", ] as const; export enum BuildType { From 96778433dba43da0dc2b639c79f6e2d3236de1cd Mon Sep 17 00:00:00 2001 From: Maks Oleksyuk Date: Mon, 7 Sep 2026 10:42:08 +0300 Subject: [PATCH 05/85] chore(ci): update GitHub Actions to latest versions and fix zizmor findings --- .github/workflows/create-pr.yml | 28 ++--- .github/workflows/deploy.yml | 47 ++++---- .github/workflows/dokploy.yml | 102 ++++++++++-------- .github/workflows/format.yml | 9 +- .github/workflows/hotfix-cherry-pick.yml | 6 +- .github/workflows/hotfix-release.yml | 6 +- .github/workflows/monitoring.yml | 47 ++++---- .github/workflows/pull-request.yml | 8 +- .github/workflows/sync-openapi-docs.yml | 11 +- .../workflows/upgrade-integration-test.yml | 7 +- 10 files changed, 164 insertions(+), 107 deletions(-) diff --git a/.github/workflows/create-pr.yml b/.github/workflows/create-pr.yml index 248b98d5a..7cf6fa136 100644 --- a/.github/workflows/create-pr.yml +++ b/.github/workflows/create-pr.yml @@ -14,34 +14,34 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Get version from package.json - run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV + run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV" - name: Get latest GitHub tag run: | LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1) - echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - echo $LATEST_TAG + echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV" + echo "$LATEST_TAG" - name: Compare versions run: | - if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then + if [ "$VERSION" != "$LATEST_TAG" ]; then VERSION_CHANGED="true" else VERSION_CHANGED="false" fi - echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV + echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV" echo "Comparing versions:" - echo "Current version: ${{ env.VERSION }}" - echo "Latest tag: ${{ env.LATEST_TAG }}" + echo "Current version: $VERSION" + echo "Latest tag: $LATEST_TAG" echo "Version changed: $VERSION_CHANGED" - name: Check if a PR already exists run: | PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length') - echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV + echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV" env: GH_TOKEN: ${{ secrets.GH_PAT }} @@ -56,12 +56,12 @@ jobs: git push origin canary gh pr create \ - --title "πŸš€ Release ${{ env.VERSION }}" \ - --body ' - This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}. + --title "πŸš€ Release $VERSION" \ + --body " + This PR promotes changes from \`canary\` to \`main\` for version $VERSION. ### πŸ” Changes Include: - - Version bump to ${{ env.VERSION }} + - Version bump to $VERSION - All changes from canary branch ### βœ… Pre-merge Checklist: @@ -69,7 +69,7 @@ jobs: - [ ] Documentation updated - [ ] Docker images built and tested - > πŸ€– This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \ + > πŸ€– This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \ --base main \ --head canary \ --label "release" --label "automated pr" || true \ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 321fb2029..49428320e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,33 +5,38 @@ on: branches: [main, canary] workflow_dispatch: +permissions: + contents: read + jobs: build-and-push-cloud-image: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set tag and version id: meta-cloud run: | VERSION=$(jq -r .version apps/dokploy/package.json) - echo "version=$VERSION" >> $GITHUB_OUTPUT - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if [ "$GITHUB_REF" = "refs/heads/main" ]; then + echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> "$GITHUB_OUTPUT" else - echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT + echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT" fi - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.cloud @@ -48,26 +53,28 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set tag and version id: meta-schedule run: | VERSION=$(jq -r .version apps/dokploy/package.json) - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT + if [ "$GITHUB_REF" = "refs/heads/main" ]; then + echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> "$GITHUB_OUTPUT" else - echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT + echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT" fi - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.schedule @@ -80,26 +87,28 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set tag and version id: meta-server run: | VERSION=$(jq -r .version apps/dokploy/package.json) - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT + if [ "$GITHUB_REF" = "refs/heads/main" ]; then + echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> "$GITHUB_OUTPUT" else - echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT + echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT" fi - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.server diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index 08d6b8a5b..175543205 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -8,18 +8,23 @@ on: env: IMAGE_NAME: dokploy/dokploy +permissions: + contents: read + jobs: docker-amd: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -27,15 +32,15 @@ jobs: - name: Set tag and version id: meta run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" VERSION=$(node -p "require('./apps/dokploy/package.json').version") - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi - echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT + echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT" - name: Prepare env file run: | @@ -43,7 +48,7 @@ jobs: cp apps/dokploy/.env.production.example apps/dokploy/.env.production - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64 @@ -53,13 +58,15 @@ jobs: runs-on: ubuntu-24.04-arm steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -68,15 +75,15 @@ jobs: id: meta run: | VERSION=$(node -p "require('./apps/dokploy/package.json').version") - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" VERSION=$(node -p "require('./apps/dokploy/package.json').version") - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi - echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT + echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT" - name: Prepare env file run: | @@ -84,7 +91,7 @@ jobs: cp apps/dokploy/.env.production.example apps/dokploy/.env.production - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . platforms: linux/arm64 @@ -96,20 +103,22 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create and push manifests run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then VERSION=$(node -p "require('./apps/dokploy/package.json').version") TAG="latest" @@ -121,7 +130,7 @@ jobs: ${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-arm64 - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ @@ -138,32 +147,37 @@ jobs: needs: [combine-manifests] if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + permissions: + contents: write outputs: version: ${{ steps.get_version.outputs.version }} npm_version: ${{ steps.get_version.outputs.npm_version }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 + persist-credentials: false - name: Get version id: get_version run: | VERSION=$(node -p "require('./apps/dokploy/package.json').version") - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT" - name: Fetch install.sh + env: + RELEASE_VERSION: ${{ steps.get_version.outputs.version }} run: | curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; } grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; } - { head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh + { head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh mv install-pinned.sh install.sh - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.get_version.outputs.version }} target_commitish: ${{ github.sha }} @@ -179,15 +193,19 @@ jobs: needs: [generate-release] if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + env: + NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: version: 10.22.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 24.4.0 cache: pnpm @@ -202,63 +220,63 @@ jobs: git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo cd /tmp/mcp-repo - jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - cp ${{ github.workspace }}/openapi.json src/generated/openapi.json + cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + git commit -m "chore: bump version to $NPM_VERSION" \ + -m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ --allow-empty git push - echo "βœ… MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}" + echo "βœ… MCP repo synced to version $NPM_VERSION" - name: Sync version to CLI repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo cd /tmp/cli-repo - jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - cp ${{ github.workspace }}/openapi.json ./openapi.json + cp $GITHUB_WORKSPACE/openapi.json ./openapi.json pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + git commit -m "chore: bump version to $NPM_VERSION" \ + -m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ --allow-empty git push - echo "βœ… CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}" + echo "βœ… CLI repo synced to version $NPM_VERSION" - name: Sync version to SDK repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo cd /tmp/sdk-repo - jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - cp ${{ github.workspace }}/openapi.json ./openapi.json + cp $GITHUB_WORKSPACE/openapi.json ./openapi.json pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + git commit -m "chore: bump version to $NPM_VERSION" \ + -m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ --allow-empty git push - echo "βœ… SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}" + echo "βœ… SDK repo synced to version $NPM_VERSION" diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 48589575f..fd0823a7a 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -6,15 +6,20 @@ on: pull_request: branches: [canary] +permissions: + contents: read + jobs: format: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup biomeJs - uses: biomejs/setup-biome@v2 + uses: biomejs/setup-biome@v2 - name: Run Biome formatter run: biome format --write diff --git a/.github/workflows/hotfix-cherry-pick.yml b/.github/workflows/hotfix-cherry-pick.yml index c85e118d6..30a3dda7c 100644 --- a/.github/workflows/hotfix-cherry-pick.yml +++ b/.github/workflows/hotfix-cherry-pick.yml @@ -16,17 +16,19 @@ jobs: contents: write steps: - name: Checkout main - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: main fetch-depth: 0 token: ${{ secrets.HOTFIX_PUSH_TOKEN }} - name: Cherry-pick fix to main + env: + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" - SHA="${{ github.event.pull_request.merge_commit_sha }}" + SHA="$MERGE_SHA" if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then git cherry-pick -x -m 1 "$SHA" else diff --git a/.github/workflows/hotfix-release.yml b/.github/workflows/hotfix-release.yml index 7e473b3d2..3271df861 100644 --- a/.github/workflows/hotfix-release.yml +++ b/.github/workflows/hotfix-release.yml @@ -7,12 +7,16 @@ concurrency: group: hotfix-to-main cancel-in-progress: false +# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token. +permissions: + contents: read + jobs: release: runs-on: ubuntu-latest steps: - name: Checkout main - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: main token: ${{ secrets.HOTFIX_PUSH_TOKEN }} diff --git a/.github/workflows/monitoring.yml b/.github/workflows/monitoring.yml index 378b019d4..df0a70e0a 100644 --- a/.github/workflows/monitoring.yml +++ b/.github/workflows/monitoring.yml @@ -7,18 +7,23 @@ on: env: IMAGE_NAME: dokploy/monitoring +permissions: + contents: read + jobs: docker-amd: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -26,17 +31,17 @@ jobs: - name: Set tag id: meta run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi - echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT + echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT" - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.monitoring @@ -47,13 +52,15 @@ jobs: runs-on: ubuntu-24.04-arm steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -61,17 +68,17 @@ jobs: - name: Set id: meta run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi - echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT + echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT" - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.monitoring @@ -84,27 +91,29 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create and push manifests run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-arm64 - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 227022a90..d1cd96364 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -14,9 +14,11 @@ jobs: matrix: job: [build, test, typecheck] steps: - - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 with: node-version: 24.4.0 cache: "pnpm" diff --git a/.github/workflows/sync-openapi-docs.yml b/.github/workflows/sync-openapi-docs.yml index 147d8d97e..f378f7dca 100644 --- a/.github/workflows/sync-openapi-docs.yml +++ b/.github/workflows/sync-openapi-docs.yml @@ -12,17 +12,20 @@ on: workflow_dispatch: +permissions: + contents: read + jobs: generate-and-commit: name: Generate OpenAPI and commit to Dokploy repo runs-on: ubuntu-latest steps: - name: Checkout Dokploy repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: - token: ${{ secrets.GITHUB_TOKEN }} - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + persist-credentials: false + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 with: node-version: 24.4.0 cache: "pnpm" diff --git a/.github/workflows/upgrade-integration-test.yml b/.github/workflows/upgrade-integration-test.yml index f73b7276e..eea581175 100644 --- a/.github/workflows/upgrade-integration-test.yml +++ b/.github/workflows/upgrade-integration-test.yml @@ -28,6 +28,9 @@ env: DOKPLOY_SERVICE: dokploy DOKPLOY_PORT: 3000 +permissions: + contents: read + # ────────────────────────────────────────────────────────────────────────────── jobs: @@ -421,8 +424,10 @@ jobs: # ── Job summary ──────────────────────────────────────────────────────── - name: Write job summary if: always() + env: + JOB_STATUS: ${{ job.status }} run: | - STATUS="${{ job.status }}" + STATUS="$JOB_STATUS" ICON="βœ…"; [ "$STATUS" != "success" ] && ICON="❌" cat >> "$GITHUB_STEP_SUMMARY" < Date: Thu, 10 Sep 2026 10:26:52 +0300 Subject: [PATCH 06/85] fix(vault): read Infisical secrets pulled in through an import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #5413. A secret brought into a folder with "Import Secrets" was reported as not found. Two reasons, and the first is easy to miss: The list endpoint takes the flag in **snake_case**. `includeImports` is silently ignored β€” the request still returns 200, with `imports` present but empty β€” so the imported secret looked like it simply did not exist. Measured against app.infisical.com with a registered import (confirmed via GET /api/v1/secret-imports): expandSecretReferences=true imports[] empty expandSecretReferences=true&includeImports=true imports[] empty expandSecretReferences=true&include_imports=true imports[] has the secret Second, imported secrets never appear in `secrets` β€” they come back in a separate `imports` array, one entry per source path, which the client did not read at all. Imported entries are merged before the folder's own, so a name defined in both resolves to the local value, matching how Infisical resolves it. Also measured, for whoever looks next: `/api/v4/secrets` returns imports with no flag at all, and single-secret reads (`/raw/{name}`) never see an imported key β€” 404 on v3, no such route on v4. So a folder listing is the only way to reach them. vault.test.ts: 57 passed. Reverting the fix fails the new merge test. Co-Authored-By: Claude Opus 5 (1M context) --- apps/dokploy/__test__/env/vault.test.ts | 44 ++++++++++++++++++++ packages/server/src/utils/vault/infisical.ts | 14 +++++++ 2 files changed, 58 insertions(+) diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts index 98fd7e4d9..eff5634ef 100644 --- a/apps/dokploy/__test__/env/vault.test.ts +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -472,6 +472,50 @@ describe("infisical client", () => { expect(params.get("secretPath")).toBe("/frontend"); }); + it("asks for imported secrets and merges them", async () => { + mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( + jsonResponse({ + secrets: [], + imports: [ + { + secretPath: "/shared", + secrets: [ + { secretKey: "DB_URL", secretValue: "postgres://imported" }, + ], + }, + ], + }), + ); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + expect(result).toEqual({ DB_URL: "postgres://imported" }); + const [listUrl] = mockFetch.mock.calls[1] as [string]; + // snake_case on purpose: `includeImports` is silently ignored and the + // response comes back with an empty `imports` array. + expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true"); + }); + + it("lets a folder's own secret win over an imported one of the same name", async () => { + mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( + jsonResponse({ + secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }], + imports: [ + { + secretPath: "/shared", + secrets: [ + { secretKey: "DB_URL", secretValue: "postgres://imported" }, + ], + }, + ], + }), + ); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + expect(result).toEqual({ DB_URL: "postgres://local" }); + }); + it("throws a clear error for a missing secret", async () => { mockFetch .mockResolvedValueOnce(loginResponse()) diff --git a/packages/server/src/utils/vault/infisical.ts b/packages/server/src/utils/vault/infisical.ts index 9af3bca2f..7696d15ab 100644 --- a/packages/server/src/utils/vault/infisical.ts +++ b/packages/server/src/utils/vault/infisical.ts @@ -79,6 +79,12 @@ const readPath = async ( // still reports success. Single secrets read via /raw/{name} expand by // default, which makes the difference easy to miss in the UI. expandSecretReferences: "true", + // Secrets pulled in through "Import Secrets" are not part of `secrets`: + // they come back in a separate `imports` array, and only when this is + // asked for. Note the snake_case β€” `includeImports` is silently ignored + // (the request still returns 200 with an empty `imports`), which is why + // an imported secret looked like it simply did not exist. + include_imports: "true", }); const response = await vaultFetch( `${baseUrl(config)}/api/v3/secrets/raw?${params.toString()}`, @@ -93,9 +99,17 @@ const readPath = async ( const body = (await response.json()) as { secrets?: { secretKey: string; secretValue: string }[]; + imports?: { secrets?: { secretKey: string; secretValue: string }[] }[]; }; const secrets: Record = {}; + // Imported first, then the folder's own: Infisical resolves a name defined + // in both in favour of the local one, so writing local last preserves that. + for (const imported of body.imports ?? []) { + for (const secret of imported.secrets ?? []) { + secrets[secret.secretKey] = secret.secretValue; + } + } for (const secret of body.secrets ?? []) { secrets[secret.secretKey] = secret.secretValue; } From e70c880612db27544be6d37500b3e3d4894233a8 Mon Sep 17 00:00:00 2001 From: Artur Spatari Date: Thu, 10 Sep 2026 10:32:09 +0300 Subject: [PATCH 07/85] test(vault): lock the precedence between two imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review flagged that multi-import collisions were untested. The order the code implements is the documented one β€” Infisical: "If two imports carry a secret with the same name, the value from the bottom-most import wins" β€” and the response lists imports in that order, so sequential assignment matches it. Added a case with two imports defining the same key. Reversing the merge order in the client fails it, so the test pins the behaviour rather than restating the implementation. vault.test.ts: 58 passed. Co-Authored-By: Claude Opus 5 (1M context) --- apps/dokploy/__test__/env/vault.test.ts | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts index eff5634ef..b1381cd05 100644 --- a/apps/dokploy/__test__/env/vault.test.ts +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -496,6 +496,33 @@ describe("infisical client", () => { expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true"); }); + it("lets the later import win when two define the same key", async () => { + mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( + jsonResponse({ + secrets: [], + imports: [ + { + secretPath: "/base", + secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }], + }, + { + secretPath: "/override", + secrets: [ + { secretKey: "DB_URL", secretValue: "postgres://override" }, + ], + }, + ], + }), + ); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + // Infisical documents this order: "If two imports carry a secret with the + // same name, the value from the bottom-most import wins." The response + // lists imports in that order, so assigning them in sequence matches it. + expect(result).toEqual({ DB_URL: "postgres://override" }); + }); + it("lets a folder's own secret win over an imported one of the same name", async () => { mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( jsonResponse({ From 4cd4b835232141303d13276cf06f4b177b627e4f Mon Sep 17 00:00:00 2001 From: Narciso Date: Thu, 10 Sep 2026 18:23:38 -0400 Subject: [PATCH 08/85] 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 8075a7ce95fdb92a40e28b6f008dfb2cdc0e12ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Thu, 10 Sep 2026 18:40:09 -0400 Subject: [PATCH 09/85] Merge pull request #5358 from KingIronMan2011/patch-1 feat: added missing Railpack versions (cherry picked from commit 326908869dbb99e397e0b81956cfb092157a0eca) [skip ci] --- .../dashboard/application/build/show.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/apps/dokploy/components/dashboard/application/build/show.tsx b/apps/dokploy/components/dashboard/application/build/show.tsx index ac2531f94..e55db5524 100644 --- a/apps/dokploy/components/dashboard/application/build/show.tsx +++ b/apps/dokploy/components/dashboard/application/build/show.tsx @@ -31,6 +31,45 @@ import { api } from "@/utils/api"; // Railpack versions from https://github.com/railwayapp/railpack/releases export const RAILPACK_VERSIONS = [ + "0.39.0", + "0.38.0", + "0.37.1", + "0.37.0", + "0.36.4", + "0.36.3", + "0.36.2", + "0.36.1", + "0.36.0", + "0.35.0", + "0.34.0", + "0.33.0", + "0.32.0", + "0.31.2", + "0.31.1", + "0.31.0", + "0.30.1", + "0.30.0", + "0.29.0", + "0.28.0", + "0.27.2", + "0.27.1", + "0.27.0", + "0.26.1", + "0.26.0", + "0.25.0", + "0.24.0", + "0.23.0", + "0.22.2", + "0.22.1", + "0.22.0", + "0.21.0", + "0.20.0", + "0.19.0", + "0.18.0", + "0.17.2", + "0.17.1", + "0.17.0", + "0.16.0", "0.15.4", "0.15.3", "0.15.2", @@ -45,12 +84,22 @@ export const RAILPACK_VERSIONS = [ "0.9.1", "0.9.0", "0.8.0", + "0.7.2", + "0.7.1", "0.7.0", + "0.6.1", "0.6.0", + "0.5.1", "0.5.0", "0.4.0", "0.3.0", + "0.2.3", "0.2.2", + "0.2.1", + "0.2.0", + "0.1.2", + "0.1.1", + "0.1.0", ] as const; export enum BuildType { 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 10/85] 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 11/85] 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 12/85] 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 13/85] 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 14/85] 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 15/85] 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 a87a5c06f0755465be8e09586676ccbb66bbc338 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 14 Sep 2026 11:01:47 -0600 Subject: [PATCH 16/85] feat: account deletion for Dokploy Cloud Self-service deletion from Settings > Profile confirmed with the account password (when present) plus a 6-digit code sent by email, and a root-only "delete by email" tool in the impersonation bar to fulfil GDPR requests. Both paths run the same flow: cancel every Stripe subscription (customer is kept for invoicing), delete the user and all owned organizations in one transaction, anonymize audit trail left in other organizations, reassign pending invitations to the org owner, remove the HubSpot contact when HUBSPOT_ACCESS_TOKEN is configured, and log a JSON deletion record. Also blocks better-auth's raw /admin/remove-user endpoint and makes the Stripe webhook tolerate subscription/customer events for already deleted users. --- .../__test__/utils/account-deletion.test.ts | 182 +++++++++++++++ .../impersonation/delete-account-by-email.tsx | 143 ++++++++++++ .../impersonation/impersonation-bar.tsx | 6 +- .../settings/profile/delete-account.tsx | 220 ++++++++++++++++++ apps/dokploy/pages/api/stripe/webhook.ts | 14 +- .../pages/dashboard/settings/profile.tsx | 2 + apps/dokploy/server/api/routers/user.ts | 146 +++++++++++- apps/dokploy/server/utils/account-deletion.ts | 44 ++++ apps/dokploy/server/utils/billing.ts | 33 +++ .../emails/emails/account-deletion-code.tsx | 87 +++++++ packages/server/src/index.ts | 1 + packages/server/src/lib/auth.ts | 6 + .../server/src/services/account-deletion.ts | 179 ++++++++++++++ packages/server/src/services/user.ts | 10 + packages/server/src/utils/tracking/hubspot.ts | 45 ++++ .../verification/send-verification-email.tsx | 26 +++ 16 files changed, 1122 insertions(+), 22 deletions(-) create mode 100644 apps/dokploy/__test__/utils/account-deletion.test.ts create mode 100644 apps/dokploy/components/dashboard/impersonation/delete-account-by-email.tsx create mode 100644 apps/dokploy/components/dashboard/settings/profile/delete-account.tsx create mode 100644 apps/dokploy/server/utils/account-deletion.ts create mode 100644 packages/server/src/emails/emails/account-deletion-code.tsx create mode 100644 packages/server/src/services/account-deletion.ts diff --git a/apps/dokploy/__test__/utils/account-deletion.test.ts b/apps/dokploy/__test__/utils/account-deletion.test.ts new file mode 100644 index 000000000..b83ad0b7f --- /dev/null +++ b/apps/dokploy/__test__/utils/account-deletion.test.ts @@ -0,0 +1,182 @@ +import { + checkDeletionCode, + generateDeletionCode, + serializeDeletionCode, +} from "@dokploy/server/services/account-deletion"; +import { deleteHubSpotContactByEmail } from "@dokploy/server/utils/tracking/hubspot"; +import Stripe from "stripe"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { cancelStripeSubscriptions } from "@/server/utils/billing"; + +const makeStripe = ( + subscriptions: Array<{ id: string; status: string }>, + listError?: Error, +) => { + const stripe = { + subscriptions: { + list: vi.fn(() => + listError + ? Promise.reject(listError) + : Promise.resolve({ data: subscriptions }), + ), + cancel: vi.fn(() => Promise.resolve({})), + }, + }; + return stripe as unknown as Stripe & typeof stripe; +}; + +describe("cancelStripeSubscriptions", () => { + test("cancels every non-terminal subscription without invoicing", async () => { + const stripe = makeStripe([ + { id: "sub_active", status: "active" }, + { id: "sub_trial", status: "trialing" }, + { id: "sub_past_due", status: "past_due" }, + { id: "sub_canceled", status: "canceled" }, + { id: "sub_expired", status: "incomplete_expired" }, + ]); + + const result = await cancelStripeSubscriptions("cus_1", stripe); + + expect(stripe.subscriptions.list).toHaveBeenCalledWith({ + customer: "cus_1", + status: "all", + limit: 100, + }); + expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(3); + expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_active", { + invoice_now: false, + prorate: false, + }); + expect(result).toEqual({ + cancelledSubscriptions: ["sub_active", "sub_trial", "sub_past_due"], + }); + }); + + test("treats a missing customer as having nothing to cancel", async () => { + const missing = new Stripe.errors.StripeInvalidRequestError({ + type: "invalid_request_error", + code: "resource_missing", + message: "No such customer", + }); + const stripe = makeStripe([], missing); + + const result = await cancelStripeSubscriptions("cus_missing", stripe); + + expect(stripe.subscriptions.cancel).not.toHaveBeenCalled(); + expect(result).toEqual({ cancelledSubscriptions: [] }); + }); + + test("propagates unexpected Stripe errors", async () => { + const failure = new Stripe.errors.StripeAPIError({ + type: "api_error", + message: "boom", + }); + const stripe = makeStripe([], failure); + + await expect(cancelStripeSubscriptions("cus_1", stripe)).rejects.toBe( + failure, + ); + }); +}); + +describe("deleteHubSpotContactByEmail", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("is skipped when no access token is configured", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + expect(await deleteHubSpotContactByEmail("a@b.com", undefined)).toBe( + "skipped", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("calls the GDPR delete endpoint with the email as id", async () => { + const fetchMock = vi.fn(() => + Promise.resolve(new Response(null, { status: 204 })), + ); + vi.stubGlobal("fetch", fetchMock); + + expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe( + "deleted", + ); + const [url, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url).toBe( + "https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete", + ); + expect(init.method).toBe("POST"); + expect((init.headers as Record).Authorization).toBe( + "Bearer token", + ); + expect(JSON.parse(init.body as string)).toEqual({ + idProperty: "email", + objectId: "a@b.com", + }); + }); + + test("maps 404 to not_found and other errors to failed", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))), + ); + expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe( + "not_found", + ); + + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response("nope", { status: 500 }))), + ); + expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe( + "failed", + ); + }); +}); + +describe("account deletion codes", () => { + test("generates six-digit numeric codes", () => { + for (let i = 0; i < 50; i++) { + expect(generateDeletionCode()).toMatch(/^\d{6}$/); + } + }); + + test("accepts the right code, ignoring surrounding whitespace", () => { + const value = serializeDeletionCode("482913"); + + expect(checkDeletionCode(value, "482913")).toEqual({ + valid: true, + attempts: 0, + }); + expect(checkDeletionCode(value, " 482913 ").valid).toBe(true); + }); + + test("counts failed attempts without revealing the hash", () => { + const value = serializeDeletionCode("482913"); + + const first = checkDeletionCode(value, "000000"); + expect(first.valid).toBe(false); + if (first.valid) throw new Error("unreachable"); + expect(first.attempts).toBe(1); + expect(JSON.parse(first.nextValue)).toEqual({ + ...JSON.parse(value), + attempts: 1, + }); + + const second = checkDeletionCode(first.nextValue, "999999"); + expect(second.valid).toBe(false); + if (second.valid) throw new Error("unreachable"); + expect(second.attempts).toBe(2); + + expect(checkDeletionCode(second.nextValue, "482913")).toEqual({ + valid: true, + attempts: 2, + }); + expect(value).not.toContain("482913"); + }); +}); diff --git a/apps/dokploy/components/dashboard/impersonation/delete-account-by-email.tsx b/apps/dokploy/components/dashboard/impersonation/delete-account-by-email.tsx new file mode 100644 index 000000000..5f940dd5b --- /dev/null +++ b/apps/dokploy/components/dashboard/impersonation/delete-account-by-email.tsx @@ -0,0 +1,143 @@ +import copy from "copy-to-clipboard"; +import { Copy, Loader2, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; + +const CONFIRMATION_WORD = "DELETE"; + +export const DeleteAccountByEmail = () => { + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [record, setRecord] = useState(null); + const { mutateAsync, isPending } = + api.user.deleteAccountByEmail.useMutation(); + + const canConfirm = + email.trim().length > 0 && confirmation.trim() === CONFIRMATION_WORD; + + const reset = () => { + setEmail(""); + setConfirmation(""); + setRecord(null); + }; + + const handleDelete = async () => { + try { + const result = await mutateAsync({ email: email.trim() }); + setRecord(JSON.stringify(result, null, 2)); + toast.success(`Account ${result.email} deleted`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deleting account", + ); + } + }; + + return ( + { + setOpen(value); + if (!value) reset(); + }} + > + + + + + + Delete an account by email + + Cancels every Stripe subscription and deletes the user with all the + organizations they own. Invoices stay in Stripe for accounting. Use + it to fulfil data deletion requests. + + + {record ? ( +
+ + Deletion completed. Keep this record as evidence of the request + being fulfilled. + +
+							{record}
+						
+ +
+ ) : ( +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setConfirmation(e.target.value)} + /> +
+
+ )} + + {record ? ( + + ) : ( + + )} + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx index c6c72fae6..5ab262ec8 100644 --- a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx +++ b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx @@ -46,6 +46,7 @@ import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling"; +import { DeleteAccountByEmail } from "./delete-account-by-email"; type User = typeof authClient.$Infer.Session.user; @@ -78,12 +79,12 @@ export const ImpersonationBar = () => { }); const filteredUsers = response.data?.users.filter( - // @ts-ignore + // @ts-expect-error (user) => user.allowImpersonation && data?.user?.email !== user.email, ); if (!response.error) { - // @ts-ignore + // @ts-expect-error setUsers(filteredUsers || []); } } catch (error) { @@ -282,6 +283,7 @@ export const ImpersonationBar = () => { Impersonate +
) : (
diff --git a/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx b/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx new file mode 100644 index 000000000..9c18831ee --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx @@ -0,0 +1,220 @@ +import { Loader2, Trash2 } from "lucide-react"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth-client"; +import { api } from "@/utils/api"; + +const errorMessage = (error: unknown, fallback: string) => + error instanceof Error ? error.message : fallback; + +export const DeleteAccount = () => { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [step, setStep] = useState<"request" | "confirm">("request"); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [expiresInMinutes, setExpiresInMinutes] = useState(10); + const { data } = api.user.get.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: hasPassword } = api.user.hasPassword.useQuery(); + const requestCode = api.user.requestAccountDeletionCode.useMutation(); + const deleteAccount = api.user.deleteAccount.useMutation(); + + const email = data?.user?.email ?? ""; + const isBusy = requestCode.isPending || deleteAccount.isPending; + + if ( + !data || + hasPassword === undefined || + (!isCloud && data.role === "owner") + ) { + return null; + } + + const reset = () => { + setStep("request"); + setPassword(""); + setCode(""); + }; + + const handleRequestCode = async () => { + try { + const result = await requestCode.mutateAsync( + hasPassword ? { password } : {}, + ); + setExpiresInMinutes(result.expiresInMinutes); + setCode(""); + setStep("confirm"); + toast.success(`We sent a confirmation code to ${email}`); + } catch (error) { + toast.error(errorMessage(error, "Error sending the confirmation code")); + } + }; + + const handleDelete = async () => { + try { + await deleteAccount.mutateAsync({ code }); + toast.success("Your account has been deleted"); + await authClient.signOut().catch(() => undefined); + router.push("/"); + } catch (error) { + toast.error(errorMessage(error, "Error deleting account")); + } + }; + + return ( + + + + + Delete account + + + Permanently remove your account and all the data associated with it. + + + +
    + {isCloud && ( +
  • + Active subscriptions are cancelled immediately. Invoices already + issued are kept for accounting. +
  • + )} +
  • + Every organization you own is deleted, including its projects, + services, environments, domains and server connections. +
  • +
  • + In organizations you belong to but do not own, only your membership, + API keys and the Git providers you connected are removed. + Applications using those providers keep running but must be + reconnected to redeploy. +
  • +
  • Containers already running on your servers are not touched.
  • +
  • + We will email a confirmation code to{" "} + {email} before + anything is deleted. +
  • +
  • This action cannot be undone.
  • +
+
+ { + setOpen(next); + if (!next) reset(); + }} + > + + + + + + Delete your account? + + {step === "request" + ? `This will permanently delete your account and every organization you own. We will send a confirmation code to ${email}.` + : `Enter the ${expiresInMinutes}-minute code we sent to ${email} to confirm the deletion.`} + + +
+ {step === "request" && hasPassword && ( + <> + + setPassword(e.target.value)} + /> + + )} + {step === "confirm" && ( + <> + + + setCode(e.target.value.replace(/\D/g, "")) + } + /> + + + )} +
+ + Cancel + {step === "request" ? ( + + ) : ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/pages/api/stripe/webhook.ts b/apps/dokploy/pages/api/stripe/webhook.ts index ef8f90632..9cd16e5d9 100644 --- a/apps/dokploy/pages/api/stripe/webhook.ts +++ b/apps/dokploy/pages/api/stripe/webhook.ts @@ -150,11 +150,9 @@ export default async function handler( newSubscription.customer as string, ); - if (!admin) { - return res.status(400).send("Webhook Error: Admin not found"); - } - - if (admin.isEnterpriseCloud) { + // The account may already be gone when the deletion flow cancelled + // the subscription; nothing left to disable. + if (!admin || admin.isEnterpriseCloud) { break; } @@ -297,11 +295,7 @@ export default async function handler( const customer = event.data.object as Stripe.Customer; const admin = await findUserByStripeCustomerId(customer.id); - if (!admin) { - return res.status(400).send("Webhook Error: Admin not found"); - } - - if (admin.isEnterpriseCloud) { + if (!admin || admin.isEnterpriseCloud) { break; } diff --git a/apps/dokploy/pages/dashboard/settings/profile.tsx b/apps/dokploy/pages/dashboard/settings/profile.tsx index 95ff7aef2..a767658f3 100644 --- a/apps/dokploy/pages/dashboard/settings/profile.tsx +++ b/apps/dokploy/pages/dashboard/settings/profile.tsx @@ -5,6 +5,7 @@ import type { ReactElement } from "react"; import superjson from "superjson"; import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys"; import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account"; +import { DeleteAccount } from "@/components/dashboard/settings/profile/delete-account"; import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { appRouter } from "@/server/api/root"; @@ -20,6 +21,7 @@ const Page = () => { {isCloud && } {permissions?.api.read && } +
); diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 0dd06c6e0..10d55cacd 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -1,6 +1,10 @@ import { + ACCOUNT_DELETION_CODE_TTL_MINUTES, + consumeAccountDeletionCode, + createAccountDeletionCode, createApiKey, createOrganizationUserWithCredentials, + findCredentialAccount, findNotificationById, findOrganizationById, findPasskeysByUserId, @@ -11,6 +15,7 @@ import { IS_CLOUD, removeUserById, renderInvitationEmail, + sendAccountDeletionCodeEmail, sendEmailNotification, sendResendNotification, updateUser, @@ -24,6 +29,7 @@ import { apiUpdateUser, invitation, member, + organization, session, user, } from "@dokploy/server/db/schema"; @@ -38,6 +44,7 @@ import { and, asc, desc, eq, gt, ne } from "drizzle-orm"; import { z } from "zod"; import { apiKeyNameSchema } from "@/lib/api-keys"; import { audit } from "@/server/api/utils/audit"; +import { deleteAccount } from "@/server/utils/account-deletion"; import { adminProcedure, createTRPCRouter, @@ -63,6 +70,43 @@ const apiCreateApiKey = z.object({ refillInterval: z.number().optional(), }); +const hasRootAccess = (ctx: { + user: { id: string }; + session: { impersonatedBy?: string } | null; +}) => { + const rootId = process.env.USER_ADMIN_ID; + if (!IS_CLOUD || !rootId) { + return false; + } + return rootId === ctx.user.id || ctx.session?.impersonatedBy === rootId; +}; + +const assertCanDeleteOwnAccount = async (ctx: { + user: { id: string }; + session: { impersonatedBy?: string }; +}) => { + if (ctx.session.impersonatedBy) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Accounts cannot be deleted while impersonating", + }); + } + + if (!IS_CLOUD) { + const ownedOrganization = await db.query.organization.findFirst({ + where: eq(organization.ownerId, ctx.user.id), + columns: { id: true }, + }); + if (ownedOrganization) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Transfer or delete the organizations you own before deleting your account", + }); + } + } +}; + export const userRouter = createTRPCRouter({ all: withPermission("member", "read").query(async ({ ctx }) => { return await db.query.member.findMany({ @@ -178,16 +222,7 @@ export const userRouter = createTRPCRouter({ return findPasskeysByUserId(ctx.user.id); }), haveRootAccess: protectedProcedure.query(async ({ ctx }) => { - if (!IS_CLOUD) { - return false; - } - if ( - process.env.USER_ADMIN_ID === ctx.user.id || - ctx.session?.impersonatedBy === process.env.USER_ADMIN_ID - ) { - return true; - } - return false; + return hasRootAccess(ctx); }), getBackups: adminProcedure.query(async ({ ctx }) => { const memberResult = await db.query.member.findFirst({ @@ -458,6 +493,97 @@ export const userRouter = createTRPCRouter({ }); return result; }), + hasPassword: protectedProcedure.query(async ({ ctx }) => { + const credential = await findCredentialAccount(ctx.user.id); + return !!credential?.password; + }), + requestAccountDeletionCode: protectedProcedure + .input(z.object({ password: z.string().optional() })) + .mutation(async ({ ctx, input }) => { + await assertCanDeleteOwnAccount(ctx); + + const credential = await findCredentialAccount(ctx.user.id); + if ( + credential?.password && + !bcrypt.compareSync(input.password ?? "", credential.password) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The password is incorrect", + }); + } + + const code = await createAccountDeletionCode(ctx.user.id); + try { + await sendAccountDeletionCodeEmail({ + email: ctx.user.email, + userName: ctx.user.name || ctx.user.email, + code, + expiresInMinutes: ACCOUNT_DELETION_CODE_TTL_MINUTES, + }); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "The confirmation email could not be sent", + }); + } + + return { expiresInMinutes: ACCOUNT_DELETION_CODE_TTL_MINUTES }; + }), + deleteAccount: protectedProcedure + .input(z.object({ code: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + await assertCanDeleteOwnAccount(ctx); + + const { requestedAt } = await consumeAccountDeletionCode( + ctx.user.id, + input.code, + ); + + await audit(ctx, { + action: "delete", + resourceType: "user", + resourceId: ctx.user.id, + metadata: { selfService: true }, + }); + + return deleteAccount(ctx.user.id, ctx.user.id, { requestedAt }); + }), + deleteAccountByEmail: protectedProcedure + .input(z.object({ email: z.string().email() })) + .mutation(async ({ ctx, input }) => { + if (!hasRootAccess(ctx)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the root account can delete accounts by email", + }); + } + + const target = await db.query.user.findFirst({ + where: eq(user.email, input.email.trim().toLowerCase()), + columns: { id: true }, + }); + + if (!target) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "No account found with that email", + }); + } + + if (target.id === process.env.USER_ADMIN_ID) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The root account cannot be deleted", + }); + } + + return deleteAccount( + target.id, + ctx.session.impersonatedBy ?? ctx.user.id, + ); + }), assignPermissions: withPermission("member", "update") .input(apiAssignPermissions) .mutation(async ({ input, ctx }) => { diff --git a/apps/dokploy/server/utils/account-deletion.ts b/apps/dokploy/server/utils/account-deletion.ts new file mode 100644 index 000000000..cd6ce4689 --- /dev/null +++ b/apps/dokploy/server/utils/account-deletion.ts @@ -0,0 +1,44 @@ +import { + deleteHubSpotContactByEmail, + deleteUserAccountData, + findUserById, + IS_CLOUD, +} from "@dokploy/server"; +import { cancelStripeSubscriptions } from "./billing"; + +export const deleteAccount = async ( + userId: string, + requestedBy: string, + options?: { requestedAt?: Date | null }, +) => { + const target = await findUserById(userId); + const startedAt = new Date().toISOString(); + + const stripe = + IS_CLOUD && target.stripeCustomerId + ? { + customerId: target.stripeCustomerId, + ...(await cancelStripeSubscriptions(target.stripeCustomerId)), + } + : null; + + const { organizationsDeleted } = await deleteUserAccountData(userId); + + const hubspot = IS_CLOUD + ? await deleteHubSpotContactByEmail(target.email) + : "skipped"; + + const record = { + userId, + email: target.email, + requestedBy, + requestedAt: options?.requestedAt?.toISOString() ?? startedAt, + startedAt, + completedAt: new Date().toISOString(), + organizationsDeleted, + stripe, + hubspot, + }; + + return record; +}; diff --git a/apps/dokploy/server/utils/billing.ts b/apps/dokploy/server/utils/billing.ts index f2a98fa02..0faeace7f 100644 --- a/apps/dokploy/server/utils/billing.ts +++ b/apps/dokploy/server/utils/billing.ts @@ -152,3 +152,36 @@ export const getBillingStatus = async ( hasActiveAccess: plan !== null || !!trialingSub, }; }; + +const isMissingResource = (error: unknown) => + error instanceof Stripe.errors.StripeError && + error.code === "resource_missing"; + +export const cancelStripeSubscriptions = async ( + stripeCustomerId: string, + stripe: Stripe = getStripeClient(), +) => { + const subscriptions = await stripe.subscriptions + .list({ customer: stripeCustomerId, status: "all", limit: 100 }) + .catch((error: unknown) => { + if (isMissingResource(error)) return null; + throw error; + }); + + const cancelledSubscriptions: string[] = []; + for (const subscription of subscriptions?.data ?? []) { + if ( + subscription.status === "canceled" || + subscription.status === "incomplete_expired" + ) { + continue; + } + await stripe.subscriptions.cancel(subscription.id, { + invoice_now: false, + prorate: false, + }); + cancelledSubscriptions.push(subscription.id); + } + + return { cancelledSubscriptions }; +}; diff --git a/packages/server/src/emails/emails/account-deletion-code.tsx b/packages/server/src/emails/emails/account-deletion-code.tsx new file mode 100644 index 000000000..e86f00813 --- /dev/null +++ b/packages/server/src/emails/emails/account-deletion-code.tsx @@ -0,0 +1,87 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { emailTailwindConfig } from "../tailwind-config"; + +export type TemplateProps = { + userName: string; + code: string; + expiresInMinutes: number; +}; + +export const AccountDeletionCodeTemplate = ({ + userName = "User", + code = "123456", + expiresInMinutes = 10, +}: TemplateProps) => { + const previewText = `${code} is your account deletion code`; + return ( + + + {previewText} + + + +
+ Dokploy +
+ +
+ + Confirm your account deletion + + + Hello {userName}, we received a request to permanently delete + your Dokploy account. Enter the code below to confirm it. + + +
+ + {code} + +
+ + + This code expires in {expiresInMinutes} minutes. Once confirmed, + your subscriptions are cancelled and every organization you own + is deleted. This cannot be undone. + +
+ +
+ + If you did not request this, someone else may have access to + your account. Change your password and contact{" "} + + Dokploy support + + . + +
+
+ +
+ + ); +}; + +export default AccountDeletionCodeTemplate; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index cd7bef41f..ad862de46 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -7,6 +7,7 @@ export * from "./db/validations/index"; export * from "./lib/auth"; export * from "./lib/logger"; export * from "./monitoring/utils"; +export * from "./services/account-deletion"; export * from "./services/admin"; export * from "./services/application"; export * from "./services/backup"; diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index 10d62a5b0..752a8112f 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -126,6 +126,12 @@ const createBetterAuth = () => ...(await resolveTrustedOrigins()), ].filter(Boolean); + if (ctx.path === "/admin/remove-user") { + throw new APIError("FORBIDDEN", { + message: "Use the account deletion flow to remove users", + }); + } + const isBlockedAuthPath = ctx.path.startsWith("/sign-in/email") || ctx.path.startsWith("/sign-in/social") || diff --git a/packages/server/src/services/account-deletion.ts b/packages/server/src/services/account-deletion.ts new file mode 100644 index 000000000..48eb966f0 --- /dev/null +++ b/packages/server/src/services/account-deletion.ts @@ -0,0 +1,179 @@ +import { createHash, randomInt, timingSafeEqual } from "node:crypto"; +import { db } from "@dokploy/server/db"; +import { + auditLog, + backups, + invitation, + organization, + user, + verification, +} from "@dokploy/server/db/schema"; +import { TRPCError } from "@trpc/server"; +import { eq, inArray } from "drizzle-orm"; +import { nanoid } from "nanoid"; + +export const ANONYMIZED_EMAIL = "deleted-user"; +export const ACCOUNT_DELETION_CODE_TTL_MINUTES = 10; +const ACCOUNT_DELETION_CODE_RESEND_SECONDS = 60; +const ACCOUNT_DELETION_CODE_MAX_ATTEMPTS = 5; + +const codeIdentifier = (userId: string) => `account-deletion:${userId}`; + +const hashCode = (code: string) => + createHash("sha256").update(code.trim()).digest("hex"); + +export const generateDeletionCode = () => + randomInt(0, 1_000_000).toString().padStart(6, "0"); + +export const serializeDeletionCode = (code: string) => + JSON.stringify({ hash: hashCode(code), attempts: 0 }); + +export const checkDeletionCode = (value: string, code: string) => { + const stored = JSON.parse(value) as { hash: string; attempts: number }; + const expected = Buffer.from(stored.hash, "hex"); + const actual = Buffer.from(hashCode(code), "hex"); + + if (expected.length === actual.length && timingSafeEqual(expected, actual)) { + return { valid: true as const, attempts: stored.attempts }; + } + + const attempts = stored.attempts + 1; + return { + valid: false as const, + attempts, + nextValue: JSON.stringify({ ...stored, attempts }), + }; +}; + +export const createAccountDeletionCode = async (userId: string) => { + const identifier = codeIdentifier(userId); + const existing = await db.query.verification.findFirst({ + where: eq(verification.identifier, identifier), + }); + + if ( + existing?.createdAt && + Date.now() - existing.createdAt.getTime() < + ACCOUNT_DELETION_CODE_RESEND_SECONDS * 1000 + ) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: `Wait ${ACCOUNT_DELETION_CODE_RESEND_SECONDS} seconds before requesting a new code`, + }); + } + + await db.delete(verification).where(eq(verification.identifier, identifier)); + + const code = generateDeletionCode(); + const now = new Date(); + await db.insert(verification).values({ + id: nanoid(), + identifier, + value: serializeDeletionCode(code), + expiresAt: new Date( + now.getTime() + ACCOUNT_DELETION_CODE_TTL_MINUTES * 60_000, + ), + createdAt: now, + updatedAt: now, + }); + + return code; +}; + +export const consumeAccountDeletionCode = async ( + userId: string, + code: string, +) => { + const row = await db.query.verification.findFirst({ + where: eq(verification.identifier, codeIdentifier(userId)), + }); + + if (!row) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Request a confirmation code first", + }); + } + + if (row.expiresAt.getTime() < Date.now()) { + await db.delete(verification).where(eq(verification.id, row.id)); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The code has expired, request a new one", + }); + } + + const result = checkDeletionCode(row.value, code); + if (!result.valid) { + if (result.attempts >= ACCOUNT_DELETION_CODE_MAX_ATTEMPTS) { + await db.delete(verification).where(eq(verification.id, row.id)); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Too many incorrect attempts, request a new code", + }); + } + await db + .update(verification) + .set({ value: result.nextValue, updatedAt: new Date() }) + .where(eq(verification.id, row.id)); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The code is incorrect", + }); + } + + await db.delete(verification).where(eq(verification.id, row.id)); + + return { requestedAt: row.createdAt }; +}; + +export const deleteUserAccountData = async (userId: string) => { + return db.transaction(async (tx) => { + const target = await tx.query.user.findFirst({ + where: eq(user.id, userId), + columns: { id: true, email: true }, + }); + + if (!target) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + const ownedOrganizations = await tx.query.organization.findMany({ + where: eq(organization.ownerId, userId), + columns: { id: true }, + }); + const organizationIds = ownedOrganizations.map((org) => org.id); + + if (organizationIds.length > 0) { + await tx + .delete(auditLog) + .where(inArray(auditLog.organizationId, organizationIds)); + } + + // Actions performed in organizations owned by someone else stay in + // their audit trail, but without the deleted user's PII. + await tx + .update(auditLog) + .set({ userEmail: ANONYMIZED_EMAIL }) + .where(eq(auditLog.userId, userId)); + + await tx.delete(invitation).where(eq(invitation.email, target.email)); + + // backups.userId has no ON DELETE clause, so a leftover reference would + // abort the user delete. + await tx + .update(backups) + .set({ userId: null }) + .where(eq(backups.userId, userId)); + + await tx.delete(user).where(eq(user.id, userId)); + + return { + email: target.email, + organizationsDeleted: organizationIds.length, + }; + }); +}; diff --git a/packages/server/src/services/user.ts b/packages/server/src/services/user.ts index dbc78c275..f34352b2a 100644 --- a/packages/server/src/services/user.ts +++ b/packages/server/src/services/user.ts @@ -566,3 +566,13 @@ export const createApiKey = async ( return result; }; + +export const findCredentialAccount = async (userId: string) => { + return db.query.account.findFirst({ + where: and( + eq(account.userId, userId), + eq(account.providerId, "credential"), + ), + columns: { id: true, password: true }, + }); +}; diff --git a/packages/server/src/utils/tracking/hubspot.ts b/packages/server/src/utils/tracking/hubspot.ts index d22a9591c..43d0a60aa 100644 --- a/packages/server/src/utils/tracking/hubspot.ts +++ b/packages/server/src/utils/tracking/hubspot.ts @@ -123,3 +123,48 @@ export async function submitToHubSpot( return false; } } + +export type HubSpotDeletionResult = + | "deleted" + | "not_found" + | "skipped" + | "failed"; + +export async function deleteHubSpotContactByEmail( + email: string, + accessToken = process.env.HUBSPOT_ACCESS_TOKEN, +): Promise { + if (!accessToken) { + return "skipped"; + } + + try { + const response = await fetch( + "https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete", + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ idProperty: "email", objectId: email }), + }, + ); + + if (response.ok) { + return "deleted"; + } + if (response.status === 404) { + return "not_found"; + } + console.error( + "HubSpot GDPR delete error:", + response.status, + await response.text(), + ); + return "failed"; + } catch (error) { + console.error("Error deleting HubSpot contact:", error); + return "failed"; + } +} diff --git a/packages/server/src/verification/send-verification-email.tsx b/packages/server/src/verification/send-verification-email.tsx index 3b6eb1fbb..5c4bee84b 100644 --- a/packages/server/src/verification/send-verification-email.tsx +++ b/packages/server/src/verification/send-verification-email.tsx @@ -1,4 +1,5 @@ import { render } from "@react-email/components"; +import AccountDeletionCodeTemplate from "../emails/emails/account-deletion-code"; import InvitationEmail from "../emails/emails/invitation"; import VerifyEmailTemplate from "../emails/emails/verify-email"; import { sendEmailNotification } from "../utils/notifications/utils"; @@ -91,3 +92,28 @@ export const sendInvitationEmail = async ({ text: html, }); }; + +export const sendAccountDeletionCodeEmail = async ({ + email, + userName, + code, + expiresInMinutes, +}: { + email: string; + userName: string; + code: string; + expiresInMinutes: number; +}) => { + const html = await render( + AccountDeletionCodeTemplate({ + userName: userName || "User", + code, + expiresInMinutes, + }), + ); + await sendEmail({ + email, + subject: `${code} is your account deletion code`, + text: html, + }); +}; From 318ad36967af64379b63c70176228a735581197c Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 14 Sep 2026 11:05:26 -0600 Subject: [PATCH 17/85] feat: restrict self-service account deletion to cloud organization owners --- .../settings/profile/delete-account.tsx | 16 ++++-------- apps/dokploy/server/api/routers/user.ts | 26 +++++++++++-------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx b/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx index 9c18831ee..18a9f1bd2 100644 --- a/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx @@ -44,11 +44,7 @@ export const DeleteAccount = () => { const email = data?.user?.email ?? ""; const isBusy = requestCode.isPending || deleteAccount.isPending; - if ( - !data || - hasPassword === undefined || - (!isCloud && data.role === "owner") - ) { + if (!isCloud || !data || data.role !== "owner" || hasPassword === undefined) { return null; } @@ -96,12 +92,10 @@ export const DeleteAccount = () => {
    - {isCloud && ( -
  • - Active subscriptions are cancelled immediately. Invoices already - issued are kept for accounting. -
  • - )} +
  • + Active subscriptions are cancelled immediately. Invoices already + issued are kept for accounting. +
  • Every organization you own is deleted, including its projects, services, environments, domains and server connections. diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 10d55cacd..576548737 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -85,6 +85,13 @@ const assertCanDeleteOwnAccount = async (ctx: { user: { id: string }; session: { impersonatedBy?: string }; }) => { + if (!IS_CLOUD) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This feature is only available in Dokploy Cloud", + }); + } + if (ctx.session.impersonatedBy) { throw new TRPCError({ code: "FORBIDDEN", @@ -92,18 +99,15 @@ const assertCanDeleteOwnAccount = async (ctx: { }); } - if (!IS_CLOUD) { - const ownedOrganization = await db.query.organization.findFirst({ - where: eq(organization.ownerId, ctx.user.id), - columns: { id: true }, + const ownedOrganization = await db.query.organization.findFirst({ + where: eq(organization.ownerId, ctx.user.id), + columns: { id: true }, + }); + if (!ownedOrganization) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only organization owners can delete their account", }); - if (ownedOrganization) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Transfer or delete the organizations you own before deleting your account", - }); - } } }; From 95b2712c26ac5dd3fc0d94e16a8edbbe1a6a8f7e Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:35:27 -0600 Subject: [PATCH 18/85] 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 19/85] 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 20/85] 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 21/85] 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 22/85] 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 23/85] 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 24/85] 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 25/85] 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 26/85] 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 27/85] 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 28/85] 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" && ( + <> + + + )}