From 341f38bba66e92bb4be70ee6f46f79910218dd95 Mon Sep 17 00:00:00 2001 From: Reza Rahemtola Date: Fri, 4 Sep 2026 21:29:07 +0200 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 06/17] 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 07/17] 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 08/17] 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 09/17] 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 95b2712c26ac5dd3fc0d94e16a8edbbe1a6a8f7e Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:35:27 -0600 Subject: [PATCH 10/17] 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 11/17] 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 12/17] 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 13/17] 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 14/17] 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 15/17] 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 16/17] 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 17/17] 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;