[
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: Build and Deploy\n\non:\n  push:\n    branches: [ \"**\" ]\n    tags:\n      - 'v*'\n      - 'be*'\n      - 'fe*'\n  workflow_dispatch:\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Set environment variables based on branch\n        run: |\n          echo \"ENV=dev\" >> $GITHUB_ENV\n          echo \"VITE_API_BASE=http://localhost:8080\" >> $GITHUB_ENV\n          echo \"VITE_CANONICAL=http://localhost:8080\" >> $GITHUB_ENV\n          echo \"VITE_OG_URL=http://localhost:8080\" >> $GITHUB_ENV\n\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Set up JDK 25\n        uses: actions/setup-java@v4\n        with:\n          java-version: '25'\n          distribution: 'temurin'\n          cache: 'maven'\n\n      - name: Build with Maven\n        run: ./mvnw clean package\n\n  build-backend:\n    name: Build and Push Docker Images to YC CR\n    needs: build\n    if: startsWith(github.ref, 'refs/tags/be') || startsWith(github.ref, 'refs/tags/fe')\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Set environment variables based on branch\n        run: |\n          echo \"ENV=prod\" >> $GITHUB_ENV\n          echo \"VITE_API_BASE=https://portbuddy.dev\" >> $GITHUB_ENV\n          echo \"VITE_CANONICAL=https://portbuddy.dev\" >> $GITHUB_ENV\n          echo \"VITE_OG_URL=https://portbuddy.dev\" >> $GITHUB_ENV\n\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Set up JDK 25\n        uses: actions/setup-java@v4\n        with:\n          java-version: '25'\n          distribution: 'temurin'\n          cache: 'maven'\n\n      - name: Build with Maven\n        run: ./mvnw clean package -pl web -pl eureka -pl gateway -pl net-proxy -pl server -pl ssl-service -am -DskipTests\n\n      - name: Login to Yandex Cloud Container Registry\n        id: login-cr\n        uses: yc-actions/yc-cr-login@v3\n        with:\n          yc-sa-json-credentials: ${{ secrets.YC_CR_JSON_CREDS }}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build and push Server\n        uses: docker/build-push-action@v6\n        if: startsWith(github.ref, 'refs/tags/be')\n        with:\n          context: .\n          file: Dockerfile-server\n          platforms: linux/amd64\n          push: true\n          tags: cr.yandex/${{ vars.CR_REGISTRY }}/port-buddy-server:latest\n\n      - name: Build and push Gateway\n        uses: docker/build-push-action@v6\n        if: startsWith(github.ref, 'refs/tags/be')\n        with:\n          context: .\n          file: Dockerfile-gateway\n          platforms: linux/amd64\n          push: true\n          tags: cr.yandex/${{ vars.CR_REGISTRY }}/port-buddy-gateway:latest\n\n      - name: Build and push Eureka\n        uses: docker/build-push-action@v6\n        if: startsWith(github.ref, 'refs/tags/be')\n        with:\n          context: .\n          file: Dockerfile-eureka\n          platforms: linux/amd64\n          push: true\n          tags: cr.yandex/${{ vars.CR_REGISTRY }}/port-buddy-eureka:latest\n\n      - name: Build and push Net Proxy\n        uses: docker/build-push-action@v6\n        if: startsWith(github.ref, 'refs/tags/be')\n        with:\n          context: .\n          file: Dockerfile-net-proxy\n          platforms: linux/amd64\n          push: true\n          tags: cr.yandex/${{ vars.CR_REGISTRY }}/port-buddy-net-proxy:latest\n\n      - name: Build and push SSL Service\n        uses: docker/build-push-action@v6\n        if: startsWith(github.ref, 'refs/tags/be')\n        with:\n          context: .\n          file: Dockerfile-ssl-service\n          platforms: linux/amd64\n          push: true\n          tags: cr.yandex/${{ vars.CR_REGISTRY }}/port-buddy-ssl-service:latest\n\n      - name: Build and push Web App\n        uses: docker/build-push-action@v6\n        if: startsWith(github.ref, 'refs/tags/fe')\n        with:\n          context: .\n          file: Dockerfile-web\n          platforms: linux/amd64\n          push: true\n          tags: cr.yandex/${{ vars.CR_REGISTRY }}/port-buddy-web-app:latest\n\n  build-portbuddy-docker:\n    name: Build and Push CLI Images\n    needs: build\n    if: startsWith(github.ref, 'refs/tags/v')\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Extract version\n        id: extract_version\n        run: |\n          if [[ $GITHUB_REF == refs/tags/v* ]]; then\n            echo \"VERSION=${GITHUB_REF#refs/tags/v}\" >> $GITHUB_OUTPUT\n          else\n            echo \"VERSION=latest\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Set up JDK 25\n        uses: actions/setup-java@v4\n        with:\n          java-version: '25'\n          distribution: 'temurin'\n          cache: 'maven'\n\n      - name: Build with Maven\n        run: ./mvnw clean package -pl cli -am -DskipTests\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Build and push CLI\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: Dockerfile-cli\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: |\n            portbuddy/portbuddy:${{ steps.extract_version.outputs.VERSION }}\n            portbuddy/portbuddy:latest\n\n  build-portbuddy-native:\n    name: Build Native CLI (${{ matrix.os }})\n    needs: build\n    if: startsWith(github.ref, 'refs/tags/v')\n    permissions:\n      contents: write\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - os: ubuntu-latest\n            asset_name: portbuddy-linux-x64\n            executable_path: cli/target/portbuddy\n          - os: ubuntu-24.04-arm\n            asset_name: portbuddy-linux-arm64\n            executable_path: cli/target/portbuddy\n          - os: macos-15-intel\n            asset_name: portbuddy-macos-x64\n            executable_path: cli/target/portbuddy\n          - os: macos-latest\n            asset_name: portbuddy-macos-arm64\n            executable_path: cli/target/portbuddy\n          - os: windows-latest\n            asset_name: portbuddy-windows-x64.exe\n            executable_path: cli/target/portbuddy.exe\n    runs-on: ${{ matrix.os }}\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Extract version\n        id: extract_version\n        shell: bash\n        run: |\n          if [[ $GITHUB_REF == refs/tags/v* ]]; then\n            echo \"VERSION=${GITHUB_REF#refs/tags/v}\" >> $GITHUB_OUTPUT\n          else\n            echo \"VERSION=latest\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Set up MSVC\n        if: matrix.os == 'windows-latest'\n        uses: ilammy/msvc-dev-cmd@v1\n\n      - name: Set up GraalVM\n        uses: actions/setup-java@v4\n        with:\n          java-version: '25'\n          distribution: 'graalvm'\n          cache: 'maven'\n\n      - name: Build with Maven (Native Image)\n        run: ./mvnw clean package -pl cli -am -DskipTests -Pnative\n\n      - name: Prepare Artifact\n        id: prepare_artifact\n        shell: bash\n        run: |\n          cp ${{ matrix.executable_path }} ${{ matrix.asset_name }}\n          if command -v shasum &> /dev/null; then\n            SHA=$(shasum -a 256 ${{ matrix.asset_name }} | awk '{print $1}')\n          elif command -v sha256sum &> /dev/null; then\n            SHA=$(sha256sum ${{ matrix.asset_name }} | awk '{print $1}')\n          else\n            echo \"No shasum or sha256sum found\"\n            exit 1\n          fi\n          echo \"sha256=$SHA\" >> $GITHUB_OUTPUT\n\n      - name: Release\n        uses: softprops/action-gh-release@v2\n        with:\n          files: ${{ matrix.asset_name }}\n          tag_name: ${{ steps.extract_version.outputs.VERSION }}\n          name: Release ${{ steps.extract_version.outputs.VERSION }}\n          draft: false\n          prerelease: false\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n  update-homebrew:\n    name: Update Homebrew Tap\n    needs: build-portbuddy-native\n    if: startsWith(github.ref, 'refs/tags/v')\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout Homebrew Tap\n        uses: actions/checkout@v4\n        with:\n          repository: amak-tech/homebrew-tap # REPLACE with your actual tap repository\n          token: ${{ secrets.HOMEBREW_TAP_TOKEN }} # Needs a PAT with 'repo' scope\n\n      - name: Extract version\n        id: extract_version\n        run: echo \"VERSION=${GITHUB_REF#refs/tags/v}\" >> $GITHUB_OUTPUT\n\n      - name: Update Formula\n        run: |\n          # Use values from build-native-cli outputs\n          # Note: Accessing matrix outputs by job ID is complex. \n          # An easier way is to download them from the release if they are already there.\n          # Or since we are in the same workflow, we can use 'gh release download'\n          \n          gh release download ${{ steps.extract_version.outputs.VERSION }} -p \"portbuddy-*\" --repo ${{ github.repository }}\n          \n          if command -v shasum &> /dev/null; then\n            MAC_ARM_SHA=$(shasum -a 256 portbuddy-macos-arm64 | awk '{print $1}')\n            MAC_X64_SHA=$(shasum -a 256 portbuddy-macos-x64 | awk '{print $1}')\n            LINUX_ARM_SHA=$(shasum -a 256 portbuddy-linux-arm64 | awk '{print $1}')\n            LINUX_X64_SHA=$(shasum -a 256 portbuddy-linux-x64 | awk '{print $1}')\n          else\n            MAC_ARM_SHA=$(sha256sum portbuddy-macos-arm64 | awk '{print $1}')\n            MAC_X64_SHA=$(sha256sum portbuddy-macos-x64 | awk '{print $1}')\n            LINUX_ARM_SHA=$(sha256sum portbuddy-linux-arm64 | awk '{print $1}')\n            LINUX_X64_SHA=$(sha256sum portbuddy-linux-x64 | awk '{print $1}')\n          fi\n          \n          sed -i \"s/version \\\".*\\\"/version \\\"${{ steps.extract_version.outputs.VERSION }}\\\"/\" Formula/portbuddy.rb\n          sed -i \"/portbuddy-macos-arm64\\\"/{n;s/sha256 \\\".*\\\"/sha256 \\\"$MAC_ARM_SHA\\\"/;}\" Formula/portbuddy.rb\n          sed -i \"/portbuddy-macos-x64\\\"/{n;s/sha256 \\\".*\\\"/sha256 \\\"$MAC_X64_SHA\\\"/;}\" Formula/portbuddy.rb\n          sed -i \"/portbuddy-linux-arm64\\\"/{n;s/sha256 \\\".*\\\"/sha256 \\\"$LINUX_ARM_SHA\\\"/;}\" Formula/portbuddy.rb\n          sed -i \"/portbuddy-linux-x64\\\"/{n;s/sha256 \\\".*\\\"/sha256 \\\"$LINUX_X64_SHA\\\"/;}\" Formula/portbuddy.rb\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Commit and Push\n        run: |\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n          git add Formula/portbuddy.rb\n          git commit -m \"Update portbuddy to v${{ steps.extract_version.outputs.VERSION }}\"\n          git push\n"
  },
  {
    "path": ".gitignore",
    "content": "target/\n!.mvn/wrapper/maven-wrapper.jar\n!**/src/main/**/target/\n!**/src/test/**/target/\n.kotlin\n\n### IntelliJ IDEA ###\n.idea/*\n.idea\n*.iws\n*.iml\n*.ipr\n\n### Eclipse ###\n.apt_generated\n.classpath\n.factorypath\n.project\n.settings\n.springBeans\n.sts4-cache\n\n### NetBeans ###\n/nbproject/private/\n/nbbuild/\n/dist/\n/nbdist/\n/.nb-gradle/\nbuild/\n!**/src/main/**/build/\n!**/src/test/**/build/\n\n### VS Code ###\n.vscode/\n\n### Mac OS ###\n.DS_Store\nnode_modules/\ndist\n.env\nref\npg_data\nlog\n/checkstyle_report.txt\n/test_output.txt\n/config/acme/account.key\n/ssl-service/config/acme/account.key\n\n.junie\n\n"
  },
  {
    "path": ".mvn/wrapper/maven-wrapper.properties",
    "content": "wrapperVersion=3.3.4\ndistributionType=only-script\ndistributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip\n"
  },
  {
    "path": "Dockerfile-cli",
    "content": "FROM eclipse-temurin:25-jre\n\nWORKDIR /app\nCOPY cli/target/cli-*.jar /app/app.jar\nCOPY entrypoint.sh /app/entrypoint.sh\nRUN chmod +x /app/entrypoint.sh\n\nENV JVM_OPTS=\"--enable-native-access=ALL-UNNAMED\"\n\nENTRYPOINT [\"/app/entrypoint.sh\"]\n"
  },
  {
    "path": "Dockerfile-eureka",
    "content": "FROM eclipse-temurin:25-jre\n\nWORKDIR /app\nCOPY eureka/target/eureka-*.jar /app/app.jar\nCOPY entrypoint.sh /app/entrypoint.sh\nRUN chmod +x /app/entrypoint.sh\n\nENTRYPOINT [\"/app/entrypoint.sh\"]\n"
  },
  {
    "path": "Dockerfile-gateway",
    "content": "FROM eclipse-temurin:25-jre\n\nWORKDIR /app\nCOPY gateway/target/gateway-*.jar /app/app.jar\nCOPY entrypoint.sh /app/entrypoint.sh\nRUN chmod +x /app/entrypoint.sh\n\nENTRYPOINT [\"/app/entrypoint.sh\"]\n"
  },
  {
    "path": "Dockerfile-net-proxy",
    "content": "FROM eclipse-temurin:25-jre\n\nWORKDIR /app\nCOPY net-proxy/target/net-proxy-*.jar /app/app.jar\nCOPY entrypoint.sh /app/entrypoint.sh\nRUN chmod +x /app/entrypoint.sh\n\nENTRYPOINT [\"/app/entrypoint.sh\"]\n"
  },
  {
    "path": "Dockerfile-server",
    "content": "FROM eclipse-temurin:25-jre\n\nWORKDIR /app\nCOPY server/target/server-*.jar /app/app.jar\nCOPY entrypoint.sh /app/entrypoint.sh\nRUN chmod +x /app/entrypoint.sh\n\nENTRYPOINT [\"/app/entrypoint.sh\"]\n"
  },
  {
    "path": "Dockerfile-ssl-service",
    "content": "FROM eclipse-temurin:25-jre\n\nWORKDIR /app\nCOPY ssl-service/target/ssl-service-*.jar /app/app.jar\nCOPY entrypoint.sh /app/entrypoint.sh\nRUN chmod +x /app/entrypoint.sh\n\nENTRYPOINT [\"/app/entrypoint.sh\"]\n"
  },
  {
    "path": "Dockerfile-web",
    "content": "FROM alpine:latest\n\nWORKDIR /app\nCOPY web/dist /app/dist\nCOPY entrypoint-web.sh /app/entrypoint.sh\nRUN chmod +x /app/entrypoint.sh\n\nENTRYPOINT [\"/app/entrypoint.sh\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# PortBuddy 🚀\n\nPortBuddy is a powerful yet simple tool that allows you to expose a port opened on your local host or in a private network to the public internet. It works as a secure tunnel, similar to ngrok, providing a public URL for your local services.\n\nWhether you're developing a web app, testing webhooks, or sharing access to a local database, PortBuddy makes it easy and secure.\n\n## ✨ Features\n\n- **Multi-protocol support**: Tunnel HTTP, TCP, and UDP traffic.\n- **SSL by default**: All HTTP tunnels are automatically secured with SSL.\n- **Customizable**: Support for static subdomains and custom domains.\n- **Websocket support**: Full support for real-time applications.\n- **Private tunnels**: Secure your tunnels with passcodes.\n- **Cross-platform CLI**: Lightweight CLI built with Java 25 and GraalVM (native executable).\n- **Web Dashboard**: Manage your tunnels, subscriptions, and team members easily.\n\n## 🚀 Quick Start\n\n### 1. Installation\n\nDownload the latest version of the `portbuddy` CLI for your platform (Windows, Linux, or Mac).\n\n### 2. Authentication\n\nBefore exposing ports, you need to authenticate your CLI.\n1. Log in to your account at [portbuddy.dev](https://portbuddy.dev).\n2. Generate an API Token in your dashboard.\n3. Run the following command:\n   ```bash\n   portbuddy init {YOUR_API_TOKEN}\n   ```\n\n### 3. Expose a Port\n\n#### HTTP (Default)\nExpose a local web server running on port 3000:\n```bash\nportbuddy 3000\n```\nOutput: `http://localhost:3000 exposed to: https://abc123.portbuddy.dev`\n\n#### TCP\nExpose a local PostgreSQL database:\n```bash\nportbuddy tcp 5432\n```\nOutput: `tcp localhost:5432 exposed to: net-proxy-3.portbuddy.dev:43452`\n\n#### UDP\nExpose a local UDP service:\n```bash\nportbuddy udp 9000\n```\n\n### 4. Run with Docker\n\nYou can also run the PortBuddy CLI inside a Docker container.\n\n#### Pull the Image\nIf you haven't built it locally, you can use the official image (replace with actual image name if applicable):\n```bash\ndocker pull amaktech/portbuddy:latest\n```\n*Note: If you are developing locally, you can build the image using `Dockerfile-cli`.*\n\n#### Authentication with Docker\nTo use PortBuddy in Docker, you should mount your token file from your host machine to the container. The CLI expects the token at `/root/.port-buddy/token`.\n\nFirst, authenticate on your host machine:\n```bash\nportbuddy init {YOUR_API_TOKEN}\n```\n\nThen, run the Docker container with the token mounted:\n```bash\ndocker run -it --rm \\\n  -v ~/.port-buddy/token:/root/.port-buddy/token \\\n  --network host \\\n  amaktech/portbuddy 3000\n```\n\n#### Mounting a Token File Directly\nIf you have your API token in a file (e.g., `my_token.txt`), you can mount it directly:\n```bash\ndocker run -it --rm \\\n  -v $(pwd)/my_token.txt:/root/.port-buddy/token \\\n  --network host \\\n  amaktech/portbuddy 3000\n```\n\n*Note: `--network host` is used to allow the container to access services running on your host's localhost (e.g., port 3000).*\n\n## 🛠️ CLI Usage\n\n```text\nUsage: portbuddy [options] [mode] [host:][port]\n\nModes:\n  http (default), tcp, udp\n\nOptions:\n  -d,  --domain=<domain>        Requested static subdomain (e.g. my-app)\n  -pr, --port-reservation=<hp>  Use specific port reservation host:port for TCP/UDP\n  -pc, --passcode=<passcode>    Protect tunnel with a passcode\n  -v,  --verbose                Enable verbose logging\n  -h,  --help                   Show help message\n  -V,  --version                Show version info\n```\n\n## 💳 Subscription Plans\n\n| Feature | Pro ($0/mo) | Team ($10/mo) |\n| :--- | :--- | :--- |\n| Tunnels | HTTP, TCP, UDP | Everything in Pro |\n| SSL | Included | Included |\n| Subdomains | Static | Static |\n| Custom Domains | Supported | Supported |\n| Team Members | - | Included |\n| Free Tunnels | 1 at a time | 10 at a time |\n| Extra Tunnels | $1/mo each | $1/mo each |\n| Support | Standard | Priority |\n\n## 🏗️ Architecture\n\nPortBuddy is built as a multi-modular system:\n\n- **`cli`**: GraalVM-native command-line application (Java 25).\n- **`server`**: Spring Boot 3.5.7 API & Tunnel Management.\n- **`net-proxy`**: High-performance TCP/UDP proxy.\n- **`gateway`**: Webflux-based API Gateway.\n- **`web`**: React-based dashboard (TypeScript + TailwindCSS).\n- **`eureka`**: Service discovery.\n- **`ssl-service`**: Automated SSL certificate management.\n- **`common`**: Shared DTOs and utilities.\n\n## 🛠️ Development\n\n### Prerequisites\n- Java 25\n- Docker & Docker Compose\n- Spring Boot 3\n- Maven 3.9+\n- Node.js & npm (for web module)\n\n### Build\nTo build the entire project:\n```bash\n./mvnw clean install\n```\n\n### Run with Docker Compose\n```bash\ndocker-compose up -d\n```\n\n## 📄 License\n\nThis project is licensed under the Apache License, Version 2.0 - see the [LICENSE](LICENSE) file for details.\n\n## 🤝 Community Projects\n\nProjects built by the PortBuddy community:\n\n- **[PortBuddy GUI](https://github.com/quack-stuff/portbuddy-gui)** by Quack - A graphical user interface for PortBuddy on Windows.\n"
  },
  {
    "path": "cli/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n    <parent>\n        <groupId>tech.amak</groupId>\n        <artifactId>port-buddy</artifactId>\n        <version>1.0-SNAPSHOT</version>\n    </parent>\n    <artifactId>cli</artifactId>\n    <name>port-buddy-cli</name>\n    <properties>\n        <maven.compiler.release>25</maven.compiler.release>\n    </properties>\n    <dependencies>\n        <dependency>\n            <groupId>org.projectlombok</groupId>\n            <artifactId>lombok</artifactId>\n            <version>${lombok.version}</version>\n            <scope>provided</scope>\n        </dependency>\n        <dependency>\n            <groupId>ch.qos.logback</groupId>\n            <artifactId>logback-classic</artifactId>\n            <version>1.5.16</version>\n        </dependency>\n        <dependency>\n            <groupId>ch.qos.logback</groupId>\n            <artifactId>logback-core</artifactId>\n            <version>1.5.16</version>\n        </dependency>\n        <dependency>\n            <groupId>com.fasterxml.jackson.core</groupId>\n            <artifactId>jackson-databind</artifactId>\n            <version>2.18.1</version>\n        </dependency>\n        <dependency>\n            <groupId>com.fasterxml.jackson.dataformat</groupId>\n            <artifactId>jackson-dataformat-yaml</artifactId>\n            <version>2.18.1</version>\n        </dependency>\n        <dependency>\n            <groupId>com.fasterxml.jackson.core</groupId>\n            <artifactId>jackson-annotations</artifactId>\n            <version>2.18.1</version>\n        </dependency>\n        <dependency>\n            <groupId>com.fasterxml.jackson.core</groupId>\n            <artifactId>jackson-core</artifactId>\n            <version>2.18.1</version>\n        </dependency>\n        <dependency>\n            <groupId>tech.amak</groupId>\n            <artifactId>common</artifactId>\n            <version>${project.version}</version>\n        </dependency>\n        <dependency>\n            <groupId>com.squareup.okhttp3</groupId>\n            <artifactId>okhttp</artifactId>\n            <version>4.12.0</version>\n        </dependency>\n        <dependency>\n            <groupId>org.jline</groupId>\n            <artifactId>jline</artifactId>\n            <version>3.26.3</version>\n        </dependency>\n        <dependency>\n            <groupId>org.junit.jupiter</groupId>\n            <artifactId>junit-jupiter-api</artifactId>\n            <version>5.11.4</version>\n            <scope>test</scope>\n        </dependency>\n        <dependency>\n            <groupId>org.junit.jupiter</groupId>\n            <artifactId>junit-jupiter-engine</artifactId>\n            <version>5.11.4</version>\n            <scope>test</scope>\n        </dependency>\n    </dependencies>\n    <build>\n        <plugins>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-compiler-plugin</artifactId>\n                <configuration>\n                    <annotationProcessorPaths>\n                        <path>\n                            <groupId>org.projectlombok</groupId>\n                            <artifactId>lombok</artifactId>\n                            <version>${lombok.version}</version>\n                        </path>\n                    </annotationProcessorPaths>\n                </configuration>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-jar-plugin</artifactId>\n                <version>3.4.2</version>\n                <configuration>\n                    <archive>\n                        <manifest>\n                            <mainClass>tech.amak.portbuddy.cli.PortBuddy</mainClass>\n                        </manifest>\n                    </archive>\n                </configuration>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-shade-plugin</artifactId>\n                <version>3.6.0</version>\n                <executions>\n                    <execution>\n                        <phase>package</phase>\n                        <goals>\n                            <goal>shade</goal>\n                        </goals>\n                        <configuration>\n                            <createDependencyReducedPom>false</createDependencyReducedPom>\n                            <transformers>\n                                <transformer implementation=\"org.apache.maven.plugins.shade.resource.ManifestResourceTransformer\">\n                                    <mainClass>tech.amak.portbuddy.cli.PortBuddy</mainClass>\n                                    <manifestEntries>\n                                        <Implementation-Title>${project.name}</Implementation-Title>\n                                        <Implementation-Version>${project.version}</Implementation-Version>\n                                    </manifestEntries>\n                                </transformer>\n                            </transformers>\n                        </configuration>\n                    </execution>\n                </executions>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-checkstyle-plugin</artifactId>\n            </plugin>\n        </plugins>\n    </build>\n\n    <profiles>\n        <profile>\n            <id>native</id>\n            <build>\n                <plugins>\n                    <plugin>\n                        <groupId>org.graalvm.buildtools</groupId>\n                        <artifactId>native-maven-plugin</artifactId>\n                        <version>0.11.3</version>\n                        <extensions>true</extensions>\n                        <executions>\n                            <execution>\n                                <id>build-native</id>\n                                <goals>\n                                    <goal>compile-no-fork</goal>\n                                </goals>\n                                <phase>package</phase>\n                            </execution>\n                        </executions>\n                        <configuration>\n                            <imageName>portbuddy</imageName>\n                            <mainClass>tech.amak.portbuddy.cli.PortBuddy</mainClass>\n                            <debug>false</debug>\n                            <verbose>false</verbose>\n                            <buildArgs>\n                                <buildArg>-Os</buildArg>\n                                <buildArg>--no-fallback</buildArg>\n                                <buildArg>--enable-http</buildArg>\n                                <buildArg>--enable-https</buildArg>\n                                <buildArg>--gc=serial</buildArg>\n                                <buildArg>-H:+UnlockExperimentalVMOptions</buildArg>\n                                <buildArg>-H:-StackTrace</buildArg>\n                                <buildArg>--initialize-at-build-time=org.slf4j,ch.qos.logback,tech.amak.portbuddy.common</buildArg>\n                            </buildArgs>\n                            <jvmArgs>\n                                <jvmArg>--enable-native-access=ALL-UNNAMED</jvmArg>\n                            </jvmArgs>\n                        </configuration>\n                    </plugin>\n                </plugins>\n            </build>\n        </profile>\n    </profiles>\n</project>\n"
  },
  {
    "path": "cli/src/main/java/tech/amak/portbuddy/cli/PortBuddy.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.cli;\n\nimport static tech.amak.portbuddy.cli.utils.JsonUtils.MAPPER;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Objects;\n\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport tech.amak.portbuddy.cli.config.ConfigurationService;\nimport tech.amak.portbuddy.cli.tunnel.HttpTunnelClient;\nimport tech.amak.portbuddy.cli.tunnel.NetTunnelClient;\nimport tech.amak.portbuddy.cli.ui.ConsoleUi;\nimport tech.amak.portbuddy.cli.utils.HttpUtils;\nimport tech.amak.portbuddy.common.ClientConfig;\nimport tech.amak.portbuddy.common.TunnelType;\nimport tech.amak.portbuddy.common.dto.ExposeRequest;\nimport tech.amak.portbuddy.common.dto.ExposeResponse;\nimport tech.amak.portbuddy.common.dto.auth.RegisterRequest;\nimport tech.amak.portbuddy.common.dto.auth.RegisterResponse;\nimport tech.amak.portbuddy.common.dto.auth.TokenExchangeRequest;\nimport tech.amak.portbuddy.common.dto.auth.TokenExchangeResponse;\n\n/**\n * Main class for the PortBuddy CLI application.\n * Handles command-line argument parsing and coordinates tunnel exposure.\n */\n@Slf4j\npublic class PortBuddy {\n\n    private static final String OUTDATED = \"outdated\";\n    private static final int EXIT_OK = 0;\n    private static final int EXIT_ERROR = 1;\n    private static final int EXIT_USAGE = 2;\n\n    private final ConfigurationService configurationService = ConfigurationService.INSTANCE;\n\n    private String domain;\n    private String portReservation;\n    private String passcode;\n    private boolean verbose;\n    private final List<String> positionalArgs = new ArrayList<>();\n\n    private final OkHttpClient http = HttpUtils.createClient();\n\n    /**\n     * Main entry point for the application.\n     *\n     * @param args command line arguments\n     */\n    public static void main(final String[] args) {\n        final var portBuddy = new PortBuddy();\n        final var exitCode = portBuddy.execute(args);\n        System.exit(exitCode);\n    }\n\n    /**\n     * Parses arguments and executes the requested command.\n     *\n     * @param args command line arguments\n     * @return exit code\n     */\n    public int execute(final String[] args) {\n        if (args.length == 0) {\n            printHelp();\n            return EXIT_USAGE;\n        }\n\n        var i = 0;\n        while (i < args.length) {\n            final var arg = args[i];\n            if (\"-h\".equals(arg) || \"--help\".equals(arg)) {\n                printHelp();\n                return EXIT_OK;\n            } else if (\"-V\".equals(arg) || \"--version\".equals(arg)) {\n                printVersion();\n                return EXIT_OK;\n            } else if (\"-v\".equals(arg) || \"--verbose\".equals(arg)) {\n                this.verbose = true;\n            } else if (\"-d\".equals(arg) || \"--domain\".equals(arg)) {\n                if (++i < args.length) {\n                    this.domain = args[i];\n                } else {\n                    System.err.println(\"Error: Option '-d', '--domain' requires an argument.\");\n                    return EXIT_USAGE;\n                }\n            } else if (arg.startsWith(\"--domain=\")) {\n                this.domain = arg.substring(\"--domain=\".length());\n            } else if (\"-pr\".equals(arg) || \"--port-reservation\".equals(arg)) {\n                if (++i < args.length) {\n                    this.portReservation = args[i];\n                } else {\n                    System.err.println(\"Error: Option '-pr', '--port-reservation' requires an argument.\");\n                    return EXIT_USAGE;\n                }\n            } else if (arg.startsWith(\"--port-reservation=\")) {\n                this.portReservation = arg.substring(\"--port-reservation=\".length());\n            } else if (\"-pc\".equals(arg) || \"--passcode\".equals(arg)) {\n                if (++i < args.length) {\n                    this.passcode = args[i];\n                } else {\n                    System.err.println(\"Error: Option '-pc', '--passcode' requires an argument.\");\n                    return EXIT_USAGE;\n                }\n            } else if (arg.startsWith(\"--passcode=\")) {\n                this.passcode = arg.substring(\"--passcode=\".length());\n            } else if (\"-n\".equals(arg) || \"--no-request-log\".equals(arg)) {\n                configurationService.getConfig().setLogEnabled(false);\n            } else if (\"init\".equals(arg)) {\n                if (++i < args.length) {\n                    return init(args[i]);\n                } else {\n                    System.err.println(\"Error: Missing API token for 'init' command.\");\n                    return EXIT_USAGE;\n                }\n            } else if (arg.startsWith(\"-\")) {\n                System.err.println(\"Unknown option: \" + arg);\n                printHelp();\n                return EXIT_USAGE;\n            } else {\n                positionalArgs.add(arg);\n            }\n            i++;\n        }\n\n        return expose();\n    }\n\n    private void printHelp() {\n        System.out.println(\"Usage: portbuddy [options] [mode] [host:][port]\");\n        System.out.println(\"Expose local ports to public network (simple ngrok alternative).\");\n        System.out.println();\n        System.out.println(\"Options:\");\n        System.out.println(\"  -d, --domain=<domain>       Requested domain (e.g. my-domain)\");\n        System.out.println(\"  -pr, --port-reservation=<host:port>\");\n        System.out.println(\"                              Use specific port reservation host:port for TCP/UDP\");\n        System.out.println(\"  -pc, --passcode=<passcode>  Passcode to secure HTTP tunnel (temporary for this tunnel)\");\n        System.out.println(\"  -n, --no-request-log        Disable request logging\");\n        System.out.println(\"  -v, --verbose               Verbose logging\");\n        System.out.println(\"  -h, --help                  Show this help message and exit.\");\n        System.out.println(\"  -V, --version               Print version information and exit.\");\n        System.out.println();\n        System.out.println(\"Commands:\");\n        System.out.println(\"  init <apiToken>             Initialize CLI with API token\");\n        System.out.println();\n        System.out.println(\"Examples:\");\n        System.out.println(\"  portbuddy 3000\");\n        System.out.println(\"  portbuddy tcp 5432\");\n        System.out.println(\"  portbuddy --domain=my-app 8080\");\n    }\n\n    private void printVersion() {\n        System.out.println(\"portbuddy \" + resolveCliVersion());\n    }\n\n    private int init(final String apiToken) {\n        try {\n            configurationService.saveApiToken(apiToken);\n            System.out.println(\"API token saved. You're now authenticated.\");\n            return EXIT_OK;\n        } catch (final IOException e) {\n            System.err.println(\"Failed to save API token: \" + e.getMessage());\n            return EXIT_ERROR;\n        }\n    }\n\n    private int expose() {\n        final String modeStr;\n        final String hostPortStr;\n        if (positionalArgs.isEmpty()) {\n            System.err.println(\"Usage: portbuddy [mode] [host:][port] or [schema://]host[:port]\");\n            return EXIT_USAGE;\n        } else if (positionalArgs.size() == 1) {\n            modeStr = null; // default http\n            hostPortStr = positionalArgs.get(0);\n        } else {\n            modeStr = positionalArgs.get(0);\n            hostPortStr = positionalArgs.get(1);\n        }\n\n        final var mode = TunnelType.from(modeStr);\n        final var hostPort = parseHostPort(hostPortStr);\n        if (hostPort == null) {\n            return EXIT_USAGE;\n        }\n        if (hostPort.port < 1 || hostPort.port > 65535) {\n            System.err.println(\"Port must be in range [1, 65535]\");\n            return EXIT_USAGE;\n        }\n\n        final var config = configurationService.getConfig();\n\n        // 1) Ensure API key is present and exchange it for a JWT at startup\n        if (!ensureAuthenticated(config)) {\n            return EXIT_ERROR;\n        }\n\n        final var apiKey = config.getApiToken();\n        final var jwt = exchangeApiTokenForJwt(config.getServerUrl(), apiKey);\n        if (Objects.equals(jwt, OUTDATED)) {\n            System.err.println(\"\"\"\n                Your portbuddy CLI is outdated.\n                Please upgrade to the latest version and try again.\"\"\");\n            return EXIT_ERROR;\n        }\n\n        if (jwt == null || jwt.isBlank()) {\n            System.err.println(\"\"\"\n                Failed to authenticate with the provided API Key.\n                CLI must be initialized with a valid API Key.\n                Example: portbuddy init {API_TOKEN}\"\"\");\n            return EXIT_ERROR;\n        }\n\n        if (mode == TunnelType.HTTP) {\n            final var expose = callExposeTunnel(config.getServerUrl(), jwt,\n                new ExposeRequest(mode, hostPort.scheme, hostPort.host, hostPort.port, domain, null, passcode));\n            if (expose == null) {\n                System.err.println(\"Failed to contact server to create tunnel\");\n                return EXIT_ERROR;\n            }\n\n            final var localInfo = String.format(\"%s://%s:%d\", hostPort.scheme, hostPort.host, hostPort.port);\n            final var publicInfo = expose.publicUrl();\n            final var ui = new ConsoleUi(TunnelType.HTTP, localInfo, publicInfo);\n            final var tunnelId = expose.tunnelId();\n            if (tunnelId == null) {\n                System.err.println(\"Server did not return tunnelId\");\n                return EXIT_ERROR;\n            }\n\n            final var client = new HttpTunnelClient(\n                config.getServerUrl(),\n                tunnelId,\n                hostPort.host,\n                hostPort.port,\n                hostPort.scheme,\n                jwt,\n                publicInfo,\n                ui,\n                verbose\n            );\n\n            final var thread = new Thread(client::runBlocking, \"port-buddy-http-client\");\n            ui.setOnExit(client::close);\n            thread.start();\n            ui.start();\n            ui.waitForExit();\n            try {\n                thread.join(2000);\n            } catch (final InterruptedException e) {\n                Thread.currentThread().interrupt();\n            }\n        } else {\n            final var scheme = mode == TunnelType.UDP ? \"udp\" : \"tcp\";\n            final var expose = callExposeTunnel(config.getServerUrl(), jwt,\n                new ExposeRequest(mode, scheme, hostPort.host, hostPort.port, null, portReservation, null));\n            if (expose == null || expose.publicHost() == null || expose.publicPort() == null) {\n                System.err.println(\"Failed to contact server to create \" + mode + \" tunnel\");\n                return EXIT_ERROR;\n            }\n            final var localInfo = String.format(\"%s %s:%d\", mode.name().toLowerCase(), hostPort.host, hostPort.port);\n            final var publicInfo = String.format(\"%s:%d\", expose.publicHost(), expose.publicPort());\n            final var ui = new ConsoleUi(mode, localInfo, publicInfo);\n            final var tunnelId = expose.tunnelId();\n            if (tunnelId == null) {\n                System.err.println(\"Server did not return tunnelId\");\n                return EXIT_ERROR;\n            }\n            // Use configured API server URL for the WebSocket control channel, not the public TCP host\n            final var serverUri = URI.create(config.getServerUrl());\n            final var wsHost = serverUri.getHost();\n            final var wsPort = serverUri.getPort() == -1\n                ? (\"https\".equalsIgnoreCase(serverUri.getScheme()) ? 443 : 80)\n                : serverUri.getPort();\n            final var secure = \"https\".equalsIgnoreCase(serverUri.getScheme());\n            final var tcpClient = new NetTunnelClient(\n                wsHost,\n                wsPort,\n                secure,\n                tunnelId,\n                hostPort.host,\n                hostPort.port,\n                mode,\n                expose.publicHost(),\n                expose.publicPort(),\n                jwt,\n                ui,\n                verbose);\n            final var thread = new Thread(tcpClient::runBlocking, \"port-buddy-net-client-\" + mode.name().toLowerCase());\n            ui.setOnExit(tcpClient::close);\n            thread.start();\n            ui.start();\n            ui.waitForExit();\n            try {\n                thread.join(2000);\n            } catch (final InterruptedException e) {\n                Thread.currentThread().interrupt();\n            }\n        }\n\n        System.out.println(\"\\nThanks, bye!\");\n\n        return EXIT_OK;\n    }\n\n    private ExposeResponse callExposeTunnel(final String baseUrl, final String jwt, final ExposeRequest requestBody) {\n        final var tunnelType = requestBody.tunnelType();\n\n        try {\n            final var url = baseUrl + \"/api/expose/\"\n                            + (tunnelType == TunnelType.HTTP ? \"http\" : \"net\");\n\n            final var json = MAPPER.writeValueAsString(requestBody);\n            final var request = new Request.Builder()\n                .url(url)\n                .post(RequestBody.create(json, MediaType.parse(\"application/json\")))\n                .header(\"Authorization\", \"Bearer \" + jwt)\n                .build();\n\n            try (final var response = http.newCall(request).execute()) {\n                if (!response.isSuccessful()) {\n                    log.warn(\"Expose {} failed: {} {}\", tunnelType, response.code(), response.message());\n                    if (response.code() == 401) {\n                        System.err.println(\"Authentication failed. Please re-initialize CLI with a valid API Key.\\n\"\n                                           + \"Example: portbuddy init {API_TOKEN}\");\n                    }\n                    return null;\n                }\n                final var body = response.body();\n                if (body == null) {\n                    return null;\n                }\n                return MAPPER.readValue(body.string(), ExposeResponse.class);\n            }\n        } catch (final Exception e) {\n            log.warn(\"Expose {} tunnel call error: {}\", tunnelType, e.toString());\n            return null;\n        }\n    }\n\n    /**\n     * Exchanges the provided API token for a JWT using the server's auth endpoint.\n     * Returns the JWT string if successful, otherwise null.\n     */\n    private String exchangeApiTokenForJwt(final String baseUrl, final String apiToken) {\n        try {\n            final var url = baseUrl + \"/api/auth/token-exchange\";\n            final var cliVersion = resolveCliVersion();\n            final var payload = new TokenExchangeRequest(apiToken, cliVersion);\n            final var json = MAPPER.writeValueAsString(payload);\n            final var request = new Request.Builder()\n                .url(url)\n                .post(RequestBody.create(json, MediaType.parse(\"application/json\")))\n                .build();\n\n            try (final var response = http.newCall(request).execute()) {\n                if (!response.isSuccessful()) {\n                    log.warn(\"Token exchange failed: {} {}\", response.code(), response.message());\n                    if (response.code() == 426) {\n                        return OUTDATED;\n                    }\n                    return null;\n                }\n                final var body = response.body();\n                if (body == null) {\n                    return null;\n                }\n                final var resp = MAPPER.readValue(body.string(), TokenExchangeResponse.class);\n                final var accessToken = resp.getAccessToken() == null ? \"\" : resp.getAccessToken();\n                final var tokenType = resp.getTokenType() == null ? \"\" : resp.getTokenType();\n                if (!accessToken.isBlank() && (tokenType.isBlank() || \"Bearer\".equalsIgnoreCase(tokenType))) {\n                    return accessToken;\n                }\n                return null;\n            }\n        } catch (final Exception e) {\n            log.warn(\"Token exchange call error: {}\", e.toString());\n            return null;\n        }\n    }\n\n    private String resolveCliVersion() {\n        final var pkg = PortBuddy.class.getPackage();\n        final var impl = pkg == null ? null : pkg.getImplementationVersion();\n        if (impl != null && !impl.isBlank()) {\n            return impl.trim();\n        }\n        return \"1.0\";\n    }\n\n    private boolean ensureAuthenticated(final ClientConfig config) {\n        final var apiKey = config.getApiToken();\n        if (apiKey != null && !apiKey.isBlank()) {\n            return true;\n        }\n\n        System.out.println(\"No API key found. Please sign up.\");\n\n        try {\n            final var request = ConsoleUi.promptForUserRegistration();\n            if (request == null) {\n                System.err.println(\"Registration cancelled or failed to get details.\");\n                return false;\n            }\n\n            final var serverUrl = config.getServerUrl();\n            if (serverUrl == null || serverUrl.isBlank()) {\n                System.err.println(\"Error: Server URL is not configured. Please check your application.yml.\");\n                return false;\n            }\n\n            final var newApiKey = registerUser(serverUrl, request);\n            configurationService.saveApiToken(newApiKey);\n            config.setApiToken(newApiKey);\n            System.out.println(\"Registration successful! API key saved.\");\n            return true;\n        } catch (final Exception e) {\n            log.debug(\"Registration failed\", e);\n            System.err.println(\"Registration failed: \" + e.getMessage());\n            if (e.getMessage() == null) {\n                System.err.println(\"Error details: \" + e.getClass().getName());\n                e.printStackTrace(System.err); // Print stack trace to see exactly where NPE happens\n            }\n            return false;\n        }\n    }\n\n    private String registerUser(final String baseUrl,\n                                final RegisterRequest registerRequest) throws IOException {\n        final var url = baseUrl + (baseUrl.endsWith(\"/\") ? \"\" : \"/\") + \"api/auth/register\";\n        final var json = MAPPER.writeValueAsString(registerRequest);\n        final var request = new Request.Builder()\n            .url(url)\n            .post(RequestBody.create(json, MediaType.parse(\"application/json\")))\n            .build();\n\n        try (final var response = http.newCall(request).execute()) {\n            final var respBody = response.body();\n            if (respBody == null) {\n                if (!response.isSuccessful()) {\n                    throw new IOException(\"Server returned: \" + response.message());\n                }\n                throw new IOException(\"Empty response from server\");\n            }\n\n            if (response.code() == 503) {\n                throw new IOException(\"Server is unavailable. Please try again later.\");\n            }\n\n            final var bodyStr = respBody.string();\n            try {\n                final var registerResponse = MAPPER.readValue(bodyStr, RegisterResponse.class);\n                if (!registerResponse.isSuccess()) {\n                    throw new IOException(registerResponse.getMessage());\n                }\n                return registerResponse.getApiKey();\n            } catch (final IOException e) {\n                if (!response.isSuccessful()) {\n                    throw new IOException(\"Server returned: \" + response.message());\n                }\n                throw e;\n            }\n        }\n    }\n\n    private HostPort parseHostPort(final String arg) {\n        var scheme = \"http\"; // default scheme\n        var host = \"localhost\"; // default host\n        Integer port = null;\n\n        if (arg == null || arg.isBlank()) {\n            System.err.println(\"Missing [host:][port] or [schema://]host[:port]. Example: 'portbuddy 3000' or 'portbuddy https://localhost'\");\n            return null;\n        }\n\n        var url = arg.trim();\n\n        if (url.endsWith(\"/\")) {\n            url = url.substring(0, url.length() - 1);\n        }\n\n        // Case 1: pure port number, e.g. \"3000\"\n        if (!url.contains(\"://\") && !url.contains(\":\")) {\n            // try parse as number; if fails, treat as host without port\n            try {\n                port = Integer.parseInt(url);\n                return new HostPort(host, port, scheme);\n            } catch (final NumberFormatException ignore) {\n                // not a pure number -> host only, keep going\n                host = url;\n                port = null;\n            }\n        }\n\n        var schemeExplicit = false;\n\n        // Case 2: URL with scheme: http(s)://host[:port]\n        if (url.contains(\"://\")) {\n            final var parts = url.split(\"://\", 2);\n            final var givenScheme = parts[0].toLowerCase();\n            if (!givenScheme.equals(\"http\") && !givenScheme.equals(\"https\")) {\n                System.err.println(\"Unsupported schema: \" + givenScheme + \". Only http or https are allowed.\");\n                return null;\n            }\n            scheme = givenScheme;\n            schemeExplicit = true;\n            final var rest = parts[1];\n            if (rest.contains(\":\")) {\n                final var hostPortPart = rest.split(\":\", 2);\n                host = hostPortPart[0].isBlank() ? host : hostPortPart[0];\n                try {\n                    port = Integer.parseInt(hostPortPart[1]);\n                } catch (final NumberFormatException e) {\n                    System.err.println(\"Invalid port: \" + hostPortPart[1]);\n                    return null;\n                }\n            } else if (!rest.isBlank()) {\n                host = rest;\n            }\n        } else if (port == null) {\n            // Case 3: host[:port] (no scheme)\n            if (url.contains(\":\")) {\n                final var hostPortPart = url.split(\":\", 2);\n                host = hostPortPart[0].isBlank() ? host : hostPortPart[0];\n                try {\n                    port = Integer.parseInt(hostPortPart[1]);\n                } catch (final NumberFormatException e) {\n                    System.err.println(\"Invalid port: \" + hostPortPart[1]);\n                    return null;\n                }\n            } else {\n                host = url;\n            }\n        }\n\n        if (port == null) {\n            // Default port by scheme\n            port = scheme.equals(\"https\") ? 443 : 80;\n        }\n\n        // If scheme is not explicit and port is 443/80, infer scheme from common defaults\n        if (!schemeExplicit) {\n            if (port == 443) {\n                scheme = \"https\";\n            } else if (port == 80) {\n                scheme = \"http\";\n            }\n        }\n\n        return new HostPort(host, port, scheme);\n    }\n\n    private static final class HostPort {\n        private final String host;\n        private final int port;\n        private final String scheme;\n\n        private HostPort(final String host, final int port, final String scheme) {\n            this.host = host;\n            this.port = port;\n            this.scheme = scheme;\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/tech/amak/portbuddy/cli/config/ConfigurationService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.cli.config;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.attribute.PosixFilePermission;\nimport java.util.HashSet;\nimport java.util.Optional;\nimport java.util.concurrent.atomic.AtomicReference;\n\nimport org.slf4j.LoggerFactory;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.fasterxml.jackson.dataformat.yaml.YAMLFactory;\n\nimport ch.qos.logback.classic.Level;\nimport ch.qos.logback.classic.Logger;\nimport ch.qos.logback.classic.LoggerContext;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.ClientConfig;\n\n@Slf4j\npublic class ConfigurationService {\n\n    private static final String PORT_BUDDY_ENV = \"PORT_BUDDY_ENV\";\n    private static final String PORT_BUDDY_ENV_DEV = \"dev\";\n    private static final String APP_DIR = \".port-buddy\";\n    private static final String TOKEN_FILE = \"token\";\n\n    private final String home = System.getProperty(\"user.home\");\n    private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());\n    private final AtomicReference<ClientConfig> config = new AtomicReference<>();\n\n    public static final ConfigurationService INSTANCE = new ConfigurationService();\n\n    private ConfigurationService() {\n        try {\n            configureLogging();\n            loadConfig();\n            loadToken();\n        } catch (final Exception e) {\n            log.error(\"Failed to load config: {}\", e.toString());\n            config.set(new ClientConfig());\n        }\n    }\n\n    private void configureLogging() {\n        final var env = System.getenv(PORT_BUDDY_ENV);\n\n        final var loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();\n\n        final var rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME);\n        rootLogger.setLevel(Level.ERROR);\n\n        if (PORT_BUDDY_ENV_DEV.equalsIgnoreCase(env)) {\n            final var appLogger = loggerContext.getLogger(\"tech.amak.portbuddy\");\n            appLogger.setLevel(Level.DEBUG);\n        }\n    }\n\n    private void loadConfig() throws IOException {\n        final var envPart = Optional.ofNullable(System.getenv(PORT_BUDDY_ENV))\n            .map(String::toLowerCase)\n            .map(\"-%s\"::formatted)\n            .orElse(\"\");\n\n        final var resourceName = \"/application%s.yml\".formatted(envPart);\n        log.debug(\"Loading config from resource: {}\", resourceName);\n\n        try (final var configStream = ConfigurationService.class.getResourceAsStream(resourceName)) {\n            if (configStream != null) {\n                final var clientConfig = yamlMapper.readValue(configStream, ClientConfig.class);\n                log.debug(\"Loaded config: {}\", clientConfig);\n                config.set(clientConfig);\n            } else {\n                log.warn(\"Resource not found: {}\", resourceName);\n                if (config.get() == null) {\n                    config.set(new ClientConfig());\n                }\n            }\n        }\n    }\n\n    private void loadToken() throws IOException {\n        final var tokenFile = Path.of(home, APP_DIR, TOKEN_FILE);\n        if (Files.exists(tokenFile)) {\n            final var token = Files.readString(tokenFile).trim();\n            if (!token.isBlank()) {\n                config.get().setApiToken(token);\n            }\n        }\n    }\n\n    public ClientConfig getConfig() {\n        return config.get();\n    }\n\n    public boolean isDev() {\n        final var env = System.getenv(PORT_BUDDY_ENV);\n        return PORT_BUDDY_ENV_DEV.equalsIgnoreCase(env);\n    }\n\n    /**\n     * Saves the provided API token to a file located in the user's home directory\n     * under the \".port-buddy\" directory. If the directory or file does not exist,\n     * they are created. File permissions are restricted to the owner on POSIX systems.\n     *\n     * @param token the API token to be saved. It is stripped of leading and trailing whitespaces\n     *              before being written to the file.\n     * @throws IOException if an I/O error occurs while creating the directory, file, or writing the token.\n     */\n    public void saveApiToken(final String token) throws IOException {\n        final var dir = Path.of(home, APP_DIR);\n\n        if (!Files.exists(dir)) {\n            Files.createDirectories(dir);\n        }\n\n        final var file = dir.resolve(TOKEN_FILE);\n        Files.writeString(file, token.strip());\n        // Try to restrict permissions on POSIX systems\n        try {\n            final var perms = new HashSet<PosixFilePermission>();\n            perms.add(PosixFilePermission.OWNER_READ);\n            perms.add(PosixFilePermission.OWNER_WRITE);\n            Files.setPosixFilePermissions(file, perms);\n        } catch (final UnsupportedOperationException ignore) {\n            // Non-POSIX filesystem (e.g., Windows) - best effort only\n        }\n    }\n\n}\n"
  },
  {
    "path": "cli/src/main/java/tech/amak/portbuddy/cli/tunnel/HttpTunnelClient.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.cli.tunnel;\n\nimport static tech.amak.portbuddy.cli.utils.JsonUtils.MAPPER;\n\nimport java.net.URI;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ScheduledFuture;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.WebSocket;\nimport okhttp3.WebSocketListener;\nimport okio.ByteString;\nimport tech.amak.portbuddy.cli.config.ConfigurationService;\nimport tech.amak.portbuddy.cli.ui.ConsoleUi;\nimport tech.amak.portbuddy.cli.ui.HttpLogSink;\nimport tech.amak.portbuddy.cli.utils.HttpUtils;\nimport tech.amak.portbuddy.common.tunnel.ControlMessage;\nimport tech.amak.portbuddy.common.tunnel.HttpTunnelMessage;\nimport tech.amak.portbuddy.common.tunnel.MessageEnvelope;\nimport tech.amak.portbuddy.common.tunnel.WsTunnelMessage;\n\n@Slf4j\n@RequiredArgsConstructor\npublic class HttpTunnelClient {\n\n    private final String serverUrl; // e.g. https://portbuddy.dev\n    private final UUID tunnelId;\n    private final String localHost;\n    private final int localPort;\n    private final String localScheme; // http or https\n    private final String authToken; // Bearer token for API auth\n    private final String publicBaseUrl; // e.g. https://abc123.portbuddy.dev\n    private final HttpLogSink httpLogSink;\n    private final boolean verbose;\n\n    // OkHttp client used exclusively for the control WebSocket connection to the server\n    private final OkHttpClient http = createHttpClient();\n    // Separate OkHttp client for calling the local target service (avoid any interference with WS client)\n    private final OkHttpClient localHttp = createLocalHttpClient();\n\n    private static OkHttpClient createHttpClient() {\n        final var builder = new OkHttpClient.Builder()\n            .readTimeout(0, TimeUnit.MILLISECONDS) // keep-alive for WS\n            .pingInterval(15, TimeUnit.SECONDS) // send pings to keep intermediaries/proxies from dropping idle WS\n            .retryOnConnectionFailure(true);\n\n        if (ConfigurationService.INSTANCE.isDev()) {\n            HttpUtils.configureInsecureSsl(builder);\n        }\n\n        return builder.build();\n    }\n\n    private static OkHttpClient createLocalHttpClient() {\n        final var builder = new OkHttpClient.Builder()\n            .connectTimeout(10, TimeUnit.SECONDS)\n            .readTimeout(60, TimeUnit.SECONDS)\n            .writeTimeout(60, TimeUnit.SECONDS)\n            // Do not follow redirects automatically; they must be proxied back to the client\n            .followRedirects(false)\n            .followSslRedirects(false)\n            .retryOnConnectionFailure(true);\n\n        if (ConfigurationService.INSTANCE.isDev()) {\n            HttpUtils.configureInsecureSsl(builder);\n        }\n\n        return builder.build();\n    }\n\n    private WebSocket webSocket;\n    private CountDownLatch closed = new CountDownLatch(1);\n    private final AtomicBoolean stop = new AtomicBoolean(false);\n    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {\n        final var thread = new Thread(runnable, \"port-buddy-heartbeat\");\n        thread.setDaemon(true);\n        return thread;\n    });\n    private volatile ScheduledFuture<?> heartbeatTask;\n    private final ExecutorService requestExecutor = Executors.newFixedThreadPool(4, runnable -> {\n        final var thread = new Thread(runnable, \"port-buddy-http-worker\");\n        thread.setDaemon(true);\n        return thread;\n    });\n\n    private final Map<String, WebSocket> localWebsocketMap = new ConcurrentHashMap<>();\n\n    /**\n     * Establishes and maintains a blocking WebSocket connection to the server.\n     * This method constructs a WebSocket connection to a server using a URL\n     * derived from the server's URL combined with the tunnel identifier. The\n     * method blocks until the WebSocket connection is closed or interrupted.\n     * Behavior:\n     * - Converts the server URL and tunnel identifier into a WebSocket URL.\n     * - Opens a WebSocket connection to the calculated URL and uses a\n     * {@code Listener} to handle WebSocket events, such as incoming messages,\n     * connection closure, or failures.\n     * - Waits on the {@code closeLatch} to ensure blocking behavior until the\n     * connection is terminated.\n     * Exceptions:\n     * - Catches and handles {@link InterruptedException} if the wait operation\n     * on the latch is interrupted. Restores the interrupted thread state.\n     */\n    public void runBlocking() {\n        var backoffMs = 1000L;\n        final var maxBackoffMs = 30000L;\n        while (!stop.get()) {\n            try {\n                closed = new CountDownLatch(1);\n                final var wsUrl = toWebSocketUrl(serverUrl, \"/api/http-tunnel/\" + tunnelId);\n                final var request = new Request.Builder().url(wsUrl);\n                if (authToken != null && !authToken.isBlank()) {\n                    request.addHeader(\"Authorization\", \"Bearer \" + authToken);\n                }\n                webSocket = http.newWebSocket(request.build(), new Listener());\n\n                // Block until this connection is closed\n                closed.await();\n                if (stop.get()) {\n                    break;\n                }\n                // Reconnect with backoff\n                log.info(\"Tunnel disconnected; reconnecting in {} ms...\", backoffMs);\n                Thread.sleep(backoffMs);\n                backoffMs = Math.min(backoffMs * 2, maxBackoffMs);\n            } catch (final InterruptedException e) {\n                Thread.currentThread().interrupt();\n                break;\n            } catch (final Exception e) {\n                log.warn(\"Tunnel loop error: {}\", e.toString());\n                if (verbose) {\n                    e.printStackTrace(System.err);\n                }\n                try {\n                    Thread.sleep(backoffMs);\n                } catch (final InterruptedException ie) {\n                    Thread.currentThread().interrupt();\n                    break;\n                }\n                backoffMs = Math.min(backoffMs * 2, maxBackoffMs);\n            }\n        }\n    }\n\n    /**\n     * Closes the WebSocket connection associated with this HTTP tunnel client.\n     * This method attempts to gracefully close the WebSocket connection, if it exists,\n     * using the standard WebSocket closure status code 1000 (indicating a normal closure)\n     * and a reason message \"Client exit\". If an exception occurs during the closure process,\n     * it is logged at the debug level and suppressed to ensure that the exception does not\n     * disrupt the application's flow.\n     * Behavior:\n     * - If there is an active WebSocket (represented by the {@code webSocket} field),\n     * it calls the {@code close} method on the WebSocket instance, passing the closure\n     * status code and reason message.\n     * - Logs any exception encountered during the close operation at the debug level\n     * without re-throwing it.\n     * Thread-safety: This method is thread-safe, as it uses a local reference to the\n     * {@code webSocket} field to prevent potential null pointer exceptions caused by\n     * concurrent modifications.\n     */\n    public void close() {\n        try {\n            stop.set(true);\n            final var task = heartbeatTask;\n            if (task != null) {\n                task.cancel(true);\n            }\n            scheduler.shutdownNow();\n            requestExecutor.shutdownNow();\n            if (webSocket != null) {\n                webSocket.close(1000, \"Client exit\");\n                log.debug(\"Websocket closed: 1000 OK\");\n            }\n            localWebsocketMap.values().forEach(ws -> {\n                try {\n                    ws.close(1000, \"Tunnel closed\");\n                } catch (final Exception ignore) {\n                    // ignore\n                }\n            });\n            localWebsocketMap.clear();\n        } catch (final Exception ignore) {\n            log.debug(\"HTTP tunnel close error: {}\", ignore.toString());\n        }\n    }\n\n    private String toWebSocketUrl(final String base, final String path) {\n        final var uri = URI.create(base);\n        var scheme = uri.getScheme();\n        if (\"https\".equalsIgnoreCase(scheme)) {\n            scheme = \"wss\";\n        } else if (\"http\".equalsIgnoreCase(scheme)) {\n            scheme = \"ws\";\n        }\n        final var hostPort = (uri.getPort() == -1) ? uri.getHost() : (uri.getHost() + \":\" + uri.getPort());\n        return scheme + \"://\" + hostPort + path;\n    }\n\n    private class Listener extends WebSocketListener {\n        @Override\n        public void onOpen(final WebSocket webSocket, final Response response) {\n            log.debug(\"Tunnel connected to server\");\n            // Start application-level heartbeat PINGs\n            try {\n                if (heartbeatTask != null && !heartbeatTask.isCancelled()) {\n                    heartbeatTask.cancel(true);\n                }\n                final var config = ConfigurationService.INSTANCE.getConfig();\n                final var intervalSec = config.getHealthcheckIntervalSec();\n                heartbeatTask = scheduler.scheduleAtFixedRate(() -> {\n                    try {\n                        final var ping = new ControlMessage();\n                        ping.setType(ControlMessage.Type.PING);\n                        ping.setTs(System.currentTimeMillis());\n                        HttpTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(ping));\n                    } catch (final Exception e) {\n                        log.debug(\"Heartbeat send failed: {}\", e.toString());\n                    }\n                }, intervalSec, intervalSec, TimeUnit.SECONDS);\n            } catch (final Exception e) {\n                log.debug(\"Failed to start heartbeat: {}\", e.toString());\n            }\n        }\n\n        @Override\n        public void onMessage(final WebSocket webSocket, final String text) {\n            try {\n                log.debug(\"Received WS message: {}\", text);\n                final var env = MAPPER.readValue(text, MessageEnvelope.class);\n                if (env.getKind() != null && env.getKind().equals(\"CTRL\")) {\n                    final var ctrl = MAPPER.readValue(text, ControlMessage.class);\n                    if (ctrl.getType() == ControlMessage.Type.EXIT) {\n                        log.info(\"Received EXIT control message. Shutting down...\");\n                        try {\n                            HttpTunnelClient.this.webSocket.close(1000, \"Server requested exit\");\n                        } catch (final Exception ignore) {\n                            // ignore\n                        }\n                        if (httpLogSink instanceof ConsoleUi ui) {\n                            ui.stop();\n                        }\n                    }\n                    return;\n                }\n                if (env.getKind() != null && env.getKind().equals(\"WS\")) {\n                    final var wsMsg = MAPPER.readValue(text, WsTunnelMessage.class);\n                    handleWsFromServer(wsMsg);\n                    return;\n                }\n                final var message = MAPPER.readValue(text, HttpTunnelMessage.class);\n                if (message.getType() == HttpTunnelMessage.Type.REQUEST) {\n                    // Offload request processing to a worker thread to avoid blocking the WS listener\n                    requestExecutor.submit(() -> {\n                        try {\n                            final var resp = handleRequest(message);\n                            final var json = MAPPER.writeValueAsString(resp);\n                            HttpTunnelClient.this.webSocket.send(json);\n                            log.debug(\"Responded to WS request: {}\", resp.getId());\n                        } catch (final Exception ex) {\n                            log.warn(\"Failed to handle tunneled request {}: {}\", message.getId(), ex.toString());\n                            try {\n                                final var error = buildErrorMessage(message.getId(), 502, \"Proxy error\");\n                                HttpTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(error));\n                            } catch (final Exception e) {\n                                log.error(\"Failed to send error response: {}\", e.getMessage(), e);\n                            }\n                        }\n                    });\n                } else {\n                    log.debug(\"Ignoring non-REQUEST msg\");\n                }\n            } catch (final Exception e) {\n                log.warn(\"Failed to process WS message: {}\", e.toString());\n            }\n        }\n\n        @Override\n        public void onClosed(final WebSocket webSocket, final int code, final String reason) {\n            log.info(\"Tunnel closed: {} {}\", code, reason);\n            final var task = heartbeatTask;\n            if (task != null) {\n                task.cancel(true);\n            }\n            localWebsocketMap.values().forEach(ws -> {\n                try {\n                    ws.close(1000, \"Tunnel closed\");\n                } catch (final Exception ignore) {\n                    // ignore\n                }\n            });\n            localWebsocketMap.clear();\n            closed.countDown();\n        }\n\n        @Override\n        public void onFailure(final WebSocket webSocket, final Throwable error, final Response response) {\n            log.warn(\"Tunnel failure: {}\", error.toString());\n            final var task = heartbeatTask;\n            if (task != null) {\n                task.cancel(true);\n            }\n            localWebsocketMap.values().forEach(ws -> {\n                try {\n                    ws.close(1000, \"Tunnel failure\");\n                } catch (final Exception ignore) {\n                    // ignore\n                }\n            });\n            localWebsocketMap.clear();\n            closed.countDown();\n        }\n    }\n\n    private void handleWsFromServer(final WsTunnelMessage message) {\n        final var connId = message.getConnectionId();\n        switch (message.getWsType()) {\n            case OPEN -> {\n                // Connect to local target via WS\n                final var localWsScheme = \"https\".equalsIgnoreCase(localScheme) ? \"wss\" : \"ws\";\n                var url = localWsScheme + \"://\" + localHost + \":\" + localPort\n                          + (message.getPath() != null ? message.getPath() : \"/\");\n                if (message.getQuery() != null && !message.getQuery().isBlank()) {\n                    url += \"?\" + message.getQuery();\n                }\n                final var builder = new Request.Builder().url(url);\n                final var publicHost = URI.create(publicBaseUrl).getHost();\n                if (publicHost != null) {\n                    builder.header(\"Host\", publicHost);\n                }\n                if (message.getHeaders() != null) {\n                    for (final var entry : message.getHeaders().entrySet()) {\n                        if (entry.getKey() != null && entry.getValue() != null) {\n                            if (entry.getKey().equalsIgnoreCase(\"Host\")) {\n                                continue;\n                            }\n                            builder.addHeader(entry.getKey(), entry.getValue());\n                        }\n                    }\n                }\n                final var local = http.newWebSocket(builder.build(), new LocalWsListener(connId));\n                localWebsocketMap.put(connId, local);\n            }\n            case TEXT -> {\n                final var local = localWebsocketMap.get(connId);\n                if (local != null) {\n                    local.send(message.getText() != null ? message.getText() : \"\");\n                }\n            }\n            case BINARY -> {\n                final var local = localWebsocketMap.get(connId);\n                if (local != null && message.getDataB64() != null) {\n                    local.send(ByteString.of(Base64.getDecoder().decode(message.getDataB64())));\n                }\n            }\n            case CLOSE -> {\n                final var local = localWebsocketMap.remove(connId);\n                if (local != null) {\n                    local.close(message.getCloseCode() != null\n                        ? message.getCloseCode()\n                        : 1000, message.getCloseReason());\n                }\n            }\n            default -> {\n            }\n        }\n    }\n\n    @RequiredArgsConstructor\n    private class LocalWsListener extends WebSocketListener {\n\n        private final String connectionId;\n\n        @Override\n        public void onOpen(final WebSocket webSocket, final Response response) {\n            try {\n                final var ack = new WsTunnelMessage();\n                ack.setWsType(WsTunnelMessage.Type.OPEN_OK);\n                ack.setConnectionId(connectionId);\n                HttpTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(ack));\n            } catch (final Exception ignore) {\n                log.error(\"Failed to send local WS open ack: {}\", ignore.toString());\n            }\n        }\n\n        @Override\n        public void onMessage(final WebSocket webSocket, final String text) {\n            try {\n                final var message = new WsTunnelMessage();\n                message.setWsType(WsTunnelMessage.Type.TEXT);\n                message.setConnectionId(connectionId);\n                message.setText(text);\n                HttpTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(message));\n            } catch (final Exception e) {\n                log.debug(\"Failed to forward local text WS: {}\", e.toString());\n            }\n        }\n\n        @Override\n        public void onMessage(final WebSocket webSocket, final ByteString bytes) {\n            try {\n                final var message = new WsTunnelMessage();\n                message.setWsType(WsTunnelMessage.Type.BINARY);\n                message.setConnectionId(connectionId);\n                message.setDataB64(Base64.getEncoder().encodeToString(bytes.toByteArray()));\n                HttpTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(message));\n            } catch (final Exception e) {\n                log.debug(\"Failed to forward local binary WS: {}\", e.toString());\n            }\n        }\n\n        @Override\n        public void onClosed(final WebSocket webSocket, final int code, final String reason) {\n            try {\n                localWebsocketMap.remove(connectionId);\n                final var message = new WsTunnelMessage();\n                message.setWsType(WsTunnelMessage.Type.CLOSE);\n                message.setConnectionId(connectionId);\n                message.setCloseCode(code);\n                message.setCloseReason(reason);\n                HttpTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(message));\n            } catch (final Exception e) {\n                log.debug(\"Failed to notify close: {}\", e.toString());\n            }\n        }\n\n        @Override\n        public void onFailure(final WebSocket webSocket, final Throwable error, final Response response) {\n            onClosed(webSocket, 1011, error.toString());\n        }\n    }\n\n    private HttpTunnelMessage handleRequest(final HttpTunnelMessage requestMessage) {\n        final var method = requestMessage.getMethod();\n        var url = localScheme + \"://\" + localHost + \":\" + localPort + requestMessage.getPath();\n        if (requestMessage.getQuery() != null && !requestMessage.getQuery().isBlank()) {\n            url += \"?\" + requestMessage.getQuery();\n        }\n\n        final var targetRequest = new Request.Builder()\n            .url(url)\n            .method(method, buildBody(method, requestMessage.getBodyB64(), requestMessage.getBodyContentType()));\n\n        final var publicHost = URI.create(publicBaseUrl).getHost();\n        if (publicHost != null) {\n            targetRequest.header(\"Host\", publicHost);\n        }\n\n        if (requestMessage.getHeaders() != null) {\n            for (final var header : requestMessage.getHeaders().entrySet()) {\n                final var name = header.getKey();\n                final var values = header.getValue();\n                if (name == null || values == null) {\n                    continue;\n                }\n                if (name.equalsIgnoreCase(\"Host\")) {\n                    continue; // Host will be set by client\n                }\n                if (name.equalsIgnoreCase(\"Content-Type\")) {\n                    // Content-Type is derived from RequestBody media type\n                    continue;\n                }\n                for (final var value : values) {\n                    if (value != null) {\n                        targetRequest.addHeader(name, value);\n                    }\n                }\n            }\n        }\n\n        try (final var targetResponse = localHttp.newCall(targetRequest.build()).execute()) {\n            final var successMessage = new HttpTunnelMessage();\n            successMessage.setId(requestMessage.getId());\n            successMessage.setType(HttpTunnelMessage.Type.RESPONSE);\n            successMessage.setStatus(targetResponse.code());\n            successMessage.setRespHeaders(extractHeaders(targetResponse));\n            final var body = targetResponse.body();\n            if (body != null) {\n                final var bytes = body.bytes();\n                if (bytes.length > 0) {\n                    successMessage.setRespBodyB64(Base64.getEncoder().encodeToString(bytes));\n                }\n            }\n            // Log to UI sink\n            try {\n                if (httpLogSink != null) {\n                    var displayUrl = publicBaseUrl;\n                    if (requestMessage.getPath() != null) {\n                        displayUrl += requestMessage.getPath();\n                    }\n                    if (requestMessage.getQuery() != null && !requestMessage.getQuery().isBlank()) {\n                        displayUrl += \"?\" + requestMessage.getQuery();\n                    }\n                    httpLogSink.onHttpLog(method, displayUrl, targetResponse.code());\n                }\n            } catch (final Exception ignore) {\n                log.debug(\"HTTP log sink failed: {}\", ignore.toString());\n            }\n            return successMessage;\n        } catch (final Exception e) {\n            final var errorMessage = buildErrorMessage(requestMessage.getId(), 502, \"Bad Gateway: \" + e.getMessage());\n            try {\n                if (httpLogSink != null) {\n                    var displayUrl = publicBaseUrl;\n                    if (requestMessage.getPath() != null) {\n                        displayUrl += requestMessage.getPath();\n                    }\n                    if (requestMessage.getQuery() != null && !requestMessage.getQuery().isBlank()) {\n                        displayUrl += \"?\" + requestMessage.getQuery();\n                    }\n                    httpLogSink.onHttpLog(method, displayUrl, 502);\n                }\n            } catch (final Exception ignore) {\n                log.debug(\"HTTP log sink failed: {}\", ignore.toString());\n            }\n            return errorMessage;\n        }\n    }\n\n    private static HttpTunnelMessage buildErrorMessage(final String id, final int status, final String message) {\n        final var error = new HttpTunnelMessage();\n        error.setId(id);\n        error.setType(HttpTunnelMessage.Type.RESPONSE);\n        error.setStatus(status);\n        final var headers = Map.<String, List<String>>of(\"Content-Type\", List.of(\"text/plain; charset=utf-8\"));\n        error.setRespHeaders(headers);\n        error.setRespBodyB64(Base64.getEncoder().encodeToString((message).getBytes(StandardCharsets.UTF_8)));\n\n        return error;\n    }\n\n    private RequestBody buildBody(final String method, final String bodyB64, final String contentType) {\n        // Methods that usually don't have body\n        if (bodyB64 == null) {\n            return methodSupportsBody(method)\n                ? RequestBody.create(new byte[0], contentType != null ? MediaType.parse(contentType) : null)\n                : null;\n        }\n        final var bytes = Base64.getDecoder().decode(bodyB64);\n        final var mediaType = contentType != null && !contentType.isBlank()\n            ? MediaType.parse(contentType)\n            : MediaType.parse(\"application/octet-stream\");\n        return RequestBody.create(bytes, mediaType);\n    }\n\n    private boolean methodSupportsBody(final String method) {\n        if (method == null) {\n            return false;\n        }\n        return switch (method.toUpperCase()) {\n            case \"POST\", \"PUT\", \"PATCH\" -> true;\n            default -> false;\n        };\n    }\n\n    private Map<String, List<String>> extractHeaders(final Response response) {\n        final var map = new HashMap<String, List<String>>();\n        for (final var name : response.headers().names()) {\n            final var values = response.headers(name);\n            if (values != null && !values.isEmpty()) {\n                map.put(name, new ArrayList<>(values));\n            }\n        }\n        return map;\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/tech/amak/portbuddy/cli/tunnel/NetTunnelClient.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.cli.tunnel;\n\nimport static tech.amak.portbuddy.cli.utils.JsonUtils.MAPPER;\n\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.net.DatagramPacket;\nimport java.net.DatagramSocket;\nimport java.net.InetSocketAddress;\nimport java.net.Socket;\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Base64;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ScheduledFuture;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.WebSocket;\nimport okhttp3.WebSocketListener;\nimport okio.ByteString;\nimport tech.amak.portbuddy.cli.config.ConfigurationService;\nimport tech.amak.portbuddy.cli.ui.ConsoleUi;\nimport tech.amak.portbuddy.cli.ui.NetTrafficSink;\nimport tech.amak.portbuddy.cli.utils.HttpUtils;\nimport tech.amak.portbuddy.common.TunnelType;\nimport tech.amak.portbuddy.common.tunnel.BinaryWsFrame;\nimport tech.amak.portbuddy.common.tunnel.ControlMessage;\nimport tech.amak.portbuddy.common.tunnel.MessageEnvelope;\nimport tech.amak.portbuddy.common.tunnel.WsTunnelMessage;\n\n@Slf4j\n@RequiredArgsConstructor\npublic class NetTunnelClient {\n\n    private final String proxyHost;\n    private final int proxyHttpPort;\n    /**\n     * Whether the WebSocket should use TLS (wss). Must reflect the scheme of the configured server URL.\n     */\n    private final boolean secure;\n    private final UUID tunnelId;\n    private final String localHost;\n    private final int localPort;\n    private final TunnelType tunnelType;\n    // Expected public connection details returned by the server during expose REST call\n    private final String expectedPublicHost;\n    private final int expectedPublicPort;\n    private final String authToken; // Bearer token if available\n    private final NetTrafficSink trafficSink;\n    private final boolean verbose;\n\n    private final OkHttpClient http = HttpUtils.createClient();\n    private final OkHttpClient rest = HttpUtils.createClient();\n    private WebSocket webSocket;\n\n    private final Map<String, LocalTcp> locals = new ConcurrentHashMap<>();\n    private final Map<String, LocalUdp> udpLocals = new ConcurrentHashMap<>();\n    private CountDownLatch closed = new CountDownLatch(1);\n    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {\n        final var thread = new Thread(runnable, \"pb-net-heartbeat\");\n        thread.setDaemon(true);\n        return thread;\n    });\n    private volatile ScheduledFuture<?> heartbeatTask;\n    private volatile ScheduledFuture<?> wsHeartbeatTask;\n    private final AtomicBoolean closedReported = new AtomicBoolean(false);\n    private final AtomicBoolean stop = new AtomicBoolean(false);\n    private final AtomicBoolean warnedAboutReassignment = new AtomicBoolean(false);\n    private final AtomicBoolean successfullyConnected = new AtomicBoolean(false);\n\n    /**\n     * Establishes and maintains a WebSocket connection for TCP/UDP tunneling.\n     * This method constructs the WebSocket URL using the configured proxy host, port, and tunnel ID.\n     * It sets up an authentication token in the request header, if provided, and initializes the WebSocket connection.\n     * The method blocks the current thread until the connection is closed or an interruption occurs.\n     * Behavior:\n     * - Converts a base HTTP URL to a WebSocket URL using the {@code toWebSocketUrl} method.\n     * - Adds an optional \"Authorization\" header to the WebSocket request for authentication.\n     * - Creates a WebSocket connection using the provided URL and the {@code Listener} for handling events.\n     * - Waits for the WebSocket connection to close by utilizing a {@code CountDownLatch}.\n     * - Handles interruptions by setting the thread's interrupt status.\n     */\n    public void runBlocking() {\n        var backoffMs = 1000L;\n        final var maxBackoffMs = 30000L;\n        while (!stop.get()) {\n            try {\n                closed = new CountDownLatch(1);\n                final var scheme = secure ? \"https://\" : \"http://\";\n                final var publicHostParam = (expectedPublicHost == null || expectedPublicHost.isBlank())\n                    ? \"\"\n                    : \"&public-host=\" + URLEncoder.encode(expectedPublicHost, StandardCharsets.UTF_8);\n                final var path = \"/api/net-tunnel/\" + tunnelId\n                                 + \"?type=\" + tunnelType.name().toLowerCase()\n                                 + \"&port=\" + expectedPublicPort\n                                 + publicHostParam;\n                final var url = toWebSocketUrl(scheme + proxyHost + \":\" + proxyHttpPort, path);\n                final var request = new Request.Builder().url(url);\n                if (authToken != null && !authToken.isBlank()) {\n                    request.addHeader(\"Authorization\", \"Bearer \" + authToken);\n                }\n                webSocket = http.newWebSocket(request.build(), new Listener());\n                successfullyConnected.set(false);\n\n                // Block until this connection is closed\n                closed.await();\n                if (stop.get()) {\n                    break;\n                }\n\n                if (successfullyConnected.get()) {\n                    backoffMs = 1000L;\n                }\n                // Reconnect with backoff\n                log.info(\"Net tunnel disconnected; reconnecting in {} ms...\", backoffMs);\n                Thread.sleep(backoffMs);\n                backoffMs = Math.min(backoffMs * 2, maxBackoffMs);\n            } catch (final InterruptedException e) {\n                Thread.currentThread().interrupt();\n                break;\n            } catch (final Exception e) {\n                log.warn(\"Net tunnel loop error: {}\", e.toString());\n                if (verbose) {\n                    e.printStackTrace(System.err);\n                }\n                try {\n                    Thread.sleep(backoffMs);\n                } catch (final InterruptedException ie) {\n                    Thread.currentThread().interrupt();\n                    break;\n                }\n                backoffMs = Math.min(backoffMs * 2, maxBackoffMs);\n            }\n        }\n    }\n\n    /**\n     * Closes the WebSocket connection for the TCP tunnel client.\n     * This method attempts to gracefully close the WebSocket connection, if it exists,\n     * by sending a close frame with a status code of 1000 (normal closure) and a reason\n     * message (\"Client exit\"). If an exception occurs during the close operation, the\n     * error is logged for debugging purposes.\n     * Behavior:\n     * - Checks if the WebSocket instance (`ws`) is not null.\n     * - If the WebSocket exists, sends a close frame with a normal status and reason.\n     * - Catches and logs any exceptions encountered during the close operation,\n     * ensuring the process does not disrupt the program flow.\n     */\n    public void close() {\n        try {\n            stop.set(true);\n            final var task = heartbeatTask;\n            if (task != null) {\n                task.cancel(true);\n            }\n            final var wsTask = wsHeartbeatTask;\n            if (wsTask != null) {\n                wsTask.cancel(true);\n            }\n            scheduler.shutdownNow();\n            if (webSocket != null) {\n                webSocket.close(1000, \"Client exit\");\n            }\n            locals.values().forEach(this::close);\n            locals.clear();\n            udpLocals.values().forEach(this::close);\n            udpLocals.clear();\n            reportClosedSafe();\n        } catch (final Exception e) {\n            log.debug(\"TCP tunnel close error: {}\", e.toString());\n        }\n    }\n\n    private void close(final LocalTcp localTcp) {\n        if (localTcp != null) {\n            try {\n                localTcp.sock.close();\n            } catch (final Exception e) {\n                log.debug(\"Failed to close TCP: {}\", e.toString());\n            }\n        }\n    }\n\n    private void close(final LocalUdp localUdp) {\n        if (localUdp != null) {\n            try {\n                localUdp.sock.close();\n            } catch (final Exception e) {\n                log.debug(\"Failed to close UDP local: {}\", e.toString());\n            }\n        }\n    }\n\n    private String toWebSocketUrl(final String httpUri, final String path) {\n        var uri = httpUri;\n        if (uri.startsWith(\"http://\")) {\n            uri = \"ws://\" + uri.substring(7);\n        } else if (uri.startsWith(\"https://\")) {\n            uri = \"wss://\" + uri.substring(8);\n        }\n        if (uri.endsWith(\"/\")) {\n            uri = uri.substring(0, uri.length() - 1);\n        }\n        return uri + path;\n    }\n\n    private class Listener extends WebSocketListener {\n        @Override\n        public void onOpen(final WebSocket webSocket, final Response response) {\n            successfullyConnected.set(true);\n            // Report CONNECTED and start heartbeats\n            try {\n                postStatus(\"/api/tunnels/\" + tunnelId + \"/connected\");\n            } catch (final Exception e) {\n                log.debug(\"Failed to report NET connected: {}\", e.toString());\n            }\n            // allow reporting CLOSED again for future disconnects after a successful reconnect\n            closedReported.set(false);\n\n            final var config = ConfigurationService.INSTANCE.getConfig();\n            final var intervalSec = Math.max(1, config.getHealthcheckIntervalSec());\n\n            try {\n                final var existing = heartbeatTask;\n                if (existing != null && !existing.isCancelled()) {\n                    existing.cancel(true);\n                }\n                heartbeatTask = scheduler.scheduleAtFixedRate(() -> {\n                    try {\n                        postStatus(\"/api/tunnels/\" + tunnelId + \"/heartbeat\");\n                    } catch (final Exception e) {\n                        log.debug(\"NET heartbeat failed: {}\", e.toString());\n                    }\n                }, 1, intervalSec, TimeUnit.SECONDS);\n            } catch (final Exception e) {\n                log.debug(\"Failed to start NET heartbeat: {}\", e.toString());\n            }\n            // Start WS application-level heartbeat (PING/PONG)\n            try {\n                final var existingWs = wsHeartbeatTask;\n                if (existingWs != null && !existingWs.isCancelled()) {\n                    existingWs.cancel(true);\n                }\n\n                wsHeartbeatTask = scheduler.scheduleAtFixedRate(() -> {\n                    try {\n                        final var ping = new ControlMessage();\n                        ping.setType(ControlMessage.Type.PING);\n                        ping.setTs(System.currentTimeMillis());\n                        NetTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(ping));\n                    } catch (final Exception e) {\n                        log.debug(\"WS heartbeat send failed: {}\", e.toString());\n                    }\n                }, intervalSec, intervalSec, TimeUnit.SECONDS);\n            } catch (final Exception e) {\n                log.debug(\"Failed to start WS heartbeat: {}\", e.toString());\n            }\n        }\n\n        @Override\n        public void onMessage(final WebSocket webSocket, final String text) {\n            try {\n                final var env = MAPPER.readValue(text, MessageEnvelope.class);\n                if (env.getKind() != null && env.getKind().equals(\"CTRL\")) {\n                    final var ctrl = MAPPER.readValue(text, ControlMessage.class);\n                    if (ctrl.getType() == ControlMessage.Type.EXIT) {\n                        log.info(\"Received EXIT control message. Shutting down...\");\n                        try {\n                            NetTunnelClient.this.webSocket.close(1000, \"Server requested exit\");\n                        } catch (final Exception ignore) {\n                            // ignore\n                        }\n                        if (trafficSink instanceof ConsoleUi ui) {\n                            ui.stop();\n                        }\n                    }\n                    return;\n                }\n                if (env.getKind() != null && env.getKind().equals(\"WS\")) {\n                    final var msg = MAPPER.readValue(text, WsTunnelMessage.class);\n                    handleControl(msg);\n                }\n                // Unknown kinds are ignored for NET tunnels\n            } catch (final Exception e) {\n                log.warn(\"Failed to process WS text message: {}\", e.toString());\n            }\n        }\n\n        @Override\n        public void onMessage(final WebSocket webSocket, final ByteString bytes) {\n            try {\n                final var decoded = BinaryWsFrame.decode(bytes.toByteArray());\n                if (decoded == null) {\n                    return;\n                }\n                if (tunnelType == TunnelType.TCP) {\n                    final var local = locals.get(decoded.connectionId());\n                    if (local != null) {\n                        try {\n                            local.out.write(decoded.data());\n                            local.out.flush();\n                            if (trafficSink != null) {\n                                trafficSink.onBytesIn(decoded.data().length);\n                            }\n                        } catch (final Exception e) {\n                            log.debug(\"Write to local TCP failed: {}\", e.toString());\n                        }\n                    }\n                } else if (tunnelType == TunnelType.UDP) {\n                    // For UDP, forward the datagram to local UDP server using per-connection socket\n                    final var connId = decoded.connectionId();\n                    var localUdp = udpLocals.get(connId);\n                    if (localUdp == null) {\n                        try {\n                            final var sock = new DatagramSocket();\n                            localUdp = new LocalUdp(connId, sock);\n                            udpLocals.put(connId, localUdp);\n                            // start receive loop for this connection\n                            final var localUdpRef = localUdp;\n                            new Thread(() -> pumpUdpLocalToProxy(localUdpRef)).start();\n                        } catch (final Exception e) {\n                            log.debug(\"Failed to create local UDP socket: {}\", e.toString());\n                            return;\n                        }\n                    }\n                    try {\n                        final var packet = new DatagramPacket(decoded.data(), decoded.data().length,\n                            new InetSocketAddress(localHost, localPort));\n                        localUdp.sock.send(packet);\n                        if (trafficSink != null) {\n                            trafficSink.onBytesIn(decoded.data().length);\n                        }\n                    } catch (final Exception e) {\n                        log.debug(\"Write to local UDP failed: {}\", e.toString());\n                    }\n                }\n            } catch (final Exception e) {\n                log.debug(\"Failed to handle binary WS frame: {}\", e.toString());\n            }\n        }\n\n        @Override\n        public void onClosed(final WebSocket webSocket, final int code, final String reason) {\n            log.info(\"Tunnel closed: {} {}\", code, reason);\n            final var task = heartbeatTask;\n            if (task != null) {\n                task.cancel(true);\n            }\n            final var wsTask = wsHeartbeatTask;\n            if (wsTask != null) {\n                wsTask.cancel(true);\n            }\n            locals.values().forEach(NetTunnelClient.this::close);\n            locals.clear();\n            udpLocals.values().forEach(NetTunnelClient.this::close);\n            udpLocals.clear();\n            reportClosedSafe();\n            closed.countDown();\n        }\n\n        @Override\n        public void onFailure(final WebSocket webSocket, final Throwable throwable, final Response response) {\n            log.warn(\"Tunnel failure: {}\", throwable.toString());\n            final var task = heartbeatTask;\n            if (task != null) {\n                task.cancel(true);\n            }\n            final var wsTask = wsHeartbeatTask;\n            if (wsTask != null) {\n                wsTask.cancel(true);\n            }\n            locals.values().forEach(NetTunnelClient.this::close);\n            locals.clear();\n            udpLocals.values().forEach(NetTunnelClient.this::close);\n            udpLocals.clear();\n            reportClosedSafe();\n            closed.countDown();\n        }\n    }\n\n    private void reportClosedSafe() {\n        if (closedReported.compareAndSet(false, true)) {\n            try {\n                postStatus(\"/api/tunnels/\" + tunnelId + \"/closed\");\n            } catch (final Exception e) {\n                log.debug(\"Failed to report NET closed: {}\", e.toString());\n            }\n        }\n    }\n\n    private void handleControl(final WsTunnelMessage message) throws Exception {\n        final var connId = message.getConnectionId();\n        switch (message.getWsType()) {\n            case EXPOSED -> {\n                final var actualHost = message.getPublicHost();\n                final var actualPort = message.getPublicPort();\n                if (actualHost != null && actualPort != null) {\n                    final var hostDiffers = expectedPublicHost != null && !expectedPublicHost.equals(actualHost);\n                    final var portDiffers = expectedPublicPort != actualPort;\n                    if ((hostDiffers || portDiffers) && warnedAboutReassignment.compareAndSet(false, true)) {\n                        System.out.printf(\n                            \"Warning: requested public %s:%d but exposed on %s:%d%n\",\n                            expectedPublicHost,\n                            expectedPublicPort,\n                            actualHost,\n                            actualPort\n                        );\n                    }\n                }\n            }\n            case OPEN -> {\n                if (tunnelType == TunnelType.TCP) {\n                    // Establish local TCP\n                    final var socket = new Socket();\n                    socket.connect(new InetSocketAddress(localHost, localPort), 5000);\n                    final var local = new LocalTcp(connId, socket);\n                    locals.put(connId, local);\n                    // Ack\n                    final var ack = new WsTunnelMessage();\n                    ack.setWsType(WsTunnelMessage.Type.OPEN_OK);\n                    ack.setConnectionId(connId);\n                    webSocket.send(MAPPER.writeValueAsString(ack));\n                    // Start reader thread from local TCP to proxy WS\n                    new Thread(() -> pumpLocalToProxy(local)).start();\n                } else {\n                    // UDP does not use OPEN for per-flow; ignore or acknowledge for compatibility\n                    final var ack = new WsTunnelMessage();\n                    ack.setWsType(WsTunnelMessage.Type.OPEN_OK);\n                    ack.setConnectionId(connId);\n                    webSocket.send(MAPPER.writeValueAsString(ack));\n                }\n            }\n            case BINARY -> {\n                if (tunnelType == TunnelType.TCP) {\n                    // Base64 payload from proxy to local TCP (legacy)\n                    final var local = locals.get(connId);\n                    if (local != null && message.getDataB64() != null) {\n                        try {\n                            final var bytes = Base64.getDecoder().decode(message.getDataB64());\n                            local.out.write(bytes);\n                            local.out.flush();\n                            if (trafficSink != null) {\n                                trafficSink.onBytesIn(bytes.length);\n                            }\n                        } catch (final Exception e) {\n                            log.debug(\"Write to local TCP failed: {}\", e.toString());\n                        }\n                    }\n                } else if (tunnelType == TunnelType.UDP) {\n                    // Legacy TEXT BINARY for UDP: forward to local as datagram\n                    if (message.getDataB64() != null) {\n                        var localUdp = udpLocals.get(connId);\n                        if (localUdp == null) {\n                            final var sock = new DatagramSocket();\n                            localUdp = new LocalUdp(connId, sock);\n                            udpLocals.put(connId, localUdp);\n                            final var localUdpRef = localUdp;\n                            new Thread(() -> pumpUdpLocalToProxy(localUdpRef)).start();\n                        }\n                        final var bytes = Base64.getDecoder().decode(message.getDataB64());\n                        final var packet = new DatagramPacket(bytes, bytes.length,\n                            new InetSocketAddress(localHost, localPort));\n                        localUdp.sock.send(packet);\n                        if (trafficSink != null) {\n                            trafficSink.onBytesIn(bytes.length);\n                        }\n                    }\n                }\n            }\n            case CLOSE -> {\n                if (tunnelType == TunnelType.TCP) {\n                    close(locals.remove(connId));\n                } else {\n                    close(udpLocals.remove(connId));\n                }\n            }\n            default -> {\n            }\n        }\n    }\n\n    private void pumpLocalToProxy(final LocalTcp local) {\n        final var buffer = new byte[8192];\n        try {\n            while (true) {\n                final var byteCount = local.in.read(buffer);\n                if (byteCount == -1) {\n                    break;\n                }\n                final var frame = BinaryWsFrame.encodeToArray(local.connectionId, buffer, 0, byteCount);\n                final var byteString = ByteString.of(frame);\n                webSocket.send(byteString);\n                if (trafficSink != null) {\n                    trafficSink.onBytesOut(byteCount);\n                }\n            }\n        } catch (final Exception e) {\n            // ignore\n        } finally {\n            try {\n                final var message = new WsTunnelMessage();\n                message.setWsType(WsTunnelMessage.Type.CLOSE);\n                message.setConnectionId(local.connectionId);\n                webSocket.send(MAPPER.writeValueAsString(message));\n            } catch (final Exception e) {\n                log.error(\"Failed to send local WS close: {}\", e.toString());\n            }\n            close(local);\n            locals.remove(local.connectionId);\n        }\n    }\n\n    private static class LocalTcp {\n        final String connectionId;\n        final Socket sock;\n        final InputStream in;\n        final OutputStream out;\n\n        LocalTcp(final String connectionId, final Socket sock) throws Exception {\n            this.connectionId = connectionId;\n            this.sock = sock;\n            this.in = sock.getInputStream();\n            this.out = sock.getOutputStream();\n        }\n    }\n\n    private static class LocalUdp {\n        final String connectionId;\n        final DatagramSocket sock;\n\n        LocalUdp(final String connectionId, final DatagramSocket sock) {\n            this.connectionId = connectionId;\n            this.sock = sock;\n        }\n    }\n\n    private void pumpUdpLocalToProxy(final LocalUdp local) {\n        final var buffer = new byte[65535];\n        try {\n            while (!local.sock.isClosed()) {\n                final var packet = new DatagramPacket(buffer, buffer.length);\n                local.sock.receive(packet);\n                final var frame = BinaryWsFrame\n                    .encodeToArray(local.connectionId, packet.getData(), packet.getOffset(), packet.getLength());\n                final var byteString = ByteString.of(frame);\n                webSocket.send(byteString);\n                if (trafficSink != null) {\n                    trafficSink.onBytesOut(packet.getLength());\n                }\n            }\n        } catch (final Exception e) {\n            // ignore normal close\n        } finally {\n            close(local);\n            udpLocals.remove(local.connectionId);\n        }\n    }\n\n    private void postStatus(final String path) throws Exception {\n        final var base = (secure ? \"https://\" : \"http://\") + proxyHost + \":\" + proxyHttpPort;\n        final var url = base + path;\n        final var body = RequestBody.create(\"{}\", MediaType.parse(\"application/json\"));\n        final var builder = new Request.Builder().url(url).post(body);\n        if (authToken != null && !authToken.isBlank()) {\n            builder.header(\"Authorization\", \"Bearer \" + authToken);\n        }\n        try (final var response = rest.newCall(builder.build()).execute()) {\n            if (!response.isSuccessful()) {\n                log.debug(\"Status POST failed {} {} for {}\", response.code(), response.message(), path);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/tech/amak/portbuddy/cli/ui/ConsoleUi.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.cli.ui;\n\nimport java.io.IOException;\nimport java.io.PrintWriter;\nimport java.time.Duration;\nimport java.util.ArrayDeque;\nimport java.util.Deque;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicLong;\n\nimport org.jline.reader.LineReaderBuilder;\nimport org.jline.terminal.Terminal;\nimport org.jline.terminal.TerminalBuilder;\nimport org.jline.utils.InfoCmp;\n\nimport lombok.Getter;\nimport lombok.RequiredArgsConstructor;\nimport lombok.Setter;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.cli.config.ConfigurationService;\nimport tech.amak.portbuddy.common.ClientConfig;\nimport tech.amak.portbuddy.common.TunnelType;\nimport tech.amak.portbuddy.common.dto.auth.RegisterRequest;\n\n@Slf4j\n@RequiredArgsConstructor\npublic class ConsoleUi implements HttpLogSink, NetTrafficSink {\n\n    public record HttpLog(String method, String url, int status) {\n    }\n\n    private final TunnelType tunnelType;\n    private final String localDetails;\n    private final String publicDetails;\n\n    private Terminal terminal;\n    private PrintWriter out;\n    private final Deque<HttpLog> httpLogs = new ArrayDeque<>();\n    private final AtomicLong inBytes = new AtomicLong();\n    private final AtomicLong outBytes = new AtomicLong();\n    private final AtomicBoolean running = new AtomicBoolean(false);\n    private final CountDownLatch exit = new CountDownLatch(1);\n    private final ClientConfig config = ConfigurationService.INSTANCE.getConfig();\n\n    private Thread renderThread;\n\n    @Getter\n    @Setter\n    private Runnable onExit;\n\n    /**\n     * Prompts the user for registration details using the console.\n     * When no API key is initialized, the user should only be asked for the email address.\n     *\n     * @return a RegisterRequest containing the input data (email only)\n     * @throws IOException if an I/O error occurs or console is not available\n     */\n    public static RegisterRequest promptForUserRegistration() throws IOException {\n        try (final var terminal = buildTerminal()) {\n            final var reader = LineReaderBuilder.builder().terminal(terminal).build();\n\n            String email;\n            while (true) {\n                email = reader.readLine(\"Email: \");\n                if (isValidEmail(email)) {\n                    break;\n                }\n                terminal.writer().println(\"Invalid email address. Please try again.\");\n                terminal.flush();\n            }\n\n            // Only email is required for registration. Name and password are set on the server side.\n            return new RegisterRequest(email, null, null);\n        }\n    }\n\n    private static boolean isValidEmail(final String email) {\n        return email != null && email.matches(\"^[\\\\w-\\\\.]+@([\\\\w-]+\\\\.)+[\\\\w-]{2,4}$\");\n    }\n\n    private static Terminal buildTerminal() throws IOException {\n        final var terminal = TerminalBuilder.builder()\n            .streams(System.in, System.out)\n            .system(true)\n            .jansi(true)\n            .jna(true)\n            .jni(true)\n            .ffm(true)\n            .exec(true)\n            .dumb(true)\n            .build();\n        return terminal;\n    }\n\n    /**\n     * Starts the Console UI. This method initializes the terminal for interactive\n     * output, sets up signal handling for interrupt signals, and spawns a new thread\n     * to handle the rendering loop.\n     * The method ensures that the UI is started only once by using an atomic flag. If\n     * the UI is already running, the method exits early.\n     * In case of initialization failure, such as terminal setup issues, an\n     * IllegalStateException is thrown to indicate that the UI failed to start.\n     * Once started, the rendering process runs on a daemon thread that periodically\n     * updates the terminal output. The rendering loop works until instructed to stop\n     * by external signals or method calls.\n     */\n    public void start() {\n        if (running.getAndSet(true)) {\n            return;\n        }\n        try {\n            terminal = buildTerminal();\n            out = terminal.writer();\n            terminal.handle(Terminal.Signal.INT, signal -> stop());\n        } catch (final Exception e) {\n            throw new IllegalStateException(\"Failed to start console UI\", e);\n        }\n\n        clear();\n\n        out.printf(\"Port Buddy - Mode: %s%n\", tunnelType.name().toLowerCase());\n        out.println();\n        out.printf(\"Local:  %s%n\", localDetails);\n        out.printf(\"Public: %s%n\", publicDetails);\n        out.println();\n        out.println(\"Press Ctrl+C to exit\");\n        out.flush();\n\n        if (config.isLogEnabled()) {\n            out.println(\"----------------------------------------------\");\n            out.println();\n            if (tunnelType == TunnelType.HTTP) {\n                out.println(\"HTTP requests log:\");\n            } else {\n                out.println(tunnelType + \" traffic:\");\n            }\n            out.flush();\n\n            renderThread = new Thread(this::renderLoop, \"port-buddy-ui\");\n            renderThread.setDaemon(true);\n            renderThread.start();\n        }\n    }\n\n    /**\n     * Waits for the termination signal of the Console UI. This method blocks the\n     * current thread until the internal exit condition is triggered, allowing\n     * proper synchronization for dependent operations.\n     * In case the thread is interrupted while waiting, the interrupt status is\n     * restored to ensure the interruption can be detected by other parts of the\n     * application.\n     */\n    public void waitForExit() {\n        try {\n            exit.await();\n        } catch (final InterruptedException e) {\n            Thread.currentThread().interrupt();\n        }\n    }\n\n    /**\n     * Stops the Console UI. This method transitions the system out of a running state,\n     * allowing for a clean shutdown. It ensures that the rendering loop halts,\n     * decrements the exit latch, and executes the optional exit callback if provided.\n     * The method is thread-safe and idempotent; it ensures that stopping the UI\n     * multiple times has no adverse effects. If the UI is not currently running,\n     * the method exits without performing any operations.\n     * If an {@link Runnable} callback is set via {@link #setOnExit(Runnable)},\n     * the callback is executed upon stopping. Any exceptions thrown by the callback\n     * are caught and logged to avoid disrupting the shutdown process.\n     */\n    public void stop() {\n        if (!running.getAndSet(false)) {\n            return;\n        }\n        exit.countDown();\n        if (onExit != null) {\n            try {\n                onExit.run();\n            } catch (final Exception e) {\n                log.warn(\"onExit handler failed: {}\", e.toString());\n            }\n        }\n    }\n\n    @Override\n    public void onHttpLog(final String method, final String url, final int status) {\n        synchronized (httpLogs) {\n            if (httpLogs.size() == config.getLogLinesCount()) {\n                httpLogs.removeFirst();\n            }\n            httpLogs.addLast(new HttpLog(method, url, status));\n        }\n    }\n\n    @Override\n    public void onBytesIn(final long bytes) {\n        inBytes.addAndGet(Math.max(0, bytes));\n    }\n\n    @Override\n    public void onBytesOut(final long bytes) {\n        outBytes.addAndGet(Math.max(0, bytes));\n    }\n\n    private void renderLoop() {\n        final var frameDelay = Duration.ofMillis(config.getConsoleFrameDelayMs());\n        while (running.get()) {\n            try {\n                terminal.puts(InfoCmp.Capability.cursor_address, 9, 0);\n                terminal.flush();\n                render();\n\n                Thread.sleep(frameDelay.toMillis());\n            } catch (final Exception e) {\n                log.debug(\"Render loop error: {}\", e.toString());\n            }\n        }\n\n        clear();\n    }\n\n    private void clear() {\n        try {\n            terminal.puts(InfoCmp.Capability.clear_screen);\n            terminal.flush();\n        } catch (final Exception ignore) {\n            // ignore\n        }\n    }\n\n    private void render() {\n        if (tunnelType == TunnelType.HTTP) {\n\n            synchronized (httpLogs) {\n                if (httpLogs.isEmpty()) {\n                    out.println(\"(no requests yet)\");\n                } else {\n                    httpLogs.forEach(httpLog -> {\n                        terminal.puts(InfoCmp.Capability.clr_eol);\n                        terminal.flush();\n                        out.printf(\"%-6s %-3d %s%n\", safe(httpLog.method()), httpLog.status(), safe(httpLog.url()));\n                    });\n                }\n            }\n        } else {\n            final var inKb = inBytes.get() / 1024.0;\n            final var outKb = outBytes.get() / 1024.0;\n            out.printf(\"IN %.2f KB | OUT %.2f KB%n\", inKb, outKb);\n        }\n\n        out.flush();\n    }\n\n    private String safe(final String value) {\n        return value == null ? \"\" : value;\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/tech/amak/portbuddy/cli/ui/HttpLogSink.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.cli.ui;\n\npublic interface HttpLogSink {\n    void onHttpLog(final String method, final String url, final int status);\n}\n"
  },
  {
    "path": "cli/src/main/java/tech/amak/portbuddy/cli/ui/NetTrafficSink.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.cli.ui;\n\npublic interface NetTrafficSink {\n\n    void onBytesIn(final long bytes);\n\n    void onBytesOut(final long bytes);\n}\n"
  },
  {
    "path": "cli/src/main/java/tech/amak/portbuddy/cli/utils/HttpUtils.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.cli.utils;\n\nimport java.security.cert.CertificateException;\nimport java.security.cert.X509Certificate;\nimport javax.net.ssl.SSLContext;\nimport javax.net.ssl.TrustManager;\nimport javax.net.ssl.X509TrustManager;\n\nimport lombok.experimental.UtilityClass;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.OkHttpClient;\nimport tech.amak.portbuddy.cli.config.ConfigurationService;\n\n@Slf4j\n@UtilityClass\npublic class HttpUtils {\n\n    /**\n     * Creates a new OkHttpClient with default configuration.\n     * If the application is running in dev mode, it disables SSL verification.\n     *\n     * @return a configured OkHttpClient instance\n     */\n    public static OkHttpClient createClient() {\n        final var builder = new OkHttpClient.Builder();\n        if (ConfigurationService.INSTANCE.isDev()) {\n            configureInsecureSsl(builder);\n        }\n        return builder.build();\n    }\n\n    /**\n     * Configures the provided OkHttpClient.Builder to trust all SSL certificates.\n     * Use with caution, primarily intended for development environments with self-signed certificates.\n     *\n     * @param builder the OkHttpClient.Builder to configure\n     */\n    public static void configureInsecureSsl(final OkHttpClient.Builder builder) {\n        try {\n            final var trustAllCerts = new TrustManager[] {\n                new X509TrustManager() {\n                    @Override\n                    public void checkClientTrusted(final X509Certificate[] chain, final String authType)\n                        throws CertificateException {\n                    }\n\n                    @Override\n                    public void checkServerTrusted(final X509Certificate[] chain, final String authType)\n                        throws CertificateException {\n                    }\n\n                    @Override\n                    public X509Certificate[] getAcceptedIssuers() {\n                        return new X509Certificate[] {};\n                    }\n                }\n            };\n\n            final var sslContext = SSLContext.getInstance(\"SSL\");\n            sslContext.init(null, trustAllCerts, new java.security.SecureRandom());\n            final var sslSocketFactory = sslContext.getSocketFactory();\n\n            builder.sslSocketFactory(sslSocketFactory, (X509TrustManager) trustAllCerts[0]);\n            builder.hostnameVerifier((hostname, session) -> true);\n        } catch (final Exception e) {\n            log.error(\"Failed to configure insecure SSL: {}\", e.toString());\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/tech/amak/portbuddy/cli/utils/JsonUtils.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.cli.utils;\n\nimport com.fasterxml.jackson.databind.DeserializationFeature;\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport lombok.AccessLevel;\nimport lombok.NoArgsConstructor;\n\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\npublic class JsonUtils {\n\n    public static final ObjectMapper MAPPER = new ObjectMapper();\n\n    static {\n        MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);\n    }\n}\n"
  },
  {
    "path": "cli/src/main/resources/META-INF/native-image/tech.amak/port-buddy-cli/reflect-config.json",
    "content": "[\n  {\n    \"name\": \"tech.amak.portbuddy.common.dto.auth.RegisterRequest\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.dto.auth.RegisterResponse\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.dto.auth.TokenExchangeRequest\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.dto.auth.TokenExchangeResponse\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.dto.ExposeRequest\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true,\n    \"queryAllDeclaredMethods\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.dto.ExposeResponse\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true,\n    \"queryAllDeclaredMethods\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.ClientConfig\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.TunnelType\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true\n  },\n  {\n    \"name\": \"java.util.UUID\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.cli.PortBuddy$HostPort\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.tunnel.ControlMessage\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.tunnel.ControlMessage$Type\",\n    \"allPublicMethods\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.tunnel.HttpTunnelMessage\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.tunnel.HttpTunnelMessage$Type\",\n    \"allPublicMethods\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.tunnel.MessageEnvelope\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.tunnel.WsTunnelMessage\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.tunnel.WsTunnelMessage$Type\",\n    \"allPublicMethods\": true\n  },\n  {\n    \"name\": \"tech.amak.portbuddy.common.tunnel.BinaryWsFrame$Decoded\",\n    \"allDeclaredConstructors\": true,\n    \"allPublicMethods\": true,\n    \"allDeclaredFields\": true\n  },\n  {\n    \"name\": \"com.fasterxml.jackson.dataformat.yaml.YAMLFactory\",\n    \"allDeclaredConstructors\": true\n  },\n  {\n    \"name\": \"com.fasterxml.jackson.dataformat.yaml.YAMLGenerator$Feature\",\n    \"allPublicMethods\": true\n  },\n  {\n    \"name\": \"com.fasterxml.jackson.dataformat.yaml.YAMLParser$Feature\",\n    \"allPublicMethods\": true\n  }\n]\n"
  },
  {
    "path": "cli/src/main/resources/META-INF/native-image/tech.amak/port-buddy-cli/resource-config.json",
    "content": "{\n  \"resources\": [\n    {\n      \"pattern\": \"application\\\\.yml\"\n    },\n    {\n      \"pattern\": \"application-dev\\\\.yml\"\n    },\n    {\n      \"pattern\": \"META-INF/services/ch.qos.logback.classic.spi.Configurator\"\n    },\n    {\n      \"pattern\": \"logback\\\\.xml\"\n    },\n    {\n      \"pattern\": \"logback-test\\\\.xml\"\n    }\n  ],\n  \"bundles\": []\n}\n"
  },
  {
    "path": "cli/src/main/resources/application-dev.yml",
    "content": "#serverUrl: \"https://localhost:8443\"\nserverUrl: https://localhost:8443\nlogEnabled: false"
  },
  {
    "path": "cli/src/main/resources/application.yml",
    "content": "serverUrl: \"https://portbuddy.dev\"\nlogEnabled: true\n"
  },
  {
    "path": "cli/src/main/resources/logback.xml",
    "content": "<!--\n  ~ Licensed under the Apache License, Version 2.0 (the \"License\");\n  ~ you may not use this file except in compliance with the License.\n  ~ You may obtain a copy of the License at\n  ~\n  ~     http://www.apache.org/licenses/LICENSE-2.0\n  ~\n  ~ Unless required by applicable law or agreed to in writing, software\n  ~ distributed under the License is distributed on an \"AS IS\" BASIS,\n  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  ~ See the License for the specific language governing permissions and\n  ~ limitations under the License.\n  ~\n  -->\n\n<configuration>\n    <appender name=\"STDOUT\" class=\"ch.qos.logback.core.ConsoleAppender\">\n        <encoder>\n            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>\n        </encoder>\n    </appender>\n\n    <root level=\"error\">\n        <appender-ref ref=\"STDOUT\" />\n    </root>\n</configuration>\n"
  },
  {
    "path": "common/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n    <parent>\n        <groupId>tech.amak</groupId>\n        <artifactId>port-buddy</artifactId>\n        <version>1.0-SNAPSHOT</version>\n    </parent>\n    <artifactId>common</artifactId>\n    <name>port-buddy-common</name>\n    <properties>\n        <maven.compiler.release>25</maven.compiler.release>\n    </properties>\n    <dependencies>\n        <dependency>\n            <groupId>org.projectlombok</groupId>\n            <artifactId>lombok</artifactId>\n            <version>${lombok.version}</version>\n            <scope>provided</scope>\n        </dependency>\n        <dependency>\n            <groupId>com.fasterxml.jackson.core</groupId>\n            <artifactId>jackson-databind</artifactId>\n            <version>2.18.1</version>\n        </dependency>\n    </dependencies>\n</project>\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/ClientConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport lombok.Data;\n\n@Data\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ClientConfig {\n    @JsonProperty(\"apiToken\")\n    private String apiToken;\n\n    @JsonProperty(\"serverUrl\")\n    private String serverUrl;\n\n    @JsonProperty(\"logLinesCount\")\n    private int logLinesCount = 20;\n\n    @JsonProperty(\"logEnabled\")\n    private boolean logEnabled = false;\n\n    @JsonProperty(\"consoleFrameDelay\")\n    private int consoleFrameDelayMs = 200;\n\n    @JsonProperty(\"healthcheckIntervalSec\")\n    private int healthcheckIntervalSec = 5;\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/Plan.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common;\n\n/** Subscription plans. */\npublic enum Plan {\n  PRO,\n  TEAM\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/TunnelType.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common;\n\n/**\n * Supported expose modes.\n */\npublic enum TunnelType {\n    HTTP,\n    TCP,\n    UDP;\n\n    /**\n     * Converts a string representation of a mode to its corresponding {@code Mode} enum value.\n     * If the provided string is {@code null}, defaults to {@code HTTP}.\n     *\n     * @param mode the string representation of the mode, such as \"http\" or \"tcp\".\n     *             Case-insensitive. If {@code null}, the method returns {@code HTTP}.\n     * @return the corresponding {@code Mode} enum value.\n     * @throws IllegalArgumentException if the string does not match any supported mode.\n     */\n    public static TunnelType from(final String mode) {\n        if (mode == null) {\n            return HTTP;\n        }\n        return switch (mode.toLowerCase()) {\n            case \"http\" -> HTTP;\n            case \"tcp\" -> TCP;\n            case \"udp\" -> UDP;\n            default -> throw new IllegalArgumentException(\"Unknown mode: \" + mode);\n        };\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/dto/DnsInstructionsEmailRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.dto;\n\nimport java.time.OffsetDateTime;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\n\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.extern.jackson.Jacksonized;\n\n@Data\n@Builder\n@Jacksonized\npublic class DnsInstructionsEmailRequest {\n    private final UUID jobId;\n    private final String domain;\n    private final String contactEmail;\n    private final List<Map<String, String>> records;\n    private final OffsetDateTime expiresAt;\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/dto/ExposeRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.dto;\n\nimport tech.amak.portbuddy.common.TunnelType;\n\n/** Request to expose a local HTTP/TCP/UDP service. */\npublic record ExposeRequest(\n    TunnelType tunnelType,\n    String scheme,\n    String host,\n    int port,\n    String domain,\n    String portReservation /* optional, format: host:port for TCP/UDP */,\n    String passcode /* optional, for HTTP tunnels only */\n) {}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/dto/ExposeResponse.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.dto;\n\nimport java.util.UUID;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\n\n/**\n * Response with public exposure details.\n */\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic record ExposeResponse(\n    String source,\n    String publicUrl,\n    String publicHost,\n    Integer publicPort,\n    UUID tunnelId,\n    String subdomain\n) {\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/dto/auth/RegisterRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.dto.auth;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class RegisterRequest {\n\n    @JsonProperty(\"email\")\n    private String email;\n\n    @JsonProperty(\"name\")\n    private String name;\n\n    @JsonProperty(\"password\")\n    private String password;\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/dto/auth/RegisterResponse.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.dto.auth;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class RegisterResponse {\n\n    @JsonProperty(\"apiKey\")\n    private String apiKey;\n\n    @JsonProperty(\"success\")\n    private boolean success;\n\n    @JsonProperty(\"message\")\n    private String message;\n\n    @JsonProperty(\"statusCode\")\n    private int statusCode;\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/dto/auth/TokenExchangeRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.dto.auth;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class TokenExchangeRequest {\n\n    @JsonProperty(\"apiToken\")\n    private String apiToken;\n\n    @JsonProperty(\"cliClientVersion\")\n    private String cliClientVersion;\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/dto/auth/TokenExchangeResponse.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.dto.auth;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class TokenExchangeResponse {\n\n    @JsonProperty(\"accessToken\")\n    private String accessToken;\n\n    @JsonProperty(\"tokenType\")\n    private String tokenType;\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/dto/jwks/JwkKey.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.dto.jwks;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class JwkKey {\n\n    @JsonProperty(\"kty\")\n    private String kty;\n\n    @JsonProperty(\"kid\")\n    private String kid;\n\n    @JsonProperty(\"use\")\n    private String use;\n\n    @JsonProperty(\"alg\")\n    private String alg;\n\n    // RSA specific\n    @JsonProperty(\"n\")\n    private String modulus;\n\n    @JsonProperty(\"e\")\n    private String exponent;\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/dto/jwks/JwksResponse.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.dto.jwks;\n\nimport java.util.List;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class JwksResponse {\n\n    @JsonProperty(\"keys\")\n    private List<JwkKey> keys;\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/tunnel/BinaryWsFrame.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.tunnel;\n\nimport java.nio.ByteBuffer;\nimport java.nio.charset.StandardCharsets;\n\n/**\n * Utility to encode/decode binary WebSocket frames for TCP tunneling.\n * Frame format (big-endian):\n * - 2 bytes: unsigned short representing the byte length of the UTF-8 encoded connectionId (N)\n * - N bytes: connectionId UTF-8 bytes\n * - R bytes: raw payload data\n */\npublic final class BinaryWsFrame {\n\n    private BinaryWsFrame() {\n    }\n\n    /**\n     * Encodes the given connection ID and data into a {@link ByteBuffer} following a specific binary\n     * frame format. The encoded frame contains the connection ID length, the UTF-8 encoded connection ID,\n     * and a portion of the data starting at the specified offset and up to the specified length.\n     *\n     * @param connectionId the connection identifier to be encoded (expected to be non-null)\n     * @param data         the raw payload data to be included in the frame (expected to be non-null)\n     * @param offset       the starting position of the data array to be included\n     * @param length       the number of bytes from the data array to be included\n     * @return a {@link ByteBuffer} containing the encoded frame data\n     */\n    public static ByteBuffer encodeToByteBuffer(final String connectionId,\n                                                final byte[] data,\n                                                final int offset,\n                                                final int length) {\n        final var idBytes = connectionId.getBytes(StandardCharsets.UTF_8);\n        final var capacity = 2 + idBytes.length + length;\n        final var buffer = ByteBuffer.allocate(capacity);\n        buffer.putShort((short) (idBytes.length & 0xFFFF));\n        buffer.put(idBytes);\n        buffer.put(data, offset, length);\n        buffer.flip();\n        return buffer;\n    }\n\n    /**\n     * Encodes the given connection ID and data into a byte array following a specific binary\n     * frame format. The encoded frame includes the connection ID length, the UTF-8 encoded\n     * connection ID, and a portion of the data starting at the specified offset and up to the\n     * specified length.\n     *\n     * @param connectionId the connection identifier to be encoded (expected to be non-null)\n     * @param data         the raw payload data to be included in the frame (expected to be non-null)\n     * @param offset       the starting position of the data array to be included\n     * @param length       the number of bytes from the data array to be included\n     * @return a byte array containing the encoded frame data\n     */\n    public static byte[] encodeToArray(final String connectionId,\n                                       final byte[] data,\n                                       final int offset,\n                                       final int length) {\n        final var buffer = encodeToByteBuffer(connectionId, data, offset, length);\n        final var out = new byte[buffer.remaining()];\n        buffer.get(out);\n        buffer.clear();\n        return out;\n    }\n\n    /**\n     * Decodes a binary frame from the provided {@link ByteBuffer} into a {@code Decoded} record object.\n     * The frame is expected to have a specific format, starting with a 2-byte length\n     * field indicating the UTF-8 encoded connection ID's length, followed by the connection ID bytes,\n     * and ending with the remaining data bytes.\n     *\n     * @param buffer the {@link ByteBuffer} containing the binary frame data to decode;\n     *               must have sufficient remaining bytes to represent a valid frame.\n     * @return a {@code Decoded} object containing the connection ID and data extracted from the frame,\n     *     or {@code null} if the buffer does not contain a valid or complete frame.\n     */\n    public static Decoded decode(final ByteBuffer buffer) {\n        if (buffer.remaining() < 2) {\n            return null;\n        }\n        final var length = Short.toUnsignedInt(buffer.getShort());\n        if (buffer.remaining() < length) {\n            return null;\n        }\n        final var idBytes = new byte[length];\n        buffer.get(idBytes);\n        final var connectionId = new String(idBytes, StandardCharsets.UTF_8);\n        final var data = new byte[buffer.remaining()];\n        buffer.get(data);\n        return new Decoded(connectionId, data);\n    }\n\n    /**\n     * Decodes a binary frame from the provided byte array into a {@code Decoded} record object.\n     * The frame is expected to have a specific format, starting with a 2-byte length\n     * field indicating the UTF-8 encoded connection ID's length, followed by the connection ID bytes,\n     * and ending with the remaining data bytes.\n     *\n     * @param frameBytes the byte array containing the binary frame data to decode;\n     *                   must have sufficient bytes to represent a valid frame.\n     * @return a {@code Decoded} object containing the connection ID and data extracted from the frame,\n     *     or {@code null} if the array does not contain a valid or complete frame.\n     */\n    public static Decoded decode(final byte[] frameBytes) {\n        final var byteBuffer = ByteBuffer.wrap(frameBytes);\n        final var decoded = decode(byteBuffer);\n        byteBuffer.clear();\n        return decoded;\n    }\n\n    /**\n     * A record that represents the result of decoding a binary WebSocket frame.\n     * It contains a connection identifier and the corresponding payload data.\n     *\n     * <ul>\n     *   <li>The {@code connectionId} represents the unique identifier of the connection.\n     *   <li>The {@code data} represents the raw payload data associated with the frame.\n     * </ul>\n     * Instances of this record are typically produced by decoding operations on binary\n     * WebSocket frames, which follow a specific format.\n     */\n    public record Decoded(String connectionId, byte[] data) {\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/tunnel/ControlMessage.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.tunnel;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport lombok.Data;\n\n/**\n * Lightweight control envelope for tunnel health/keep-alive messages.\n */\n@Data\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ControlMessage {\n\n    @JsonProperty(\"kind\")\n    private final String kind = \"CTRL\";\n\n    @JsonProperty(\"type\")\n    private Type type;\n\n    /**\n     * Optional unix timestamp millis to help with diagnostics.\n     */\n    @JsonProperty(\"ts\")\n    private Long ts;\n\n    public enum Type {\n        PING,\n        PONG,\n        EXIT\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/tunnel/HttpTunnelMessage.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.tunnel;\n\nimport java.util.List;\nimport java.util.Map;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport lombok.Data;\n\n/**\n * Envelope for HTTP tunnel messages exchanged over WebSocket between server and CLI.\n * To keep it simple, messages are whole-request/whole-response with base64 bodies.\n */\n@Data\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class HttpTunnelMessage {\n\n    /**\n     * Unique ID to correlate request and response.\n     */\n    @JsonProperty(\"id\")\n    private String id;\n\n    /**\n     * Message type.\n     */\n    @JsonProperty(\"type\")\n    private Type type;\n\n    // Request fields\n    @JsonProperty(\"method\")\n    private String method;\n\n    @JsonProperty(\"path\")\n    private String path;\n\n    @JsonProperty(\"query\")\n    private String query;\n\n    @JsonProperty(\"headers\")\n    private Map<String, List<String>> headers;\n\n    /**\n     * Request body encoded as Base64.\n     */\n    @JsonProperty(\"bodyB64\")\n    private String bodyB64;\n\n    /**\n     * Original request body media type (e.g., \"application/json; charset=utf-8\").\n     * The server captures this from the ingress request and the CLI uses it to\n     * reconstruct the upstream request body with the same Content-Type.\n     */\n    @JsonProperty(\"bodyContentType\")\n    private String bodyContentType;\n\n    // Response fields\n    @JsonProperty(\"status\")\n    private Integer status;\n\n    @JsonProperty(\"respHeaders\")\n    private Map<String, List<String>> respHeaders;\n\n    /**\n     * Response body encoded as Base64.\n     */\n    @JsonProperty(\"respBodyB64\")\n    private String respBodyB64;\n\n    public enum Type {\n        REQUEST,\n        RESPONSE\n    }\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/tunnel/MessageEnvelope.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.tunnel;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport lombok.Data;\n\n/**\n * Minimal envelope to route incoming WS messages without using JsonNode.\n * If {@code kind} is null, treat it as an HTTP tunnel message.\n */\n@Data\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class MessageEnvelope {\n\n    @JsonProperty(\"kind\")\n    private String kind; // CTRL, WS or null (HTTP)\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/tunnel/WsTunnelMessage.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.tunnel;\n\nimport java.util.Map;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport lombok.Data;\n\n/**\n * Envelope for WebSocket tunneling over the existing control WebSocket between server and CLI.\n */\n@Data\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class WsTunnelMessage {\n\n    /**\n     * Constant marker to distinguish from HTTP messages.\n     */\n    @JsonProperty(\"kind\")\n    private final String kind = \"WS\";\n\n    /**\n     * Correlates messages of the same WS connection.\n     */\n    @JsonProperty(\"connectionId\")\n    private String connectionId;\n\n    /**\n     * Optional request/response id alignment if needed.\n     */\n    @JsonProperty(\"id\")\n    private String id;\n\n    @JsonProperty(\"wsType\")\n    private Type wsType;\n\n    // For OPEN from server to client\n    @JsonProperty(\"path\")\n    private String path;\n\n    @JsonProperty(\"query\")\n    private String query;\n\n    @JsonProperty(\"headers\")\n    private Map<String, String> headers;\n\n    // Payload\n    @JsonProperty(\"text\")\n    private String text;\n\n    @JsonProperty(\"dataB64\")\n    private String dataB64;\n\n    // Close details\n    @JsonProperty(\"closeCode\")\n    private Integer closeCode;\n\n    @JsonProperty(\"closeReason\")\n    private String closeReason;\n\n    public enum Type {\n        OPEN,\n        OPEN_OK,\n        TEXT,\n        BINARY,\n        CLOSE,\n        ERROR,\n        /**\n         * Control message sent by Net Proxy after WebSocket is established to inform CLI\n         * about the actual exposed public endpoint details (host/port).\n         */\n        EXPOSED\n    }\n\n    // Public endpoint details for EXPOSED message\n    @JsonProperty(\"publicHost\")\n    private String publicHost;\n\n    @JsonProperty(\"publicPort\")\n    private Integer publicPort;\n}\n"
  },
  {
    "path": "common/src/main/java/tech/amak/portbuddy/common/utils/IdUtils.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.common.utils;\n\nimport java.net.URI;\nimport java.util.UUID;\n\npublic class IdUtils {\n\n    private IdUtils() {\n    }\n\n    /**\n     * Extracts the tunnel ID from the given URI by taking the last part of the URI path\n     * and parsing it as a UUID. If the URI is null, its path is null, or the last part\n     * of the path is not a valid UUID, the method will return null.\n     *\n     * @param uri the URI from which the tunnel ID is to be extracted\n     * @return the UUID extracted from the last part of the URI path, or null if the input is invalid\n     */\n    public static UUID extractTunnelId(final URI uri) {\n        if (uri == null) {\n            return null;\n        }\n        final var path = uri.getPath();\n        if (path == null) {\n            return null;\n        }\n        final var parts = path.split(\"/\");\n        final var idPart = parts.length > 0 ? parts[parts.length - 1] : null;\n        return idPart == null\n            ? null\n            : parseUuid(idPart);\n    }\n\n    /**\n     * Parses a given string into a UUID. If the string cannot be parsed as a valid UUID,\n     * the method returns null.\n     *\n     * @param id the string representation of the UUID to be parsed\n     * @return the parsed UUID if the input is valid, or null if the input is invalid\n     */\n    public static UUID parseUuid(final String id) {\n        try {\n            return UUID.fromString(id);\n        } catch (final Exception e) {\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  postgres:\n    image: postgres:16-alpine\n    container_name: pb-db\n    restart: unless-stopped\n    environment:\n      POSTGRES_DB: ${DB_NAME}\n      POSTGRES_USER: ${DB_USER}\n      POSTGRES_PASSWORD: ${DB_PASSWORD}\n    ports:\n      - \"5432:5432\"\n    volumes:\n      - ${PWD}/pg_data:/var/lib/postgresql/data\n    logging:\n      options:\n        max-size: 10M\n\n  eureka:\n    image: cr.yandex/crpop0r0f81mcsvg9kei/port-buddy-eureka:latest\n    container_name: pb-eureka\n    restart: unless-stopped\n    network_mode: host\n    environment:\n      EUREKA_USERNAME: ${EUREKA_USERNAME}\n      EUREKA_PASSWORD: ${EUREKA_PASSWORD}\n    volumes:\n      - ${PWD}/eureka/config:/app/config\n      - ${PWD}/eureka/logs:/app/log\n    logging:\n      options:\n        max-size: 10M\n\n  server:\n    image: cr.yandex/crpop0r0f81mcsvg9kei/port-buddy-server:latest\n    container_name: pb-server\n    restart: unless-stopped\n    network_mode: host\n    environment:\n      DB_NAME: ${DB_NAME}\n      DB_USER: ${DB_USER}\n      DB_PASSWORD: ${DB_PASSWORD}\n      DB_HOST: ${DB_HOST}\n      DB_PORT: ${DB_PORT}\n      SMTP_HOST: ${SMTP_HOST}\n      SMTP_PORT: ${SMTP_PORT}\n      SMTP_USERNAME: ${SMTP_USERNAME}\n      SMTP_PASSWORD: ${SMTP_PASSWORD}\n      STRIPE_API_KEY: ${STRIPE_API_KEY}\n      STRIPE_PRICE_PRO: ${STRIPE_PRICE_PRO}\n      STRIPE_PRICE_TEAM: ${STRIPE_PRICE_TEAM}\n      STRIPE_PRICE_EXTRA_TUNNEL: ${STRIPE_PRICE_EXTRA_TUNNEL}\n      STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}\n      OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID}\n      OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET}\n      OAUTH_GITHUB_CLIENT_ID: ${OAUTH_GITHUB_CLIENT_ID}\n      OAUTH_GITHUB_CLIENT_SECRET: ${OAUTH_GITHUB_CLIENT_SECRET}\n      APP_DOMAIN: ${APP_DOMAIN}\n      JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY}\n      JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}\n      EUREKA_ZONE: ${EUREKA_ZONE}\n      THREATFOX_ENABLED: ${THREATFOX_ENABLED}\n      THREATFOX_AUTH_KEY: ${THREATFOX_AUTH_KEY}\n      JVM_OPTS: -Dspring.profiles.active=prod,default\n    volumes:\n      - ${PWD}/server/config:/app/config\n      - ${PWD}/server/logs:/app/log\n      - ${PWD}/server/jwt_keys:/app/jwt_keys\n    depends_on:\n      - eureka\n      - postgres\n    logging:\n      options:\n        max-size: 100M\n\n  web-app:\n    image: cr.yandex/crpop0r0f81mcsvg9kei/port-buddy-web-app:latest\n    container_name: pb-web-app\n    restart: no\n    volumes:\n      - ${PWD}/web-app:/app/pb-web\n\n  gateway:\n    image: cr.yandex/crpop0r0f81mcsvg9kei/port-buddy-gateway:latest\n    container_name: pb-gateway\n    restart: unless-stopped\n    network_mode: host\n    environment:\n      APP_DOMAIN: ${APP_DOMAIN}\n      EUREKA_ZONE: ${EUREKA_ZONE}\n      JVM_OPTS: -Dspring.profiles.active=prod,default\n    volumes:\n      - ${PWD}/gateway/config:/app/config\n      - ${PWD}/gateway/logs:/app/log\n      - ${PWD}/ssl-service/certs:/app/certs\n      - ${PWD}/web-app:/app/web/dist\n    depends_on:\n      - web-app\n      - eureka\n      - ssl-service\n    logging:\n      options:\n        max-size: 10M\n\n  net-proxy:\n    image: cr.yandex/crpop0r0f81mcsvg9kei/port-buddy-net-proxy:latest\n    container_name: pb-net-proxy\n    restart: unless-stopped\n    network_mode: host\n    environment:\n      NET_PROXY_PUBLIC_HOST: ${NET_PROXY_PUBLIC_HOST}\n      NET_PROXY_REGION: ${NET_PROXY_REGION}\n      NET_PROXY_COORDINATES: ${NET_PROXY_COORDINATES}\n      EUREKA_ZONE: ${EUREKA_ZONE}\n    volumes:\n      - ${PWD}/net-proxy/config:/app/config\n      - ${PWD}/net-proxy/logs:/app/log\n    depends_on:\n      - eureka\n      - ssl-service\n    logging:\n      options:\n        max-size: 10M\n\n  ssl-service:\n    image: cr.yandex/crpop0r0f81mcsvg9kei/port-buddy-ssl-service:latest\n    container_name: pb-ssl-service\n    restart: unless-stopped\n    network_mode: host\n    environment:\n      DB_NAME: ${DB_NAME}\n      DB_USER: ${DB_USER}\n      DB_PASSWORD: ${DB_PASSWORD}\n      EUREKA_ZONE: ${EUREKA_ZONE}\n    volumes:\n      - ${PWD}/ssl-service/config:/app/config\n      - ${PWD}/ssl-service/logs:/app/log\n      - ${PWD}/ssl-service/certs:/app/certs\n    depends_on:\n      - eureka\n      - postgres\n    logging:\n      options:\n        max-size: 10M\n"
  },
  {
    "path": "entrypoint-cli-native.sh",
    "content": "#!/bin/sh\nexec /app/portbuddy \"$@\"\n"
  },
  {
    "path": "entrypoint-web.sh",
    "content": "#!/bin/sh\necho \"Starting\" && \\\n  ls -al /app/dist/ && \\\n  mkdir -p /app/pb-web && \\\n  echo \"Dir /app/pb-web/ (re)created\" && \\\n  rm -rf /app/pb-web/* && \\\n  echo \"Dir /app/pb-web/ cleaned\" && \\\n  cp -a /app/dist/. /app/pb-web/ && \\\n  echo \"Files copied to /app/pb-web/ folder\" && \\\n  ls -al /app/pb-web/\n"
  },
  {
    "path": "entrypoint.sh",
    "content": "#!/bin/sh\nexec java $JVM_OPTS -jar /app/app.jar $@\n"
  },
  {
    "path": "eureka/HELP.md",
    "content": "# Getting Started\n\n### Reference Documentation\nFor further reference, please consider the following sections:\n\n* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html)\n* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.7/maven-plugin)\n* [Create an OCI image](https://docs.spring.io/spring-boot/3.5.7/maven-plugin/build-image.html)\n* [Eureka Server](https://docs.spring.io/spring-cloud-netflix/reference/spring-cloud-netflix.html#spring-cloud-eureka-server)\n\n### Guides\nThe following guides illustrate how to use some features concretely:\n\n* [Service Registration and Discovery with Eureka and Spring Cloud](https://spring.io/guides/gs/service-registration-and-discovery/)\n\n### Maven Parent overrides\n\nDue to Maven's design, elements are inherited from the parent POM to the project POM.\nWhile most of the inheritance is fine, it also inherits unwanted elements like `<license>` and `<developers>` from the parent.\nTo prevent this, the project POM contains empty overrides for these elements.\nIf you manually switch to a different parent and actually want the inheritance, you need to remove those overrides.\n\n"
  },
  {
    "path": "eureka/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n\txsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd\">\n\t<modelVersion>4.0.0</modelVersion>\n    <parent>\n        <groupId>tech.amak</groupId>\n        <artifactId>port-buddy</artifactId>\n        <version>1.0-SNAPSHOT</version>\n    </parent>\n\n\t<artifactId>eureka</artifactId>\n\t<version>0.0.1-SNAPSHOT</version>\n\t<name>eureka</name>\n\t<description>Discovery Server</description>\n\n\t<properties>\n\t\t<java.version>25</java.version>\n\t</properties>\n\t<dependencies>\n\t\t<dependency>\n\t\t\t<groupId>org.springframework.cloud</groupId>\n\t\t\t<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>\n\t\t</dependency>\n\n\t\t<dependency>\n\t\t\t<groupId>org.springframework.boot</groupId>\n\t\t\t<artifactId>spring-boot-starter-security</artifactId>\n\t\t</dependency>\n\n\t\t<dependency>\n\t\t\t<groupId>org.springframework.boot</groupId>\n\t\t\t<artifactId>spring-boot-starter-test</artifactId>\n\t\t\t<scope>test</scope>\n\t\t</dependency>\n\t</dependencies>\n\t<build>\n\t\t<plugins>\n\t\t\t<plugin>\n\t\t\t\t<groupId>org.springframework.boot</groupId>\n\t\t\t\t<artifactId>spring-boot-maven-plugin</artifactId>\n                <executions>\n                    <execution>\n                        <goals>\n                            <goal>repackage</goal>\n                        </goals>\n                    </execution>\n                </executions>\n\t\t\t</plugin>\n\t\t</plugins>\n\t</build>\n\n</project>\n"
  },
  {
    "path": "eureka/src/main/java/tech/amak/portbuddy/eureka/EurekaApplication.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.eureka;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.boot.context.properties.ConfigurationPropertiesScan;\nimport org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;\n\n@SpringBootApplication\n@ConfigurationPropertiesScan\n@EnableEurekaServer\npublic class EurekaApplication {\n\n    public static void main(final String[] args) {\n        SpringApplication.run(EurekaApplication.class, args);\n    }\n\n}\n"
  },
  {
    "path": "eureka/src/main/java/tech/amak/portbuddy/eureka/security/SecurityConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.eureka.security;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.core.annotation.Order;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;\nimport org.springframework.security.web.SecurityFilterChain;\n\n@Configuration\n@EnableWebSecurity\n@EnableMethodSecurity\npublic class SecurityConfig {\n\n    @Bean\n    @Order(1)\n    public SecurityFilterChain apiSecurityFilterChain(final HttpSecurity http) throws Exception {\n        http.authorizeHttpRequests((authz) -> authz\n                .anyRequest().authenticated())\n            .httpBasic(Customizer.withDefaults());\n        http.csrf(csrf -> csrf.ignoringRequestMatchers(\"/eureka/**\"));\n        return http.build();\n    }\n}\n"
  },
  {
    "path": "eureka/src/main/resources/application.yml",
    "content": "server:\n  port: 8761\n\neureka:\n  instance:\n    hostname: localhost\n  client:\n    service-url:\n      defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka\n    register-with-eureka: false\n    fetch-registry: false\n\nspring:\n  application:\n    name: eureka\n\n  security:\n    user:\n      name: ${EUREKA_USERNAME:portbuddy}\n      password: ${EUREKA_PASSWORD:portbuddy}\n\nlogging:\n  level:\n    root: warn\n  file:\n    name: log/app.log\n  logback:\n    rollingpolicy:\n      max-history: 14\n      total-size-cap: 100MB\n      max-file-size: 10MB\n"
  },
  {
    "path": "eureka/src/test/java/tech/amak/portbuddy/eureka/EurekaApplicationTests.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.eureka;\n\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.test.context.SpringBootTest;\n\n@SpringBootTest\nclass EurekaApplicationTests {\n\n    @Test\n    void contextLoads() {\n    }\n\n}\n"
  },
  {
    "path": "gateway/HELP.md",
    "content": "# Getting Started\n\n### Reference Documentation\nFor further reference, please consider the following sections:\n\n* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html)\n* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.7/maven-plugin)\n* [Create an OCI image](https://docs.spring.io/spring-boot/3.5.7/maven-plugin/build-image.html)\n* [GraalVM Native Image Support](https://docs.spring.io/spring-boot/3.5.7/reference/packaging/native-image/introducing-graalvm-native-images.html)\n* [Spring Configuration Processor](https://docs.spring.io/spring-boot/3.5.7/specification/configuration-metadata/annotation-processor.html)\n* [Reactive Gateway](https://docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway.html)\n\n### Guides\nThe following guides illustrate how to use some features concretely:\n\n* [Using Spring Cloud Gateway](https://github.com/spring-cloud-samples/spring-cloud-gateway-sample)\n\n### Additional Links\nThese additional references should also help you:\n\n* [Configure AOT settings in Build Plugin](https://docs.spring.io/spring-boot/3.5.7/how-to/aot.html)\n\n## GraalVM Native Support\n\nThis project has been configured to let you generate either a lightweight container or a native executable.\nIt is also possible to run your tests in a native image.\n\n### Lightweight Container with Cloud Native Buildpacks\nIf you're already familiar with Spring Boot container images support, this is the easiest way to get started.\nDocker should be installed and configured on your machine prior to creating the image.\n\nTo create the image, run the following goal:\n\n```\n$ ./mvnw spring-boot:build-image -Pnative\n```\n\nThen, you can run the app like any other container:\n\n```\n$ docker run --rm gateway:0.0.1-SNAPSHOT\n```\n\n### Executable with Native Build Tools\nUse this option if you want to explore more options such as running your tests in a native image.\nThe GraalVM `native-image` compiler should be installed and configured on your machine.\n\nNOTE: GraalVM 22.3+ is required.\n\nTo create the executable, run the following goal:\n\n```\n$ ./mvnw native:compile -Pnative\n```\n\nThen, you can run the app as follows:\n```\n$ target/gateway\n```\n\nYou can also run your existing tests suite in a native image.\nThis is an efficient way to validate the compatibility of your application.\n\nTo run your existing tests in a native image, run the following goal:\n\n```\n$ ./mvnw test -PnativeTest\n```\n\n\n### Maven Parent overrides\n\nDue to Maven's design, elements are inherited from the parent POM to the project POM.\nWhile most of the inheritance is fine, it also inherits unwanted elements like `<license>` and `<developers>` from the parent.\nTo prevent this, the project POM contains empty overrides for these elements.\nIf you manually switch to a different parent and actually want the inheritance, you need to remove those overrides.\n\n"
  },
  {
    "path": "gateway/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n\n    <parent>\n        <groupId>tech.amak</groupId>\n        <artifactId>port-buddy</artifactId>\n        <version>1.0-SNAPSHOT</version>\n    </parent>\n\n    <artifactId>gateway</artifactId>\n    <version>0.0.1-SNAPSHOT</version>\n    <name>api-gateway</name>\n    <description>API Gateway to proxy incoming requests to downstream</description>\n\n    <properties>\n        <java.version>25</java.version>\n    </properties>\n\n    <dependencies>\n        <dependency>\n            <groupId>org.springframework.cloud</groupId>\n            <artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-security</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.cloud</groupId>\n            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-configuration-processor</artifactId>\n            <optional>true</optional>\n        </dependency>\n        <dependency>\n            <groupId>org.projectlombok</groupId>\n            <artifactId>lombok</artifactId>\n            <optional>true</optional>\n        </dependency>\n        <dependency>\n            <groupId>com.github.ben-manes.caffeine</groupId>\n            <artifactId>caffeine</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.bouncycastle</groupId>\n            <artifactId>bcpkix-jdk18on</artifactId>\n            <version>1.80</version>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-test</artifactId>\n            <scope>test</scope>\n        </dependency>\n        <dependency>\n            <groupId>io.projectreactor</groupId>\n            <artifactId>reactor-test</artifactId>\n            <scope>test</scope>\n        </dependency>\n    </dependencies>\n\n    <build>\n        <plugins>\n            <!-- Inherit maven-compiler-plugin configuration (release, lombok processor) from parent -->\n            <plugin>\n                <groupId>org.springframework.boot</groupId>\n                <artifactId>spring-boot-maven-plugin</artifactId>\n                <configuration>\n                    <excludes>\n                        <exclude>\n                            <groupId>org.projectlombok</groupId>\n                            <artifactId>lombok</artifactId>\n                        </exclude>\n                    </excludes>\n                </configuration>\n                <executions>\n                    <execution>\n                        <goals>\n                            <goal>repackage</goal>\n                        </goals>\n                    </execution>\n                </executions>\n            </plugin>\n        </plugins>\n    </build>\n\n</project>\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/ApiGatewayApplication.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.boot.context.properties.ConfigurationPropertiesScan;\n\n@SpringBootApplication\n@ConfigurationPropertiesScan\npublic class ApiGatewayApplication {\n\n    public static void main(final String[] args) {\n        SpringApplication.run(ApiGatewayApplication.class, args);\n    }\n\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/client/SslServiceClient.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.client;\n\nimport java.time.Duration;\n\nimport org.springframework.stereotype.Service;\nimport org.springframework.web.reactive.function.client.WebClient;\n\nimport lombok.extern.slf4j.Slf4j;\nimport reactor.core.publisher.Mono;\nimport tech.amak.portbuddy.gateway.dto.CertificateResponse;\n\n@Service\n@Slf4j\npublic class SslServiceClient {\n\n    private final WebClient webClient;\n\n    /**\n     * Constructs an instance of SslServiceClient with a load-balanced WebClient configured\n     * to interact with the ssl-service.\n     *\n     * @param loadBalancedWebClientBuilder the WebClient.Builder instance used to configure\n     *                                     the load-balanced WebClient for communication with\n     *                                     the ssl-service\n     */\n    public SslServiceClient(final WebClient.Builder loadBalancedWebClientBuilder) {\n        this.webClient = loadBalancedWebClientBuilder\n            .baseUrl(\"lb://ssl-service\")\n            .build();\n    }\n\n    /**\n     * Retrieves certificate metadata for a given domain from the ssl-service.\n     *\n     * @param domain domain name\n     * @return certificate response mono\n     */\n    public Mono<CertificateResponse> getCertificate(final String domain) {\n        return webClient.get()\n            .uri(\"/internal/api/certificates/{domain}\", domain)\n            .retrieve()\n            .bodyToMono(CertificateResponse.class)\n            .timeout(Duration.ofSeconds(5))\n            .onErrorResume(e -> {\n                log.warn(\"Failed to retrieve certificate for domain [{}]: {}\", domain, e.getMessage());\n                return Mono.empty();\n            });\n    }\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/config/AppProperties.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.config;\n\nimport org.springframework.boot.context.properties.ConfigurationProperties;\nimport org.springframework.core.io.Resource;\n\n@ConfigurationProperties(prefix = \"app\")\npublic record AppProperties(\n    int httpPort,\n    String domain,\n    String url,\n    String serverErrorPage,\n    Jwt jwt,\n    Ssl ssl\n) {\n\n    public record Ssl(\n        boolean enabled,\n        Certificate fallback\n    ) {\n    }\n\n    public record Certificate(\n        boolean enabled,\n        Resource keyCertChainFile,\n        Resource keyFile\n    ) {\n    }\n\n    public record Jwt(\n        String issuer,\n        String jwkSetUri\n    ) {\n    }\n\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/config/GlobalExceptionHandler.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.config;\n\nimport static java.nio.charset.StandardCharsets.UTF_8;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.ProblemDetail;\nimport org.springframework.web.bind.annotation.ControllerAdvice;\nimport org.springframework.web.bind.annotation.ExceptionHandler;\nimport org.springframework.web.reactive.result.view.RedirectView;\nimport org.springframework.web.server.ResponseStatusException;\nimport org.springframework.web.server.ServerWebExchange;\nimport org.springframework.web.util.UriUtils;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\n\n@ControllerAdvice\n@Slf4j\n@RequiredArgsConstructor\npublic class GlobalExceptionHandler {\n\n    private final AppProperties properties;\n\n    @ExceptionHandler(ResponseStatusException.class)\n    public ProblemDetail handleIllegalArgumentException(final ResponseStatusException ex) {\n        log.error(\"Status exception: Status: {} - {}\", ex.getStatusCode(), ex.getMessage());\n        return ProblemDetail.forStatusAndDetail(ex.getStatusCode(), ex.getMessage());\n    }\n\n    /**\n     * Handles all uncaught exceptions by logging the error and redirecting to a server error page\n     * with the original request URI encoded as a retry parameter.\n     *\n     * @param ex       the exception that was thrown\n     * @param exchange the current server web exchange containing the request details\n     * @return a {@link RedirectView} pointing to the configured server error page with a retry parameter\n     */\n    @ExceptionHandler(Exception.class)\n    public RedirectView handleGenericException(final Exception ex, final ServerWebExchange exchange) {\n        log.error(\"Global exception: {}\", ex.getMessage(), ex);\n        final var string = exchange.getRequest().getURI().toString();\n        final var redirectUri = properties.serverErrorPage() + \"?retry=\" + UriUtils.encode(string, UTF_8);\n        return new RedirectView(redirectUri, HttpStatus.TEMPORARY_REDIRECT);\n    }\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/config/LoadBalancerClientsConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.config;\n\nimport org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient;\nimport org.springframework.cloud.loadbalancer.annotation.LoadBalancerClients;\nimport org.springframework.context.annotation.Configuration;\n\n/**\n * Registers custom load balancer configuration for specific downstream services.\n */\n@Configuration\n@LoadBalancerClients({\n    @LoadBalancerClient(name = \"port-buddy-server\", configuration = PortBuddyServerLoadBalancerConfiguration.class),\n    @LoadBalancerClient(name = \"net-proxy\", configuration = NetProxyLoadBalancerConfiguration.class)\n})\npublic class LoadBalancerClientsConfig {\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/config/NetProxyLoadBalancerConfiguration.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.config;\n\nimport org.springframework.beans.factory.ObjectProvider;\nimport org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer;\nimport org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;\nimport org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.core.env.Environment;\n\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.gateway.loadbalancer.NetProxyPublicHostLoadBalancer;\n\n@Slf4j\npublic class NetProxyLoadBalancerConfiguration {\n\n    @Bean\n    public ReactorServiceInstanceLoadBalancer reactorServiceInstanceLoadBalancer(\n        final Environment environment,\n        final LoadBalancerClientFactory loadBalancerClientFactory\n    ) {\n        final var serviceId = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);\n        final ObjectProvider<ServiceInstanceListSupplier> provider =\n            loadBalancerClientFactory.getLazyProvider(serviceId, ServiceInstanceListSupplier.class);\n        log.info(\"Created NetProxyPublicHostLoadBalancer for service {}\", serviceId);\n        return new NetProxyPublicHostLoadBalancer(provider, serviceId);\n    }\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/config/PortBuddyServerLoadBalancerConfiguration.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.config;\n\nimport org.springframework.beans.factory.ObjectProvider;\nimport org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer;\nimport org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;\nimport org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.core.env.Environment;\n\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.gateway.loadbalancer.PortBuddySubdomainLoadBalancer;\n\n@Slf4j\npublic class PortBuddyServerLoadBalancerConfiguration {\n\n    @Bean\n    public ReactorServiceInstanceLoadBalancer reactorServiceInstanceLoadBalancer(\n        final Environment environment,\n        final LoadBalancerClientFactory loadBalancerClientFactory\n    ) {\n        final var serviceId = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);\n        final ObjectProvider<ServiceInstanceListSupplier> provider =\n            loadBalancerClientFactory.getLazyProvider(serviceId, ServiceInstanceListSupplier.class);\n        final var loadBalancer = new PortBuddySubdomainLoadBalancer(provider, serviceId);\n        log.info(\"Created PortBuddySubdomainLoadBalancer for service {}\", serviceId);\n        return loadBalancer;\n    }\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/config/SslServerConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.config;\n\nimport org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory;\nimport org.springframework.boot.web.server.WebServerFactoryCustomizer;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.core.Ordered;\nimport org.springframework.core.annotation.Order;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.server.reactive.HttpHandler;\nimport org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;\n\nimport io.netty.handler.codec.http.HttpHeaderNames;\nimport io.netty.handler.ssl.SniHandler;\nimport jakarta.annotation.PostConstruct;\nimport jakarta.annotation.PreDestroy;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport reactor.netty.DisposableServer;\nimport reactor.netty.http.server.HttpServer;\nimport tech.amak.portbuddy.gateway.ssl.SniSslContextMapping;\n\n@Configuration\n@Slf4j\n@RequiredArgsConstructor\npublic class SslServerConfig {\n\n    private final AppProperties properties;\n    private final SniSslContextMapping sniSslContextMapping;\n    private final HttpHandler httpHandler;\n    private DisposableServer httpServer;\n\n    /**\n     * Customizes Netty server to support dynamic SSL termination via SNI.\n     * The main server will be SSL-enabled.\n     *\n     * @return NettyServerCustomizer\n     */\n    @Bean\n    @Order(Ordered.HIGHEST_PRECEDENCE)\n    public WebServerFactoryCustomizer<NettyReactiveWebServerFactory> sslCustomizer() {\n        return factory -> factory.addServerCustomizers(server -> {\n            if (properties.ssl().enabled()) {\n                // We use doOnChannelInit to configure the pipeline at the transport level.\n                // This ensures SniHandler is added before any data is read and enables dynamic SSL via SNI.\n                server = server.doOnChannelInit((observer, channel, remoteAddress) -> {\n                    channel.pipeline().addFirst(\"sni-handler\", new SniHandler(sniSslContextMapping));\n                });\n            }\n\n            return server.httpRequestDecoder(spec -> spec.allowDuplicateContentLengths(true)\n                .maxInitialLineLength(65536)\n                .maxHeaderSize(65536)\n                .maxChunkSize(65536)\n                .validateHeaders(false));\n        });\n    }\n\n    /**\n     * Starts an HTTP server to handle incoming requests. If SSL is enabled, the server redirects\n     * non-secure requests to the HTTPS endpoint. The server also handles requests to the ACME\n     * challenge endpoint for SSL certificate validation.\n     * Behavior details:\n     * - If SSL is enabled:\n     * - Requests to paths starting with \"/.well-known/acme-challenge/\" are processed directly by\n     * the {@code httpHandler} via the {@code ReactorHttpHandlerAdapter}.\n     * - Requests to other paths:\n     * - If the request does not include a \"Host\" header, a {@code 400 Bad Request} response\n     * is returned.\n     * - If the \"Host\" header is present, the server constructs a redirect URL based on the\n     * secure port provided in the SSL properties. Non-secure requests are redirected to\n     * their HTTPS equivalents with an appropriate {@code 301 Moved Permanently} response.\n     * Prerequisites:\n     * - The server requires SSL properties configuration to determine if SSL is enabled and to\n     * identify the secure port for redirections.\n     * - The {@code httpHandler} must be set up to process ACME challenge requests or other\n     * required application logic.\n     * This method is annotated with {@code @PostConstruct}, ensuring it is executed automatically\n     * during the initialization phase of the containing class.\n     */\n    @PostConstruct\n    public void startHttpServer() {\n        if (properties.ssl().enabled()) {\n            final var adapter = new ReactorHttpHandlerAdapter(httpHandler);\n            this.httpServer = HttpServer.create()\n                .port(properties.httpPort())\n                .handle((request, response) -> {\n                    final var path = request.uri();\n                    if (path.startsWith(\"/.well-known/acme-challenge/\")) {\n                        return adapter.apply(request, response);\n                    } else {\n                        final var host = request.requestHeaders().get(HttpHeaderNames.HOST);\n                        if (host == null) {\n                            response.status(HttpStatus.BAD_REQUEST.value());\n                            return response.send();\n                        }\n\n                        final var redirectUrl = properties.url() + path;\n\n                        response.status(HttpStatus.MOVED_PERMANENTLY.value());\n                        response.header(HttpHeaderNames.LOCATION, redirectUrl);\n                        return response.send();\n                    }\n                })\n                .bindNow();\n        }\n    }\n\n    /**\n     * Stops the currently running HTTP server, if it is initialized.\n     * This method is invoked automatically when the containing class is being destroyed,\n     * as indicated by the {@code @PreDestroy} annotation. It ensures proper release of resources\n     * by shutting down the HTTP server. If no server instance is present, the method exits quietly.\n     * Behavior:\n     * - If the {@code httpServer} is non-null, it invokes {@code disposeNow()} to stop the server immediately.\n     * Prerequisites:\n     * - A valid {@code httpServer} instance must exist for this method to perform the shutdown process.\n     * If the instance is null, the method performs no action.\n     * Usage context:\n     * - Typically used in applications with lifecycle management to ensure clean shutdown\n     * and resource deallocation.\n     */\n    @PreDestroy\n    public void stopHttpServer() {\n        if (this.httpServer != null) {\n            this.httpServer.disposeNow();\n        }\n    }\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/config/WebClientConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.config;\n\nimport org.springframework.cloud.client.loadbalancer.LoadBalanced;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.web.reactive.function.client.WebClient;\n\n@Configuration\npublic class WebClientConfig {\n\n    @Bean\n    @LoadBalanced\n    public WebClient.Builder loadBalancedWebClientBuilder() {\n        return WebClient.builder();\n    }\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/dto/CertificateResponse.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.dto;\n\npublic record CertificateResponse(\n    String domain,\n    String certificatePath,\n    String privateKeyPath,\n    String chainPath,\n    String fullChainPath\n) {\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/filter/PortBuddyRewritePathGatewayFilterFactory.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.filter;\n\nimport static org.springframework.cloud.gateway.support.GatewayToStringStyler.filterToStringCreator;\nimport static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR;\nimport static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.addOriginalRequestUrl;\n\nimport java.util.concurrent.atomic.AtomicReference;\nimport java.util.regex.Pattern;\n\nimport org.springframework.cloud.gateway.filter.GatewayFilter;\nimport org.springframework.cloud.gateway.filter.GatewayFilterChain;\nimport org.springframework.cloud.gateway.filter.factory.RewritePathGatewayFilterFactory;\nimport org.springframework.cloud.gateway.support.ServerWebExchangeUtils;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.server.ServerWebExchange;\n\nimport reactor.core.publisher.Mono;\n\n@Component\npublic class PortBuddyRewritePathGatewayFilterFactory extends RewritePathGatewayFilterFactory {\n\n    @Override\n    public GatewayFilter apply(final Config config) {\n\n        final var replacement = config.getReplacement().replace(\"$\\\\\", \"$\");\n        final var pattern = Pattern.compile(config.getRegexp());\n\n        return new GatewayFilter() {\n            @Override\n            public Mono<Void> filter(final ServerWebExchange exchange, final GatewayFilterChain chain) {\n                final var request = exchange.getRequest();\n                addOriginalRequestUrl(exchange, request.getURI());\n                final var path = request.getURI().getRawPath();\n\n                final var replacementReference = new AtomicReference<>(replacement);\n\n                final var uriTemplateVariables = ServerWebExchangeUtils.getUriTemplateVariables(exchange);\n                uriTemplateVariables.forEach((key, value) -> {\n                    final var placeholder = \"${%s}\".formatted(key);\n                    var cleanValue = value;\n                    if (\"customDomain\".equals(key)) {\n                        final var colonIdx = cleanValue.indexOf(':');\n                        if (colonIdx > 0) {\n                            cleanValue = cleanValue.substring(0, colonIdx);\n                        }\n                    }\n                    replacementReference.set(replacementReference.get().replace(placeholder, cleanValue));\n                });\n\n                // Fallback for customDomain and support for host if they are not in uriTemplateVariables\n                if (replacementReference.get().contains(\"${customDomain}\")\n                    || replacementReference.get().contains(\"${host}\")) {\n                    var host = request.getHeaders().getFirst(\"Host\");\n                    if (host != null) {\n                        final var colonIdx = host.indexOf(':');\n                        if (colonIdx > 0) {\n                            host = host.substring(0, colonIdx);\n                        }\n                        replacementReference.set(replacementReference.get().replace(\"${customDomain}\", host));\n                        replacementReference.set(replacementReference.get().replace(\"${host}\", host));\n                    }\n                }\n\n                final var newPath = pattern.matcher(path).replaceAll(replacementReference.get());\n\n                final var mutatedRequest = request.mutate().path(newPath).build();\n\n                exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, mutatedRequest.getURI());\n\n                return chain.filter(exchange.mutate().request(mutatedRequest).build());\n            }\n\n            @Override\n            public String toString() {\n                return filterToStringCreator(PortBuddyRewritePathGatewayFilterFactory.this)\n                    .append(config.getRegexp(), replacement)\n                    .toString();\n            }\n        };\n    }\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/loadbalancer/NetProxyPublicHostLoadBalancer.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.loadbalancer;\n\nimport java.net.URI;\nimport java.net.URLDecoder;\nimport java.nio.charset.StandardCharsets;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\n\nimport org.springframework.beans.factory.ObjectProvider;\nimport org.springframework.cloud.client.ServiceInstance;\nimport org.springframework.cloud.client.loadbalancer.DefaultResponse;\nimport org.springframework.cloud.client.loadbalancer.EmptyResponse;\nimport org.springframework.cloud.client.loadbalancer.Request;\nimport org.springframework.cloud.client.loadbalancer.RequestData;\nimport org.springframework.cloud.client.loadbalancer.RequestDataContext;\nimport org.springframework.cloud.client.loadbalancer.Response;\nimport org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer;\nimport org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;\nimport org.springframework.util.StringUtils;\n\nimport reactor.core.publisher.Mono;\n\n/**\n * A custom load balancer for the {@code net-proxy} service that selects an instance\n * by matching a request query parameter {@code public-host} against the instance\n * metadata {@code public-host} published to Eureka. If the parameter is missing or\n * no instance matches, the first available instance is selected.\n */\npublic class NetProxyPublicHostLoadBalancer implements ReactorServiceInstanceLoadBalancer {\n\n    private static final String METADATA_PUBLIC_HOST = \"public-host\";\n    private static final String QUERY_PARAM_PUBLIC_HOST = \"public-host\";\n\n    private final ObjectProvider<ServiceInstanceListSupplier> supplierProvider;\n    private final String serviceId;\n\n    /**\n     * Constructs load balancer.\n     *\n     * @param supplierProvider supplier provider\n     * @param serviceId        target service id\n     */\n    public NetProxyPublicHostLoadBalancer(final ObjectProvider<ServiceInstanceListSupplier> supplierProvider,\n                                          final String serviceId) {\n        this.supplierProvider = supplierProvider;\n        this.serviceId = serviceId;\n    }\n\n    @Override\n    public Mono<Response<ServiceInstance>> choose(final Request request) {\n        final var supplier = supplierProvider.getIfAvailable();\n        if (supplier == null) {\n            return Mono.just(new EmptyResponse());\n        }\n\n        final var requestedPublicHost = extractRequestedPublicHost(request);\n\n        return supplier.get().next().map(instances -> {\n            if (instances == null || instances.isEmpty()) {\n                return new EmptyResponse();\n            }\n\n            if (!StringUtils.hasText(requestedPublicHost)) {\n                return new DefaultResponse(instances.getFirst());\n            }\n\n            final var matched = findByPublicHost(instances, requestedPublicHost);\n            if (matched != null) {\n                return new DefaultResponse(matched);\n            }\n\n            return new DefaultResponse(instances.getFirst());\n        });\n    }\n\n    private ServiceInstance findByPublicHost(final List<ServiceInstance> instances, final String publicHost) {\n        for (final var instance : instances) {\n            final Map<String, String> metadata = instance.getMetadata();\n            if (metadata == null) {\n                continue;\n            }\n            final var instanceHost = metadata.get(METADATA_PUBLIC_HOST);\n            if (instanceHost != null && instanceHost.equalsIgnoreCase(publicHost)) {\n                return instance;\n            }\n        }\n        return null;\n    }\n\n    private String extractRequestedPublicHost(final Request request) {\n        if (!(request.getContext() instanceof RequestDataContext context)) {\n            return null;\n        }\n        final RequestData data = context.getClientRequest();\n        if (data == null) {\n            return null;\n        }\n        final URI url = data.getUrl();\n        if (url == null) {\n            return null;\n        }\n        final var query = url.getQuery();\n        if (!StringUtils.hasText(query)) {\n            return null;\n        }\n        final var pairs = query.split(\"&\");\n        for (final var pair : pairs) {\n            final var idx = pair.indexOf('=');\n            if (idx <= 0) {\n                continue;\n            }\n            final var name = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8);\n            if (!Objects.equals(name, QUERY_PARAM_PUBLIC_HOST)) {\n                continue;\n            }\n            return URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8);\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/loadbalancer/PortBuddySubdomainLoadBalancer.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.loadbalancer;\n\nimport java.net.URI;\nimport java.time.Duration;\nimport java.util.List;\n\nimport org.springframework.beans.factory.ObjectProvider;\nimport org.springframework.cloud.client.ServiceInstance;\nimport org.springframework.cloud.client.loadbalancer.DefaultResponse;\nimport org.springframework.cloud.client.loadbalancer.EmptyResponse;\nimport org.springframework.cloud.client.loadbalancer.Request;\nimport org.springframework.cloud.client.loadbalancer.RequestDataContext;\nimport org.springframework.cloud.client.loadbalancer.Response;\nimport org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer;\nimport org.springframework.cloud.loadbalancer.core.RoundRobinLoadBalancer;\nimport org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.util.StringUtils;\nimport org.springframework.web.reactive.function.client.WebClient;\n\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\n/**\n * Custom load balancer that, for subdomain ingress requests, chooses the server instance\n * that currently holds an active tunnel for the requested subdomain. If no instance confirms\n * ownership, it falls back to the first instance from the list. For all other requests,\n * it delegates to round-robin.\n */\npublic class PortBuddySubdomainLoadBalancer implements ReactorServiceInstanceLoadBalancer {\n\n    private final ObjectProvider<ServiceInstanceListSupplier> supplierProvider;\n    private final String serviceId;\n    private final RoundRobinLoadBalancer roundRobin;\n    private final WebClient webClient;\n\n    /**\n     * Constructor.\n     *\n     * @param supplierProvider the service instance supplier provider\n     * @param serviceId        service ID\n     */\n    public PortBuddySubdomainLoadBalancer(final ObjectProvider<ServiceInstanceListSupplier> supplierProvider,\n                                          final String serviceId) {\n        this.supplierProvider = supplierProvider;\n        this.serviceId = serviceId;\n        this.roundRobin = new RoundRobinLoadBalancer(supplierProvider, serviceId);\n        this.webClient = WebClient.builder().build();\n    }\n\n    @Override\n    public Mono<Response<ServiceInstance>> choose(final Request request) {\n        // Extract subdomain or custom domain from host if present; otherwise delegate.\n        final var host = extractHost(request);\n        if (!StringUtils.hasText(host)) {\n            return roundRobin.choose(request);\n        }\n\n        final var supplier = supplierProvider.getIfAvailable();\n        if (supplier == null) {\n            return Mono.just(new EmptyResponse());\n        }\n\n        final var isCustomDomain = isCustomDomain(host);\n        final var target = isCustomDomain ? host : extractSubdomain(host);\n\n        if (!StringUtils.hasText(target)) {\n            return roundRobin.choose(request);\n        }\n\n        return supplier.get().next().flatMap(instances -> {\n            if (instances == null || instances.isEmpty()) {\n                return Mono.just(new EmptyResponse());\n            }\n\n            // Probe all instances concurrently; pick the first that returns 200 OK.\n            final var probeTimeout = Duration.ofMillis(500);\n            return findOwningInstance(instances, target, isCustomDomain, probeTimeout)\n                .map(DefaultResponse::new)\n                .switchIfEmpty(Mono.just(new DefaultResponse(instances.getFirst())));\n        });\n    }\n\n    private Mono<ServiceInstance> findOwningInstance(final List<ServiceInstance> instances,\n                                                     final String target,\n                                                     final boolean isCustomDomain,\n                                                     final Duration timeout) {\n        return Flux.fromIterable(instances)\n            .flatMap(instance -> checkInstance(instance, target, isCustomDomain, timeout)\n                .onErrorResume(ex -> Mono.empty()), instances.size())\n            .next();\n    }\n\n    private Mono<ServiceInstance> checkInstance(final ServiceInstance instance,\n                                                final String target,\n                                                final boolean isCustomDomain,\n                                                final Duration timeout) {\n        final var scheme = instance.isSecure() ? \"https\" : \"http\";\n        final var path = isCustomDomain ? \"resolve-custom\" : \"resolve\";\n        final var uri = URI.create(\"%s://%s:%d/ingress/%s/%s\".formatted(\n            scheme, instance.getHost(), instance.getPort(), path, target));\n        return webClient.get()\n            .uri(uri)\n            .exchangeToMono(resp -> resp.statusCode().is2xxSuccessful() ? Mono.just(instance) : Mono.empty())\n            .timeout(timeout)\n            .onErrorResume(ex -> Mono.empty());\n    }\n\n    private String extractHost(final Request request) {\n        if (!(request.getContext() instanceof RequestDataContext context)) {\n            return null;\n        }\n        final var data = context.getClientRequest();\n        if (data == null) {\n            return null;\n        }\n        final HttpHeaders headers = data.getHeaders();\n        final var hostHeader = headers == null ? null : headers.getFirst(HttpHeaders.HOST);\n        if (!StringUtils.hasText(hostHeader)) {\n            return null;\n        }\n        // Strip port if present\n        return hostHeader.contains(\":\") ? hostHeader.substring(0, hostHeader.indexOf(':')) : hostHeader;\n    }\n\n    private boolean isCustomDomain(final String host) {\n        // Simple logic: if it doesn't end with .portbuddy.dev (or whatever is configured), it's custom.\n        // In a real app, this should probably be more robust or use a property.\n        return !host.endsWith(\".portbuddy.dev\") && host.contains(\".\");\n    }\n\n    private String extractSubdomain(final String host) {\n        final var dotIdx = host.indexOf('.');\n        if (dotIdx <= 0) {\n            return null;\n        }\n        return host.substring(0, dotIdx);\n    }\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/security/GatewayJwtConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.security;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;\nimport org.springframework.security.oauth2.jwt.JwtIssuerValidator;\nimport org.springframework.security.oauth2.jwt.JwtTimestampValidator;\nimport org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;\nimport org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;\nimport org.springframework.web.reactive.function.client.WebClient;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.gateway.config.AppProperties;\n\n@Configuration\n@RequiredArgsConstructor\npublic class GatewayJwtConfig {\n\n    private final WebClient.Builder loadBalancedWebClientBuilder;\n    private final AppProperties properties;\n\n    @Bean\n    public ReactiveJwtDecoder reactiveJwtDecoder() {\n        final var decoder = NimbusReactiveJwtDecoder.withJwkSetUri(properties.jwt().jwkSetUri())\n            .webClient(loadBalancedWebClientBuilder.build())\n            .build();\n        final var withIssuer = new JwtIssuerValidator(properties.jwt().issuer());\n        final var validator = new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(), withIssuer);\n        decoder.setJwtValidator(validator);\n        return decoder;\n    }\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/security/GatewaySecurityConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.security;\n\nimport java.util.List;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;\nimport org.springframework.security.config.web.server.ServerHttpSecurity;\nimport org.springframework.security.web.server.SecurityWebFilterChain;\nimport org.springframework.web.cors.CorsConfiguration;\nimport org.springframework.web.cors.reactive.CorsConfigurationSource;\nimport org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;\n\n@Configuration\n@EnableWebFluxSecurity\npublic class GatewaySecurityConfig {\n\n    @Bean\n    public SecurityWebFilterChain springSecurityFilterChain(final ServerHttpSecurity http) {\n        http\n            .csrf(ServerHttpSecurity.CsrfSpec::disable)\n            .cors(cors -> cors.configurationSource(corsConfigurationSource()))\n            .authorizeExchange(exchange -> exchange\n                // Public endpoints (static, SPA, OAuth callbacks, JWKS, etc.)\n                .pathMatchers(\n                    \"/\", \"/index.html\", \"/assets/**\", \"/favicon.*\",\n                    \"/app/**\", \"/login/**\", \"/auth/callback\",\n                    \"/forgot-password**\", \"/reset-password**\",\n                    \"/oauth2/**\", \"/login/oauth2/**\",\n                    \"/.well-known/jwks.json\",\n                    // Token exchange must be public to let CLI obtain a JWT\n                    \"/api/auth/token-exchange\", \"/api/auth/login\", \"/api/auth/register\",\n                    \"/api/auth/password-reset/**\", \"/api/webhooks/stripe\"\n                ).permitAll()\n                // Secure API endpoints\n                .pathMatchers(\"/api/**\").authenticated()\n                // Everything else is allowed (e.g., subdomain ingress and public tunnels)\n                .anyExchange().permitAll()\n            )\n            // Validate bearer tokens for secured endpoints\n            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));\n        return http.build();\n    }\n\n    /**\n     * Configures CORS to allow all origins.\n     *\n     * @return the CORS configuration source\n     */\n    @Bean\n    public CorsConfigurationSource corsConfigurationSource() {\n        final var configuration = new CorsConfiguration();\n        configuration.setAllowedOriginPatterns(List.of(\"*\"));\n        configuration.setAllowedMethods(List.of(\"*\"));\n        configuration.setAllowedHeaders(List.of(\"*\"));\n        configuration.setAllowCredentials(true);\n        final var source = new UrlBasedCorsConfigurationSource();\n        source.registerCorsConfiguration(\"/**\", configuration);\n        return source;\n    }\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/ssl/DynamicSslProvider.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.ssl;\n\nimport java.io.File;\nimport java.io.FileInputStream;\nimport java.io.SequenceInputStream;\nimport java.time.Duration;\nimport java.util.concurrent.CompletableFuture;\n\nimport org.springframework.stereotype.Service;\n\nimport com.github.benmanes.caffeine.cache.AsyncCache;\nimport com.github.benmanes.caffeine.cache.Caffeine;\nimport com.github.benmanes.caffeine.cache.RemovalCause;\n\nimport io.netty.handler.ssl.SslContext;\nimport io.netty.handler.ssl.SslContextBuilder;\nimport io.netty.handler.ssl.util.SelfSignedCertificate;\nimport io.netty.util.ReferenceCountUtil;\nimport jakarta.annotation.PreDestroy;\nimport lombok.Getter;\nimport lombok.extern.slf4j.Slf4j;\nimport reactor.core.publisher.Mono;\nimport tech.amak.portbuddy.gateway.client.SslServiceClient;\nimport tech.amak.portbuddy.gateway.config.AppProperties;\n\n@Service\n@Slf4j\npublic class DynamicSslProvider {\n\n    private final AppProperties properties;\n    private final SslServiceClient sslServiceClient;\n    private final AsyncCache<String, SslContext> sslContextCache;\n    private final String baseDomain;\n    @Getter\n    private final SslContext fallbackSslContext;\n\n    /**\n     * Constructs a new instance of the DynamicSslProvider.\n     *\n     * @param sslServiceClient an instance of SslServiceClient used to communicate with the SSL service\n     * @param properties       an instance of AppProperties containing configuration values\n     */\n    public DynamicSslProvider(final SslServiceClient sslServiceClient, final AppProperties properties) {\n        this.sslServiceClient = sslServiceClient;\n        this.properties = properties;\n        this.baseDomain = properties.domain();\n        this.fallbackSslContext = createFallbackSslContext();\n        this.sslContextCache = Caffeine.newBuilder()\n            .maximumSize(500)\n            .expireAfterWrite(Duration.ofMinutes(30))\n            .removalListener((String key, Object value, RemovalCause cause) -> {\n                if (value instanceof CompletableFuture<?> future) {\n                    future.whenComplete((context, ex) -> {\n                        releaseSslContext(context, key);\n                    });\n                } else {\n                    releaseSslContext(value, key);\n                }\n            })\n            .buildAsync();\n    }\n\n    private void releaseSslContext(final Object context, final String key) {\n        if (context instanceof SslContext sslContext && sslContext != fallbackSslContext) {\n            log.debug(\"Evicted SSL context for {}. Releasing resources.\", key);\n            ReferenceCountUtil.release(sslContext);\n        }\n    }\n\n    /**\n     * Releases fallback SSL context and clears the cache on shutdown to prevent resource leaks.\n     */\n    @PreDestroy\n    public void shutdown() {\n        log.info(\"Shutting down DynamicSslProvider. Releasing resources.\");\n        sslContextCache.synchronous().invalidateAll();\n        sslContextCache.synchronous().cleanUp();\n        if (fallbackSslContext != null) {\n            ReferenceCountUtil.release(fallbackSslContext);\n        }\n    }\n\n    private SslContext createFallbackSslContext() {\n        final var fallback = properties.ssl().fallback();\n\n        try {\n            if (fallback == null || !fallback.enabled()) {\n                log.info(\"Fallback certificate is disabled. Generating a temporary self-signed certificate.\");\n                final var ssc = new SelfSignedCertificate();\n                return SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()).build();\n            }\n\n            log.info(\"Loading fallback certificate from: {} and {}\",\n                fallback.keyCertChainFile(), fallback.keyFile());\n\n            try (var certStream = fallback.keyCertChainFile().getInputStream();\n                 var keyStream = fallback.keyFile().getInputStream()) {\n                return SslContextBuilder.forServer(certStream, keyStream).build();\n            }\n        } catch (final Exception e) {\n            log.error(\"Failed to create fallback SSL context\", e);\n            try {\n                final var ssc = new SelfSignedCertificate();\n                return SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()).build();\n            } catch (final Exception ex) {\n                log.error(\"Failed to create even a temporary self-signed certificate\", ex);\n                return null;\n            }\n        }\n    }\n\n    /**\n     * Retrieves SslContext for a given hostname, utilizing Caffeine cache.\n     *\n     * @param hostname requested hostname\n     * @return Mono of SslContext or fallback if not found\n     */\n    public Mono<SslContext> getSslContext(final String hostname) {\n        if (hostname == null) {\n            return Mono.just(fallbackSslContext);\n        }\n\n        final var normalizedHostname = hostname.toLowerCase();\n        return Mono.fromFuture(sslContextCache.get(normalizedHostname, (h, executor) -> loadSslContext(h).toFuture()));\n    }\n\n    private Mono<SslContext> loadSslContext(final String hostname) {\n        var lookupDomain = hostname;\n        if (hostname.equals(baseDomain) || hostname.endsWith(\".\" + baseDomain)) {\n            lookupDomain = \"*.\" + baseDomain;\n        }\n\n        log.debug(\"Loading SSL context for hostname: {}, lookup domain: {}\", hostname, lookupDomain);\n\n        final String finalLookupDomain = lookupDomain;\n        return sslServiceClient.getCertificate(lookupDomain)\n            .flatMap(cert -> {\n                if (cert == null || cert.certificatePath() == null || cert.privateKeyPath() == null) {\n                    log.warn(\"No certificate found for {}. Using fallback.\", finalLookupDomain);\n                    return Mono.just(fallbackSslContext);\n                }\n\n                try {\n                    final SslContext context;\n                    if (cert.fullChainPath() != null) {\n                        context = SslContextBuilder.forServer(\n                            new File(cert.fullChainPath()),\n                            new File(cert.privateKeyPath())\n                        ).build();\n                    } else if (cert.chainPath() != null && !cert.chainPath().isBlank()) {\n                        log.debug(\"Full chain path missing, but chain path present. Concatenating for {}.\",\n                            finalLookupDomain);\n                        try (final var certIs = new FileInputStream(cert.certificatePath());\n                             final var chainIs = new FileInputStream(cert.chainPath());\n                             final var fullChainIs = new SequenceInputStream(certIs, chainIs);\n                             final var keyIs = new FileInputStream(cert.privateKeyPath())) {\n                            context = SslContextBuilder.forServer(fullChainIs, keyIs).build();\n                        }\n                    } else {\n                        context = SslContextBuilder.forServer(\n                            new File(cert.certificatePath()),\n                            new File(cert.privateKeyPath())\n                        ).build();\n                    }\n                    ReferenceCountUtil.retain(context);\n                    return Mono.just(context);\n                } catch (final Exception e) {\n                    log.error(\"Failed to create SslContext for {}. Using fallback.\", finalLookupDomain, e);\n                    return Mono.just(fallbackSslContext);\n                }\n            })\n            .doOnDiscard(SslContext.class, ctx -> {\n                if (ctx != fallbackSslContext) {\n                    ReferenceCountUtil.release(ctx);\n                }\n            })\n            .defaultIfEmpty(fallbackSslContext)\n            .onErrorResume(e -> {\n                log.error(\"Error retrieving certificate for {}. Using fallback.\", finalLookupDomain, e);\n                return Mono.just(fallbackSslContext);\n            });\n    }\n}\n"
  },
  {
    "path": "gateway/src/main/java/tech/amak/portbuddy/gateway/ssl/SniSslContextMapping.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.ssl;\n\nimport org.springframework.stereotype.Component;\n\nimport io.netty.handler.ssl.SslContext;\nimport io.netty.util.AsyncMapping;\nimport io.netty.util.concurrent.Future;\nimport io.netty.util.concurrent.Promise;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\n\n@Component\n@RequiredArgsConstructor\n@Slf4j\npublic class SniSslContextMapping implements AsyncMapping<String, SslContext> {\n\n    private final DynamicSslProvider sslProvider;\n\n    @Override\n    public Future<SslContext> map(final String hostname, final Promise<SslContext> promise) {\n        log.debug(\"SNI lookup for hostname: {}\", hostname);\n        final var normalizedHostname = hostname != null ? hostname.toLowerCase() : null;\n        sslProvider.getSslContext(normalizedHostname)\n            .subscribe(\n                promise::setSuccess,\n                promise::setFailure\n            );\n        return promise;\n    }\n}\n"
  },
  {
    "path": "gateway/src/main/resources/application.yml",
    "content": "server:\n  port: 8443\n\napp:\n  http-port: 8080\n  domain: localhost:${server.port}\n  url: https://${app.domain}\n  server-error-page: ${app.url}/500\n  spa-backend-enabled: true\n  spa-fallback-enabled: false\n  ssl:\n    enabled: true\n    # brew install mkcert\n    # mkcert -install\n    # mkcert -key-file key.pem -cert-file cert.pem localhost 127.0.0.1 ::1\n    fallback:\n      enabled: true\n      key-cert-chain-file: ${SELF_SERT_CHAIN_FILE:}\n      key-file: ${SELF_SERT_KEY_FILE:}\n  jwt:\n    jwk-set-uri: lb://port-buddy-server/.well-known/jwks.json\n    # Must match the issuer used by the Server when minting JWTs\n    issuer: port-buddy\n\neureka:\n  client:\n    # Fetch registry updates from Eureka more frequently so the gateway sees\n    # service instance changes with lower latency.\n    registryFetchIntervalSeconds: 2\n    # Poll interval for refreshing Eureka server URLs (keep modest, default is 5 minutes)\n    eurekaServiceUrlPollIntervalSeconds: 60\n    service-url:\n      defaultZone: ${EUREKA_ZONE:http://portbuddy:portbuddy@localhost:8761/eureka}\n\nspring:\n  application:\n    name: api-gateway\n\n  cloud:\n    loadbalancer:\n      # Reduce cache TTL so instance list updates propagate quickly after\n      # Eureka registry refresh. This helps newly registered/restarted\n      # services become routable within a few seconds.\n      cache:\n        enabled: true\n        ttl: 2s\n      # Proactively check instance health and refresh the list to avoid\n      # routing to stale instances and to speed up availability.\n      health-check:\n        enabled: true\n        interval: 2s\n    gateway:\n      server:\n        webflux:\n          httpserver:\n            wiretap: off\n          httpclient:\n            websocket:\n              # Allow large WS frames (100 MiB)\n              max-frame-payload-length: 104857600\n            wiretap: off\n          x-forwarded:\n            enabled: true\n          discovery:\n            locator:\n              lower-case-service-id: true\n          #          filter:\n          #            preserve-host-header:\n          #              enabled: true\n\n          routes:\n            - id: acme_http01_route\n              order: -1\n              uri: lb://ssl-service\n              predicates:\n                - Path=/.well-known/acme-challenge/**\n              filters:\n                - PreserveHostHeader\n\n            - id: api_ws_route\n              order: -2\n              uri: lb:ws://port-buddy-server\n              predicates:\n                - Path=/api/http-tunnel/**\n                - Header=Upgrade, (?i)websocket\n\n            - id: net_proxy_ws_route\n              order: -2\n              uri: lb:ws://net-proxy\n              predicates:\n                - Path=/api/net-tunnel/**\n                - Header=Upgrade, (?i)websocket\n\n            - id: api_route\n              order: 2\n              uri: lb://port-buddy-server\n              predicates:\n                - Path=/api/**, /oauth2/**, /login/oauth2/**, /.well-known/jwks.json\n                # Exclude tunnel paths explicitly to ensure no overlap even with Order.\n                # Since Path predicates are ANDed, we can't easily exclude here without custom predicates.\n                # However, placing it AFTER WebSocket routes with higher Order (larger value) is the standard.\n                # I will ensure Order is correctly spaced.\n\n            - id: net_proxy_route\n              order: 1\n              uri: lb://net-proxy\n              predicates:\n                - Path=/api/net-tunnel/**\n\n            # WebSocket route for subdomain ingress. Must be evaluated before HTTP route.\n            - id: subdomain_ingress_ws_route\n              order: 0\n              uri: lb:ws://port-buddy-server\n              predicates:\n                - Host={subdomain}.${app.domain}\n                # Route ONLY real websocket upgrade requests to the WS upstream.\n                # Use case-insensitive match to be robust across clients/proxies.\n                - Header=Upgrade, (?i)websocket\n                # WebSocket handshakes are GET by spec; narrow to GET as an extra guard.\n                - Method=GET\n              filters:\n                - PreserveHostHeader\n                - name: PortBuddyRewritePath\n                  args:\n                    regexp: /(?<remaining>.*)\n                    replacement: /_ws/$\\{subdomain}/$\\{remaining}\n\n            # WebSocket route for custom domains.\n            - id: custom_domain_ingress_ws_route\n              order: 0\n              uri: lb:ws://port-buddy-server\n              predicates:\n                # Route ONLY real websocket upgrade requests to the WS upstream.\n                - Header=Upgrade, (?i)websocket\n                - Method=GET\n                # Exclude main domain and subdomains to avoid overlap\n                - Header=Host, ^(?!(.*\\.${app.domain}|${app.domain})$).*\n              filters:\n                - PreserveHostHeader\n                - name: PortBuddyRewritePath\n                  args:\n                    regexp: /(?<remaining>.*)\n                    replacement: /_ws/$\\{customDomain}/$\\{remaining}\n\n            # HTTP route for subdomain ingress (non-WS traffic)\n            - id: subdomain_ingress_route\n              order: 1\n              uri: lb://port-buddy-server\n              predicates:\n                - Host={subdomain}.${app.domain}\n              filters:\n                - PreserveHostHeader\n                - name: PortBuddyRewritePath\n                  args:\n                    regexp: /(?<remaining>.*)\n                    replacement: /_/$\\{subdomain}/$\\{remaining}\n\n            # Route for custom domains\n            - id: custom_domain_ingress_route\n              order: 2\n              uri: lb://port-buddy-server\n              predicates:\n                # Exclude main domain and subdomains to avoid overlap\n                - Header=Host, ^(?!(.*\\.${app.domain}|${app.domain})$).*\n              filters:\n                - PreserveHostHeader\n                - name: PortBuddyRewritePath\n                  args:\n                    regexp: /(?<remaining>.*)\n                    replacement: /_custom/$\\{customDomain}/$\\{remaining}\n\n            - id: static_guides_route\n              enabled: ${app.spa-fallback-enabled}\n              uri: forward:/\n              order: 2\n              predicates:\n                - Path=/docs/guides/{guide}\n              filters:\n                - SetPath=/docs/guides/{guide}/index.html\n\n            - id: static_route\n              enabled: ${app.spa-fallback-enabled}\n              uri: forward:/\n              order: 2\n              predicates:\n                - Path=/{page:docs|install|terms|privacy|contacts}\n              filters:\n                - SetPath=/{page}/index.html\n\n            - id: spa_fallback_route\n              enabled: ${app.spa-fallback-enabled}\n              uri: forward:/index.html\n              order: 3\n              predicates:\n                - Path=/, /register, /passcode, /app/**, /login/**, /auth/callback, /forgot-password**, /reset-password**, /404, /500\n\n            # Dev: Proxy SPA paths to the Vite dev server\n            - id: spa_frontend_route\n              enabled: ${app.spa-backend-enabled}\n              order: 100\n              uri: http://localhost:5173\n              predicates:\n                - Path=/**\n              filters:\n                - PreserveHostHeader\n  web:\n    resources:\n      static-locations: file:./web/dist/\n      cache:\n        use-last-modified: true\n        period: 3600\n\nlogging:\n  level:\n    root: info\n    reactor.netty.http.client: warn\n    io.netty.handler.codec.http.websocketx: warn\n    org.springframework.cloud.gateway: warn\n    org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator: warn\n    com.netflix.discovery: warn\n  file:\n    name: log/app.log\n  logback:\n    rollingpolicy:\n      max-history: 14\n      total-size-cap: 1000MB\n      max-file-size: 100MB\n\n---\nspring:\n  config:\n    activate:\n      on-profile: prod\n\nserver:\n  port: 443\n\napp:\n  http-port: 80\n  domain: ${APP_DOMAIN:portbuddy.dev}\n  spa-backend-enabled: false\n  spa-fallback-enabled: true\n  ssl:\n    enabled: true\n    fallback:\n      enabled: false\n"
  },
  {
    "path": "gateway/src/test/java/tech/amak/gateway/ApiGatewayApplicationTests.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.gateway;\n\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.test.context.TestPropertySource;\nimport org.springframework.test.context.TestPropertySources;\n\nimport tech.amak.portbuddy.gateway.ApiGatewayApplication;\n\n@SpringBootTest(\n    classes = ApiGatewayApplication.class,\n    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT\n)\n@TestPropertySources(\n    {@TestPropertySource(properties = {\"app.ssl.enabled=false\"})}\n)\nclass ApiGatewayApplicationTests {\n\n    @Test\n    void contextLoads() {\n    }\n\n}\n"
  },
  {
    "path": "gateway/src/test/java/tech/amak/portbuddy/gateway/config/SslServerConfigTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.config;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.atLeastOnce;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Duration;\nimport javax.net.ssl.SNIHostName;\nimport javax.net.ssl.SSLException;\n\nimport org.junit.jupiter.api.Test;\nimport org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory;\nimport org.springframework.http.server.reactive.HttpHandler;\n\nimport io.netty.handler.ssl.SslContextBuilder;\nimport io.netty.handler.ssl.util.InsecureTrustManagerFactory;\nimport io.netty.handler.ssl.util.SelfSignedCertificate;\nimport reactor.core.publisher.Mono;\nimport reactor.netty.http.client.HttpClient;\nimport tech.amak.portbuddy.gateway.ssl.DynamicSslProvider;\nimport tech.amak.portbuddy.gateway.ssl.SniSslContextMapping;\n\nclass SslServerConfigTest {\n\n    @Test\n    void shouldInvokeDynamicSslProviderOnSniHandshake() throws Exception {\n        // Given\n        final var sslProvider = mock(DynamicSslProvider.class);\n        final var sniMapping = new SniSslContextMapping(sslProvider);\n        final var properties = mock(AppProperties.class);\n        final var sslProperties = mock(AppProperties.Ssl.class);\n        final var httpHandler = mock(HttpHandler.class);\n\n        final var ssc = new SelfSignedCertificate();\n        final var fallbackContext = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()).build();\n\n        when(properties.ssl()).thenReturn(sslProperties);\n        when(sslProperties.enabled()).thenReturn(true);\n        when(sslProvider.getFallbackSslContext()).thenReturn(fallbackContext);\n        // Return fallback even for dynamic to avoid complex setup, we just want to see if it's called\n        when(sslProvider.getSslContext(anyString())).thenReturn(Mono.just(fallbackContext));\n        when(httpHandler.handle(any(), any())).thenReturn(Mono.empty());\n\n        final var sslServerConfig = new SslServerConfig(properties, sniMapping, httpHandler);\n        final var customizer = sslServerConfig.sslCustomizer();\n\n        final var factory = new NettyReactiveWebServerFactory(0);\n        customizer.customize(factory);\n\n        final var webServer = factory.getWebServer(httpHandler);\n        webServer.start();\n        try {\n            final int port = webServer.getPort();\n\n            // When - attempt a connection with SNI\n            HttpClient.create()\n                .port(port)\n                .remoteAddress(() -> new java.net.InetSocketAddress(\"127.0.0.1\", port))\n                .secure(spec -> {\n                    try {\n                        spec.sslContext(SslContextBuilder.forClient()\n                                .trustManager(InsecureTrustManagerFactory.INSTANCE)\n                                .build())\n                            .serverNames(new SNIHostName(\"test.portbuddy.dev\"));\n                    } catch (final SSLException e) {\n                        throw new RuntimeException(e);\n                    }\n                })\n                .get()\n                .uri(\"/\")\n                .response()\n                .block(Duration.ofSeconds(5));\n        } catch (final Exception e) {\n            // It might fail for various reasons (no actual handler for the request), but we care about SNI lookup\n        } finally {\n            webServer.stop();\n        }\n\n        // Then\n        verify(sslProvider, atLeastOnce()).getSslContext(\"test.portbuddy.dev\");\n    }\n}\n"
  },
  {
    "path": "gateway/src/test/java/tech/amak/portbuddy/gateway/ssl/DynamicSslProviderTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.gateway.ssl;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.when;\n\nimport java.io.File;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.api.io.TempDir;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport io.netty.handler.ssl.SslContext;\nimport io.netty.handler.ssl.util.SelfSignedCertificate;\nimport reactor.core.publisher.Mono;\nimport tech.amak.portbuddy.gateway.client.SslServiceClient;\nimport tech.amak.portbuddy.gateway.config.AppProperties;\nimport tech.amak.portbuddy.gateway.dto.CertificateResponse;\n\n@ExtendWith(MockitoExtension.class)\nclass DynamicSslProviderTest {\n\n    @Mock\n    private SslServiceClient sslServiceClient;\n\n    @Mock\n    private AppProperties properties;\n\n    @Mock\n    private AppProperties.Ssl sslProperties;\n\n    @TempDir\n    File tempDir;\n\n    private DynamicSslProvider sslProvider;\n\n    @BeforeEach\n    void setUp() {\n        when(properties.domain()).thenReturn(\"portbuddy.dev\");\n        when(properties.ssl()).thenReturn(sslProperties);\n        when(sslProperties.fallback()).thenReturn(null);\n        sslProvider = new DynamicSslProvider(sslServiceClient, properties);\n    }\n\n    @Test\n    void shouldReturnFallbackWhenCertificateNotFound() {\n        // Given\n        when(sslServiceClient.getCertificate(anyString())).thenReturn(Mono.empty());\n\n        // When\n        final SslContext context = sslProvider.getSslContext(\"UNKNOWN.COM\").block();\n\n        // Then\n        assertNotNull(context);\n    }\n\n    @Test\n    void shouldReturnFallbackWhenHostnameIsNull() {\n        // When\n        final SslContext context = sslProvider.getSslContext(null).block();\n\n        // Then\n        assertNotNull(context);\n    }\n\n    @Test\n    void shouldLoadSslContextFromFile() throws Exception {\n        // Given\n        final SelfSignedCertificate ssc = new SelfSignedCertificate();\n        final String hostname = \"test.portbuddy.dev\";\n        final CertificateResponse response = new CertificateResponse(\n            hostname,\n            ssc.certificate().getAbsolutePath(),\n            ssc.privateKey().getAbsolutePath(),\n            null,\n            null\n        );\n        when(sslServiceClient.getCertificate(\"*.portbuddy.dev\")).thenReturn(Mono.just(response));\n\n        // When\n        final SslContext context = sslProvider.getSslContext(hostname).block();\n\n        // Then\n        assertNotNull(context);\n        assertTrue(context.isServer());\n    }\n}\n"
  },
  {
    "path": "lombok.config",
    "content": "lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier\nlombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value\nlombok.copyableAnnotations += org.springframework.context.annotation.Lazy"
  },
  {
    "path": "mvnw",
    "content": "#!/bin/sh\n# ----------------------------------------------------------------------------\n# Licensed to the Apache Software Foundation (ASF) under one\n# or more contributor license agreements.  See the NOTICE file\n# distributed with this work for additional information\n# regarding copyright ownership.  The ASF licenses this file\n# to you under the Apache License, Version 2.0 (the\n# \"License\"); you may not use this file except in compliance\n# with the License.  You may obtain a copy of the License at\n#\n#    http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing,\n# software distributed under the License is distributed on an\n# \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n# KIND, either express or implied.  See the License for the\n# specific language governing permissions and limitations\n# under the License.\n# ----------------------------------------------------------------------------\n\n# ----------------------------------------------------------------------------\n# Apache Maven Wrapper startup batch script, version 3.3.4\n#\n# Optional ENV vars\n# -----------------\n#   JAVA_HOME - location of a JDK home dir, required when download maven via java source\n#   MVNW_REPOURL - repo url base for downloading maven distribution\n#   MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven\n#   MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output\n# ----------------------------------------------------------------------------\n\nset -euf\n[ \"${MVNW_VERBOSE-}\" != debug ] || set -x\n\n# OS specific support.\nnative_path() { printf %s\\\\n \"$1\"; }\ncase \"$(uname)\" in\nCYGWIN* | MINGW*)\n  [ -z \"${JAVA_HOME-}\" ] || JAVA_HOME=\"$(cygpath --unix \"$JAVA_HOME\")\"\n  native_path() { cygpath --path --windows \"$1\"; }\n  ;;\nesac\n\n# set JAVACMD and JAVACCMD\nset_java_home() {\n  # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched\n  if [ -n \"${JAVA_HOME-}\" ]; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ]; then\n      # IBM's JDK on AIX uses strange locations for the executables\n      JAVACMD=\"$JAVA_HOME/jre/sh/java\"\n      JAVACCMD=\"$JAVA_HOME/jre/sh/javac\"\n    else\n      JAVACMD=\"$JAVA_HOME/bin/java\"\n      JAVACCMD=\"$JAVA_HOME/bin/javac\"\n\n      if [ ! -x \"$JAVACMD\" ] || [ ! -x \"$JAVACCMD\" ]; then\n        echo \"The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run.\" >&2\n        echo \"JAVA_HOME is set to \\\"$JAVA_HOME\\\", but \\\"\\$JAVA_HOME/bin/java\\\" or \\\"\\$JAVA_HOME/bin/javac\\\" does not exist.\" >&2\n        return 1\n      fi\n    fi\n  else\n    JAVACMD=\"$(\n      'set' +e\n      'unset' -f command 2>/dev/null\n      'command' -v java\n    )\" || :\n    JAVACCMD=\"$(\n      'set' +e\n      'unset' -f command 2>/dev/null\n      'command' -v javac\n    )\" || :\n\n    if [ ! -x \"${JAVACMD-}\" ] || [ ! -x \"${JAVACCMD-}\" ]; then\n      echo \"The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run.\" >&2\n      return 1\n    fi\n  fi\n}\n\n# hash string like Java String::hashCode\nhash_string() {\n  str=\"${1:-}\" h=0\n  while [ -n \"$str\" ]; do\n    char=\"${str%\"${str#?}\"}\"\n    h=$(((h * 31 + $(LC_CTYPE=C printf %d \"'$char\")) % 4294967296))\n    str=\"${str#?}\"\n  done\n  printf %x\\\\n $h\n}\n\nverbose() { :; }\n[ \"${MVNW_VERBOSE-}\" != true ] || verbose() { printf %s\\\\n \"${1-}\"; }\n\ndie() {\n  printf %s\\\\n \"$1\" >&2\n  exit 1\n}\n\ntrim() {\n  # MWRAPPER-139:\n  #   Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.\n  #   Needed for removing poorly interpreted newline sequences when running in more\n  #   exotic environments such as mingw bash on Windows.\n  printf \"%s\" \"${1}\" | tr -d '[:space:]'\n}\n\nscriptDir=\"$(dirname \"$0\")\"\nscriptName=\"$(basename \"$0\")\"\n\n# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties\nwhile IFS=\"=\" read -r key value; do\n  case \"${key-}\" in\n  distributionUrl) distributionUrl=$(trim \"${value-}\") ;;\n  distributionSha256Sum) distributionSha256Sum=$(trim \"${value-}\") ;;\n  esac\ndone <\"$scriptDir/.mvn/wrapper/maven-wrapper.properties\"\n[ -n \"${distributionUrl-}\" ] || die \"cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties\"\n\ncase \"${distributionUrl##*/}\" in\nmaven-mvnd-*bin.*)\n  MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/\n  case \"${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)\" in\n  *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;\n  :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;\n  :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;\n  :Linux*x86_64*) distributionPlatform=linux-amd64 ;;\n  *)\n    echo \"Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version\" >&2\n    distributionPlatform=linux-amd64\n    ;;\n  esac\n  distributionUrl=\"${distributionUrl%-bin.*}-$distributionPlatform.zip\"\n  ;;\nmaven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;\n*) MVN_CMD=\"mvn${scriptName#mvnw}\" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;\nesac\n\n# apply MVNW_REPOURL and calculate MAVEN_HOME\n# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>\n[ -z \"${MVNW_REPOURL-}\" ] || distributionUrl=\"$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*\"$_MVNW_REPO_PATTERN\"}\"\ndistributionUrlName=\"${distributionUrl##*/}\"\ndistributionUrlNameMain=\"${distributionUrlName%.*}\"\ndistributionUrlNameMain=\"${distributionUrlNameMain%-bin}\"\nMAVEN_USER_HOME=\"${MAVEN_USER_HOME:-${HOME}/.m2}\"\nMAVEN_HOME=\"${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string \"$distributionUrl\")\"\n\nexec_maven() {\n  unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :\n  exec \"$MAVEN_HOME/bin/$MVN_CMD\" \"$@\" || die \"cannot exec $MAVEN_HOME/bin/$MVN_CMD\"\n}\n\nif [ -d \"$MAVEN_HOME\" ]; then\n  verbose \"found existing MAVEN_HOME at $MAVEN_HOME\"\n  exec_maven \"$@\"\nfi\n\ncase \"${distributionUrl-}\" in\n*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;\n*) die \"distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'\" ;;\nesac\n\n# prepare tmp dir\nif TMP_DOWNLOAD_DIR=\"$(mktemp -d)\" && [ -d \"$TMP_DOWNLOAD_DIR\" ]; then\n  clean() { rm -rf -- \"$TMP_DOWNLOAD_DIR\"; }\n  trap clean HUP INT TERM EXIT\nelse\n  die \"cannot create temp dir\"\nfi\n\nmkdir -p -- \"${MAVEN_HOME%/*}\"\n\n# Download and Install Apache Maven\nverbose \"Couldn't find MAVEN_HOME, downloading and installing it ...\"\nverbose \"Downloading from: $distributionUrl\"\nverbose \"Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName\"\n\n# select .zip or .tar.gz\nif ! command -v unzip >/dev/null; then\n  distributionUrl=\"${distributionUrl%.zip}.tar.gz\"\n  distributionUrlName=\"${distributionUrl##*/}\"\nfi\n\n# verbose opt\n__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''\n[ \"${MVNW_VERBOSE-}\" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v\n\n# normalize http auth\ncase \"${MVNW_PASSWORD:+has-password}\" in\n'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;\nhas-password) [ -n \"${MVNW_USERNAME-}\" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;\nesac\n\nif [ -z \"${MVNW_USERNAME-}\" ] && command -v wget >/dev/null; then\n  verbose \"Found wget ... using wget\"\n  wget ${__MVNW_QUIET_WGET:+\"$__MVNW_QUIET_WGET\"} \"$distributionUrl\" -O \"$TMP_DOWNLOAD_DIR/$distributionUrlName\" || die \"wget: Failed to fetch $distributionUrl\"\nelif [ -z \"${MVNW_USERNAME-}\" ] && command -v curl >/dev/null; then\n  verbose \"Found curl ... using curl\"\n  curl ${__MVNW_QUIET_CURL:+\"$__MVNW_QUIET_CURL\"} -f -L -o \"$TMP_DOWNLOAD_DIR/$distributionUrlName\" \"$distributionUrl\" || die \"curl: Failed to fetch $distributionUrl\"\nelif set_java_home; then\n  verbose \"Falling back to use Java to download\"\n  javaSource=\"$TMP_DOWNLOAD_DIR/Downloader.java\"\n  targetZip=\"$TMP_DOWNLOAD_DIR/$distributionUrlName\"\n  cat >\"$javaSource\" <<-END\n\tpublic class Downloader extends java.net.Authenticator\n\t{\n\t  protected java.net.PasswordAuthentication getPasswordAuthentication()\n\t  {\n\t    return new java.net.PasswordAuthentication( System.getenv( \"MVNW_USERNAME\" ), System.getenv( \"MVNW_PASSWORD\" ).toCharArray() );\n\t  }\n\t  public static void main( String[] args ) throws Exception\n\t  {\n\t    setDefault( new Downloader() );\n\t    java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );\n\t  }\n\t}\n\tEND\n  # For Cygwin/MinGW, switch paths to Windows format before running javac and java\n  verbose \" - Compiling Downloader.java ...\"\n  \"$(native_path \"$JAVACCMD\")\" \"$(native_path \"$javaSource\")\" || die \"Failed to compile Downloader.java\"\n  verbose \" - Running Downloader.java ...\"\n  \"$(native_path \"$JAVACMD\")\" -cp \"$(native_path \"$TMP_DOWNLOAD_DIR\")\" Downloader \"$distributionUrl\" \"$(native_path \"$targetZip\")\"\nfi\n\n# If specified, validate the SHA-256 sum of the Maven distribution zip file\nif [ -n \"${distributionSha256Sum-}\" ]; then\n  distributionSha256Result=false\n  if [ \"$MVN_CMD\" = mvnd.sh ]; then\n    echo \"Checksum validation is not supported for maven-mvnd.\" >&2\n    echo \"Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties.\" >&2\n    exit 1\n  elif command -v sha256sum >/dev/null; then\n    if echo \"$distributionSha256Sum  $TMP_DOWNLOAD_DIR/$distributionUrlName\" | sha256sum -c - >/dev/null 2>&1; then\n      distributionSha256Result=true\n    fi\n  elif command -v shasum >/dev/null; then\n    if echo \"$distributionSha256Sum  $TMP_DOWNLOAD_DIR/$distributionUrlName\" | shasum -a 256 -c >/dev/null 2>&1; then\n      distributionSha256Result=true\n    fi\n  else\n    echo \"Checksum validation was requested but neither 'sha256sum' or 'shasum' are available.\" >&2\n    echo \"Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties.\" >&2\n    exit 1\n  fi\n  if [ $distributionSha256Result = false ]; then\n    echo \"Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised.\" >&2\n    echo \"If you updated your Maven version, you need to update the specified distributionSha256Sum property.\" >&2\n    exit 1\n  fi\nfi\n\n# unzip and move\nif command -v unzip >/dev/null; then\n  unzip ${__MVNW_QUIET_UNZIP:+\"$__MVNW_QUIET_UNZIP\"} \"$TMP_DOWNLOAD_DIR/$distributionUrlName\" -d \"$TMP_DOWNLOAD_DIR\" || die \"failed to unzip\"\nelse\n  tar xzf${__MVNW_QUIET_TAR:+\"$__MVNW_QUIET_TAR\"} \"$TMP_DOWNLOAD_DIR/$distributionUrlName\" -C \"$TMP_DOWNLOAD_DIR\" || die \"failed to untar\"\nfi\n\n# Find the actual extracted directory name (handles snapshots where filename != directory name)\nactualDistributionDir=\"\"\n\n# First try the expected directory name (for regular distributions)\nif [ -d \"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain\" ]; then\n  if [ -f \"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD\" ]; then\n    actualDistributionDir=\"$distributionUrlNameMain\"\n  fi\nfi\n\n# If not found, search for any directory with the Maven executable (for snapshots)\nif [ -z \"$actualDistributionDir\" ]; then\n  # enable globbing to iterate over items\n  set +f\n  for dir in \"$TMP_DOWNLOAD_DIR\"/*; do\n    if [ -d \"$dir\" ]; then\n      if [ -f \"$dir/bin/$MVN_CMD\" ]; then\n        actualDistributionDir=\"$(basename \"$dir\")\"\n        break\n      fi\n    fi\n  done\n  set -f\nfi\n\nif [ -z \"$actualDistributionDir\" ]; then\n  verbose \"Contents of $TMP_DOWNLOAD_DIR:\"\n  verbose \"$(ls -la \"$TMP_DOWNLOAD_DIR\")\"\n  die \"Could not find Maven distribution directory in extracted archive\"\nfi\n\nverbose \"Found extracted Maven distribution directory: $actualDistributionDir\"\nprintf %s\\\\n \"$distributionUrl\" >\"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url\"\nmv -- \"$TMP_DOWNLOAD_DIR/$actualDistributionDir\" \"$MAVEN_HOME\" || [ -d \"$MAVEN_HOME\" ] || die \"fail to move MAVEN_HOME\"\n\nclean || :\nexec_maven \"$@\"\n"
  },
  {
    "path": "mvnw.cmd",
    "content": "<# : batch portion\n@REM ----------------------------------------------------------------------------\n@REM Licensed to the Apache Software Foundation (ASF) under one\n@REM or more contributor license agreements.  See the NOTICE file\n@REM distributed with this work for additional information\n@REM regarding copyright ownership.  The ASF licenses this file\n@REM to you under the Apache License, Version 2.0 (the\n@REM \"License\"); you may not use this file except in compliance\n@REM with the License.  You may obtain a copy of the License at\n@REM\n@REM    http://www.apache.org/licenses/LICENSE-2.0\n@REM\n@REM Unless required by applicable law or agreed to in writing,\n@REM software distributed under the License is distributed on an\n@REM \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n@REM KIND, either express or implied.  See the License for the\n@REM specific language governing permissions and limitations\n@REM under the License.\n@REM ----------------------------------------------------------------------------\n\n@REM ----------------------------------------------------------------------------\n@REM Apache Maven Wrapper startup batch script, version 3.3.4\n@REM\n@REM Optional ENV vars\n@REM   MVNW_REPOURL - repo url base for downloading maven distribution\n@REM   MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven\n@REM   MVNW_VERBOSE - true: enable verbose log; others: silence the output\n@REM ----------------------------------------------------------------------------\n\n@IF \"%__MVNW_ARG0_NAME__%\"==\"\" (SET __MVNW_ARG0_NAME__=%~nx0)\n@SET __MVNW_CMD__=\n@SET __MVNW_ERROR__=\n@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%\n@SET PSModulePath=\n@FOR /F \"usebackq tokens=1* delims==\" %%A IN (`powershell -noprofile \"& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}\"`) DO @(\n  IF \"%%A\"==\"MVN_CMD\" (set __MVNW_CMD__=%%B) ELSE IF \"%%B\"==\"\" (echo %%A) ELSE (echo %%A=%%B)\n)\n@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%\n@SET __MVNW_PSMODULEP_SAVE=\n@SET __MVNW_ARG0_NAME__=\n@SET MVNW_USERNAME=\n@SET MVNW_PASSWORD=\n@IF NOT \"%__MVNW_CMD__%\"==\"\" (\"%__MVNW_CMD__%\" %*)\n@echo Cannot start maven from wrapper >&2 && exit /b 1\n@GOTO :EOF\n: end batch / begin powershell #>\n\n$ErrorActionPreference = \"Stop\"\nif ($env:MVNW_VERBOSE -eq \"true\") {\n  $VerbosePreference = \"Continue\"\n}\n\n# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties\n$distributionUrl = (Get-Content -Raw \"$scriptDir/.mvn/wrapper/maven-wrapper.properties\" | ConvertFrom-StringData).distributionUrl\nif (!$distributionUrl) {\n  Write-Error \"cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties\"\n}\n\nswitch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {\n  \"maven-mvnd-*\" {\n    $USE_MVND = $true\n    $distributionUrl = $distributionUrl -replace '-bin\\.[^.]*$',\"-windows-amd64.zip\"\n    $MVN_CMD = \"mvnd.cmd\"\n    break\n  }\n  default {\n    $USE_MVND = $false\n    $MVN_CMD = $script -replace '^mvnw','mvn'\n    break\n  }\n}\n\n# apply MVNW_REPOURL and calculate MAVEN_HOME\n# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>\nif ($env:MVNW_REPOURL) {\n  $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { \"/org/apache/maven/\" } else { \"/maven/mvnd/\" }\n  $distributionUrl = \"$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace \"^.*$MVNW_REPO_PATTERN\",'')\"\n}\n$distributionUrlName = $distributionUrl -replace '^.*/',''\n$distributionUrlNameMain = $distributionUrlName -replace '\\.[^.]*$','' -replace '-bin$',''\n\n$MAVEN_M2_PATH = \"$HOME/.m2\"\nif ($env:MAVEN_USER_HOME) {\n  $MAVEN_M2_PATH = \"$env:MAVEN_USER_HOME\"\n}\n\nif (-not (Test-Path -Path $MAVEN_M2_PATH)) {\n    New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null\n}\n\n$MAVEN_WRAPPER_DISTS = $null\nif ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {\n  $MAVEN_WRAPPER_DISTS = \"$MAVEN_M2_PATH/wrapper/dists\"\n} else {\n  $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + \"/wrapper/dists\"\n}\n\n$MAVEN_HOME_PARENT = \"$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain\"\n$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString(\"x2\")}) -join ''\n$MAVEN_HOME = \"$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME\"\n\nif (Test-Path -Path \"$MAVEN_HOME\" -PathType Container) {\n  Write-Verbose \"found existing MAVEN_HOME at $MAVEN_HOME\"\n  Write-Output \"MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD\"\n  exit $?\n}\n\nif (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {\n  Write-Error \"distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl\"\n}\n\n# prepare tmp dir\n$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile\n$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path \"$TMP_DOWNLOAD_DIR_HOLDER.dir\"\n$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null\ntrap {\n  if ($TMP_DOWNLOAD_DIR.Exists) {\n    try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }\n    catch { Write-Warning \"Cannot remove $TMP_DOWNLOAD_DIR\" }\n  }\n}\n\nNew-Item -Itemtype Directory -Path \"$MAVEN_HOME_PARENT\" -Force | Out-Null\n\n# Download and Install Apache Maven\nWrite-Verbose \"Couldn't find MAVEN_HOME, downloading and installing it ...\"\nWrite-Verbose \"Downloading from: $distributionUrl\"\nWrite-Verbose \"Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName\"\n\n$webclient = New-Object System.Net.WebClient\nif ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {\n  $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)\n}\n[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n$webclient.DownloadFile($distributionUrl, \"$TMP_DOWNLOAD_DIR/$distributionUrlName\") | Out-Null\n\n# If specified, validate the SHA-256 sum of the Maven distribution zip file\n$distributionSha256Sum = (Get-Content -Raw \"$scriptDir/.mvn/wrapper/maven-wrapper.properties\" | ConvertFrom-StringData).distributionSha256Sum\nif ($distributionSha256Sum) {\n  if ($USE_MVND) {\n    Write-Error \"Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties.\"\n  }\n  Import-Module $PSHOME\\Modules\\Microsoft.PowerShell.Utility -Function Get-FileHash\n  if ((Get-FileHash \"$TMP_DOWNLOAD_DIR/$distributionUrlName\" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {\n    Write-Error \"Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property.\"\n  }\n}\n\n# unzip and move\nExpand-Archive \"$TMP_DOWNLOAD_DIR/$distributionUrlName\" -DestinationPath \"$TMP_DOWNLOAD_DIR\" | Out-Null\n\n# Find the actual extracted directory name (handles snapshots where filename != directory name)\n$actualDistributionDir = \"\"\n\n# First try the expected directory name (for regular distributions)\n$expectedPath = Join-Path \"$TMP_DOWNLOAD_DIR\" \"$distributionUrlNameMain\"\n$expectedMvnPath = Join-Path \"$expectedPath\" \"bin/$MVN_CMD\"\nif ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {\n  $actualDistributionDir = $distributionUrlNameMain\n}\n\n# If not found, search for any directory with the Maven executable (for snapshots)\nif (!$actualDistributionDir) {\n  Get-ChildItem -Path \"$TMP_DOWNLOAD_DIR\" -Directory | ForEach-Object {\n    $testPath = Join-Path $_.FullName \"bin/$MVN_CMD\"\n    if (Test-Path -Path $testPath -PathType Leaf) {\n      $actualDistributionDir = $_.Name\n    }\n  }\n}\n\nif (!$actualDistributionDir) {\n  Write-Error \"Could not find Maven distribution directory in extracted archive\"\n}\n\nWrite-Verbose \"Found extracted Maven distribution directory: $actualDistributionDir\"\nRename-Item -Path \"$TMP_DOWNLOAD_DIR/$actualDistributionDir\" -NewName $MAVEN_HOME_NAME | Out-Null\ntry {\n  Move-Item -Path \"$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME\" -Destination $MAVEN_HOME_PARENT | Out-Null\n} catch {\n  if (! (Test-Path -Path \"$MAVEN_HOME\" -PathType Container)) {\n    Write-Error \"fail to move MAVEN_HOME\"\n  }\n} finally {\n  try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }\n  catch { Write-Warning \"Cannot remove $TMP_DOWNLOAD_DIR\" }\n}\n\nWrite-Output \"MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD\"\n"
  },
  {
    "path": "net-proxy/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n    <parent>\n        <groupId>tech.amak</groupId>\n        <artifactId>port-buddy</artifactId>\n        <version>1.0-SNAPSHOT</version>\n    </parent>\n    <artifactId>net-proxy</artifactId>\n    <name>port-buddy-net-proxy</name>\n\n    <dependencies>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-web</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-websocket</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-security</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.cloud</groupId>\n            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>\n        </dependency>\n<!--        <dependency>-->\n<!--            <groupId>org.springframework.cloud</groupId>-->\n<!--            <artifactId>spring-cloud-starter-loadbalancer</artifactId>-->\n<!--        </dependency>-->\n        <dependency>\n            <groupId>org.projectlombok</groupId>\n            <artifactId>lombok</artifactId>\n            <version>${lombok.version}</version>\n            <scope>provided</scope>\n        </dependency>\n        <dependency>\n            <groupId>tech.amak</groupId>\n            <artifactId>common</artifactId>\n            <version>${project.version}</version>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-test</artifactId>\n            <scope>test</scope>\n        </dependency>\n    </dependencies>\n\n    <build>\n        <plugins>\n            <plugin>\n                <groupId>org.springframework.boot</groupId>\n                <artifactId>spring-boot-maven-plugin</artifactId>\n                <executions>\n                    <execution>\n                        <goals>\n                            <goal>repackage</goal>\n                        </goals>\n                    </execution>\n                </executions>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-checkstyle-plugin</artifactId>\n            </plugin>\n        </plugins>\n    </build>\n</project>\n"
  },
  {
    "path": "net-proxy/src/main/java/tech/amak/portbuddy/netproxy/NetProxyApplication.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.netproxy;\n\nimport org.springframework.boot.CommandLineRunner;\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.boot.context.properties.ConfigurationPropertiesScan;\nimport org.springframework.context.annotation.Bean;\n\nimport lombok.extern.slf4j.Slf4j;\n\n@Slf4j\n@SpringBootApplication\n@ConfigurationPropertiesScan\npublic class NetProxyApplication {\n\n    static void main(final String[] args) {\n        SpringApplication.run(NetProxyApplication.class, args);\n    }\n\n    @Bean\n    CommandLineRunner onStart() {\n        return args -> {\n            log.info(\"Net Proxy service started\");\n        };\n    }\n}\n"
  },
  {
    "path": "net-proxy/src/main/java/tech/amak/portbuddy/netproxy/config/AppProperties.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.netproxy.config;\n\nimport java.time.Duration;\n\nimport org.springframework.boot.context.properties.ConfigurationProperties;\nimport org.springframework.util.unit.DataSize;\n\n@ConfigurationProperties(prefix = \"app\")\npublic record AppProperties(\n    String publicHost,\n    WebSocket webSocket,\n    Jwt jwt\n) {\n\n    public record WebSocket(\n        DataSize maxTextMessageSize,\n        DataSize maxBinaryMessageSize,\n        Duration sessionIdleTimeout,\n        Duration sendTimeLimit,\n        DataSize sendBufferSizeLimit\n    ) {\n    }\n\n    public record Jwt(\n        String issuer,\n        String jwkSetUri\n    ) {\n    }\n}\n"
  },
  {
    "path": "net-proxy/src/main/java/tech/amak/portbuddy/netproxy/config/JwtConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.netproxy.config;\n\nimport org.springframework.cloud.client.loadbalancer.LoadBalanced;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;\nimport org.springframework.security.oauth2.jwt.JwtDecoder;\nimport org.springframework.security.oauth2.jwt.JwtIssuerValidator;\nimport org.springframework.security.oauth2.jwt.JwtTimestampValidator;\nimport org.springframework.security.oauth2.jwt.NimbusJwtDecoder;\nimport org.springframework.web.client.RestTemplate;\n\nimport lombok.RequiredArgsConstructor;\n\n/**\n * Configuration for JWT decoding using a load-balanced client.\n */\n@Configuration\n@RequiredArgsConstructor\npublic class JwtConfig {\n\n    private final AppProperties appProperties;\n\n    @Bean\n    @LoadBalanced\n    public RestTemplate loadBalancedRestTemplate() {\n        return new RestTemplate();\n    }\n\n    /**\n     * Creates a JwtDecoder that uses a load-balanced RestTemplate to fetch the JWK set.\n     *\n     * @param restTemplate the load-balanced RestTemplate\n     * @return the JwtDecoder\n     */\n    @Bean\n    public JwtDecoder jwtDecoder(final RestTemplate restTemplate) {\n        final var decoder = NimbusJwtDecoder\n            .withJwkSetUri(appProperties.jwt().jwkSetUri())\n            .restOperations(restTemplate)\n            .build();\n        final var withIssuer = new JwtIssuerValidator(appProperties.jwt().issuer());\n        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(\n            new JwtTimestampValidator(), withIssuer\n        ));\n        return decoder;\n    }\n}\n"
  },
  {
    "path": "net-proxy/src/main/java/tech/amak/portbuddy/netproxy/config/WebSocketConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.netproxy.config;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.web.socket.config.annotation.EnableWebSocket;\nimport org.springframework.web.socket.config.annotation.WebSocketConfigurer;\nimport org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;\nimport org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.netproxy.tunnel.NetTunnelWebSocketHandler;\n\n@Configuration\n@EnableWebSocket\n@RequiredArgsConstructor\npublic class WebSocketConfig implements WebSocketConfigurer {\n\n    private final NetTunnelWebSocketHandler tcpHandler;\n    private final AppProperties properties;\n\n    @Override\n    public void registerWebSocketHandlers(final WebSocketHandlerRegistry registry) {\n        registry.addHandler(tcpHandler, \"/api/net-tunnel/{tunnelId}\").setAllowedOrigins(\"*\");\n    }\n\n    /**\n     * Configure the underlying servlet WebSocket container to allow larger text and\n     * binary messages. We increase limits to 2 MiB to support larger tunneled\n     * payloads between the CLI and the server.\n     */\n    @Bean\n    public ServletServerContainerFactoryBean websocketContainer() {\n        final var container = new ServletServerContainerFactoryBean();\n        final var webSocket = properties.webSocket();\n        container.setMaxTextMessageBufferSize((int) webSocket.maxTextMessageSize().toBytes());\n        container.setMaxBinaryMessageBufferSize((int) webSocket.maxBinaryMessageSize().toBytes());\n        // Prevent premature session termination by increasing idle timeout\n        if (webSocket.sessionIdleTimeout() != null) {\n            container.setMaxSessionIdleTimeout(webSocket.sessionIdleTimeout().toMillis());\n        }\n        return container;\n    }\n}\n"
  },
  {
    "path": "net-proxy/src/main/java/tech/amak/portbuddy/netproxy/security/SecurityConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.netproxy.security;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.core.annotation.Order;\nimport org.springframework.http.HttpMethod;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;\nimport org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;\nimport org.springframework.security.config.http.SessionCreationPolicy;\nimport org.springframework.security.oauth2.jwt.JwtDecoder;\nimport org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.security.web.context.RequestAttributeSecurityContextRepository;\n\nimport lombok.RequiredArgsConstructor;\n\n@Configuration\n@EnableWebSecurity\n@RequiredArgsConstructor\npublic class SecurityConfig {\n\n    private final JwtDecoder jwtDecoder;\n\n    @Bean\n    @Order(1)\n    public SecurityFilterChain apiSecurityFilterChain(final HttpSecurity http) throws Exception {\n        http\n            .securityMatcher(\"/api/**\")\n            .cors(AbstractHttpConfigurer::disable)\n            .csrf(AbstractHttpConfigurer::disable)\n            .authorizeHttpRequests(auth -> auth\n                .requestMatchers(HttpMethod.GET, \"/actuator/health\", \"/actuator/health/**\").permitAll()\n                .anyRequest().authenticated()\n            )\n            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))\n            .securityContext(sc -> sc.securityContextRepository(new RequestAttributeSecurityContextRepository()))\n            .oauth2ResourceServer(oauth2 -> oauth2\n                .jwt(jwt -> jwt\n                    .decoder(jwtDecoder)\n                    .jwtAuthenticationConverter(jwtAuthenticationConverter())\n                )\n            );\n        return http.build();\n    }\n\n    @Bean\n    @Order(2)\n    public SecurityFilterChain otherSecurityFilterChain(final HttpSecurity http) throws Exception {\n        http\n            .cors(AbstractHttpConfigurer::disable)\n            .csrf(AbstractHttpConfigurer::disable)\n            .authorizeHttpRequests(auth -> auth\n                .requestMatchers(HttpMethod.GET, \"/actuator/health\", \"/actuator/health/**\").permitAll()\n                .anyRequest().permitAll()\n            );\n        return http.build();\n    }\n\n    @Bean\n    public JwtAuthenticationConverter jwtAuthenticationConverter() {\n        return new JwtAuthenticationConverter();\n    }\n}\n"
  },
  {
    "path": "net-proxy/src/main/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelRegistry.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.netproxy.tunnel;\n\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.io.PushbackInputStream;\nimport java.net.DatagramPacket;\nimport java.net.DatagramSocket;\nimport java.net.InetSocketAddress;\nimport java.net.ServerSocket;\nimport java.net.Socket;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.Future;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ScheduledFuture;\nimport java.util.concurrent.TimeUnit;\n\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.socket.BinaryMessage;\nimport org.springframework.web.socket.TextMessage;\nimport org.springframework.web.socket.WebSocketSession;\nimport org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport jakarta.annotation.PreDestroy;\nimport lombok.Data;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.TunnelType;\nimport tech.amak.portbuddy.common.tunnel.BinaryWsFrame;\nimport tech.amak.portbuddy.common.tunnel.WsTunnelMessage;\nimport tech.amak.portbuddy.netproxy.config.AppProperties;\n\n@Slf4j\n@Component\npublic class NetTunnelRegistry {\n\n    private static final int MIN_PORT = 10000;\n    private static final int MAX_PORT = 65535;\n\n    private static final byte[][] HTTP_METHODS_BYTES = {\n        \"GET \".getBytes(), \"POST \".getBytes(), \"PUT \".getBytes(), \"DELETE \".getBytes(),\n        \"HEAD \".getBytes(), \"OPTIONS \".getBytes(), \"PATCH \".getBytes(), \"TRACE \".getBytes(), \"CONNECT \".getBytes()\n    };\n\n    /**\n     * Map of tunnels by their ID.\n     */\n    final Map<UUID, Tunnel> byTunnelId = new ConcurrentHashMap<>();\n\n    /**\n     * Map of session ID to tunnel ID for fast lookup during detachment.\n     */\n    private final Map<String, UUID> sessionToTunnelId = new ConcurrentHashMap<>();\n\n    /**\n     * Pool for IO operations using Virtual Threads.\n     */\n    private final ExecutorService ioPool = Executors.newVirtualThreadPerTaskExecutor();\n\n    /**\n     * Scheduler for cleanup tasks.\n     */\n    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();\n\n    /**\n     * Timeout for orphaned tunnels (no session attached).\n     */\n    private static final long ORPHAN_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(5);\n\n    /**\n     * Jackson object mapper.\n     */\n    private final ObjectMapper mapper;\n\n    /**\n     * Application properties.\n     */\n    private final AppProperties properties;\n\n    /**\n     * Constructor for NetTunnelRegistry.\n     *\n     * @param mapper     Jackson object mapper\n     * @param properties application properties\n     */\n    public NetTunnelRegistry(final ObjectMapper mapper, final AppProperties properties) {\n        this.mapper = mapper;\n        this.properties = properties;\n        this.scheduler.scheduleAtFixedRate(this::cleanupOrphanedTunnels, 1, 1, TimeUnit.MINUTES);\n    }\n\n    /**\n     * Shuts down the executor services when the component is destroyed.\n     */\n    @PreDestroy\n    public void shutdown() {\n        log.info(\"Shutting down NetTunnelRegistry...\");\n        scheduler.shutdown();\n        ioPool.shutdown();\n        try {\n            if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {\n                scheduler.shutdownNow();\n            }\n            if (!ioPool.awaitTermination(5, TimeUnit.SECONDS)) {\n                ioPool.shutdownNow();\n            }\n        } catch (final InterruptedException e) {\n            scheduler.shutdownNow();\n            ioPool.shutdownNow();\n            Thread.currentThread().interrupt();\n        }\n        for (final var tunnelId : byTunnelId.keySet()) {\n            closeTunnel(tunnelId);\n        }\n    }\n\n    /**\n     * Periodically cleans up \"orphaned\" tunnels that have no active session and\n     * have been inactive for more than {@link #ORPHAN_TIMEOUT_MS}.\n     */\n    private void cleanupOrphanedTunnels() {\n        final var now = System.currentTimeMillis();\n        for (final var tunnel : byTunnelId.values()) {\n            final var session = tunnel.session;\n            final var isOrphaned = session == null || !session.isOpen();\n\n            if (!isOrphaned) {\n                continue;\n            }\n\n            final var age = now - tunnel.createdAt;\n            final var udpInactiveTime = now - tunnel.lastUdpActivity;\n            final var isUdpTunnel = tunnel.udpSocket != null;\n\n            if (age > ORPHAN_TIMEOUT_MS) {\n                log.warn(\"Cleaning up orphaned tunnel {} (no session or closed for {}ms)\",\n                    tunnel.tunnelId, age);\n                closeTunnel(tunnel.tunnelId);\n            } else if (isUdpTunnel && udpInactiveTime > ORPHAN_TIMEOUT_MS) {\n                log.warn(\"Cleaning up inactive UDP tunnel {} (no activity for {}ms)\",\n                    tunnel.tunnelId, udpInactiveTime);\n                closeTunnel(tunnel.tunnelId);\n            }\n        }\n    }\n\n    /**\n     * Finds and closes a tunnel that is using the specified port for TCP or UDP.\n     *\n     * @param port the port to check\n     */\n    private void closeTunnelUsingPort(final int port) {\n        for (final var tunnel : byTunnelId.values()) {\n            final var tcpPort = tunnel.serverSocket != null ? tunnel.serverSocket.getLocalPort() : -1;\n            final var udpPort = tunnel.udpSocket != null ? tunnel.udpSocket.getLocalPort() : -1;\n\n            if (tcpPort == port || udpPort == port) {\n                log.info(\"Closing existing tunnel {} using port {}\", tunnel.tunnelId, port);\n                closeTunnel(tunnel.tunnelId);\n                break;\n            }\n        }\n    }\n\n    /**\n     * Exposes a network tunnel for either TCP or UDP based on tunnelType parameter.\n     *\n     * @param tunnelId   tunnel identifier\n     * @param tunnelType tunnelType string (\"tcp\" or \"udp\")\n     * @return exposed public port info\n     * @throws IOException on IO errors\n     */\n    public ExposedPort expose(final UUID tunnelId, final TunnelType tunnelType, final int desiredPort)\n        throws IOException {\n        if (desiredPort <= MIN_PORT) {\n            throw new IllegalArgumentException(\"desiredPort must be greater than \" + MIN_PORT);\n        }\n        if (desiredPort > MAX_PORT) {\n            throw new IllegalArgumentException(\"desiredPort must be less than \" + MAX_PORT);\n        }\n        return switch (tunnelType) {\n            case UDP -> exposeUdp(tunnelId, desiredPort);\n            case TCP -> exposeTcp(tunnelId, desiredPort);\n            default -> throw new IllegalArgumentException(\"Unsupported tunnel type: \" + tunnelType);\n        };\n    }\n\n    /**\n     * Expose TCP.\n     */\n    private ExposedPort exposeTcp(final UUID tunnelId, final Integer desiredPort) throws IOException {\n        final var tunnel = byTunnelId.computeIfAbsent(tunnelId, Tunnel::new);\n        synchronized (tunnel) {\n            if (byTunnelId.get(tunnelId) != tunnel) {\n                // The tunnel was replaced or removed; retry.\n                return exposeTcp(tunnelId, desiredPort);\n            }\n            if (tunnel.serverSocket != null && !tunnel.serverSocket.isClosed()) {\n                return new ExposedPort(tunnel.serverSocket.getLocalPort());\n            }\n            try {\n                tunnel.serverSocket = new ServerSocket(desiredPort);\n                log.info(\"New Tunnel {} using port {}\", tunnel.tunnelId, desiredPort);\n            } catch (final IOException bindEx) {\n                log.info(\"TCP port {} is busy. Trying to close existing tunnel and retry.\", desiredPort);\n                closeTunnelUsingPort(desiredPort);\n                try {\n                    tunnel.serverSocket = new ServerSocket(desiredPort);\n                } catch (final IOException secondBindEx) {\n                    log.error(\"TCP port {} is still busy.\", desiredPort);\n                    throw secondBindEx;\n                }\n            }\n            tunnel.acceptLoopFuture = ioPool.submit(() -> acceptLoop(tunnel));\n            return new ExposedPort(tunnel.serverSocket.getLocalPort());\n        }\n    }\n\n    /**\n     * Expose UDP by binding a datagram socket and starting a receive loop that forwards\n     * datagrams over the control WebSocket using binary frames.\n     */\n    private ExposedPort exposeUdp(final UUID tunnelId, final int desiredPort) throws IOException {\n\n        final var tunnel = byTunnelId.computeIfAbsent(tunnelId, Tunnel::new);\n        synchronized (tunnel) {\n            if (byTunnelId.get(tunnelId) != tunnel) {\n                // The tunnel was replaced or removed; retry.\n                return exposeUdp(tunnelId, desiredPort);\n            }\n            if (tunnel.udpSocket != null && !tunnel.udpSocket.isClosed()) {\n                return new ExposedPort(tunnel.udpSocket.getLocalPort());\n            }\n            final DatagramSocket socket;\n            DatagramSocket sock;\n            try {\n                sock = new DatagramSocket(desiredPort);\n            } catch (final IOException bindEx) {\n                log.info(\"UDP port {} is busy. Trying to close existing tunnel and retry.\", desiredPort);\n                closeTunnelUsingPort(desiredPort);\n                try {\n                    sock = new DatagramSocket(desiredPort);\n                } catch (final IOException secondBindEx) {\n                    log.error(\"UDP port {} is still busy.\", desiredPort);\n                    throw secondBindEx;\n                }\n            }\n            socket = sock;\n            tunnel.udpSocket = socket;\n            tunnel.udpReceiveLoopFuture = ioPool.submit(() -> udpReceiveLoop(tunnel));\n            return new ExposedPort(socket.getLocalPort());\n        }\n    }\n\n    /**\n     * Attach session to tunnel.\n     *\n     * @param tunnelId tunnel id.\n     * @param session  websocket session.\n     */\n    public void attachSession(final UUID tunnelId, final WebSocketSession session) {\n        final var tunnel = byTunnelId.computeIfAbsent(tunnelId, Tunnel::new);\n        synchronized (tunnel) {\n            if (byTunnelId.get(tunnelId) != tunnel) {\n                // The tunnel was replaced or removed; retry.\n                attachSession(tunnelId, session);\n                return;\n            }\n            final var ws = properties.webSocket();\n            tunnel.session = new ConcurrentWebSocketSessionDecorator(\n                session,\n                (int) ws.sendTimeLimit().toMillis(),\n                (int) ws.sendBufferSizeLimit().toBytes()\n            );\n            sessionToTunnelId.put(session.getId(), tunnelId);\n        }\n    }\n\n    /**\n     * Returns the WebSocket session associated with the given tunnel ID.\n     *\n     * @param tunnelId the tunnel identifier\n     * @return the WebSocket session, or null if not found\n     */\n    public WebSocketSession getSession(final UUID tunnelId) {\n        final var tunnel = byTunnelId.get(tunnelId);\n        return tunnel != null ? tunnel.session : null;\n    }\n\n    /**\n     * Detaches a given WebSocket session from any associated tunnel.\n     * If the specified session is currently linked to a tunnel, the link is severed.\n     *\n     * @param session the WebSocket session to detach\n     */\n    public void detachSession(final WebSocketSession session) {\n        final var tunnelId = sessionToTunnelId.remove(session.getId());\n        if (tunnelId != null) {\n            log.info(\"Session detached for tunnel {}. Closing tunnel.\", tunnelId);\n            closeTunnel(tunnelId);\n        }\n    }\n\n    /**\n     * Closes and removes the entire tunnel identified by the given tunnelId.\n     * This will immediately close the TCP ServerSocket (if any), all accepted TCP\n     * connections, and the UDP DatagramSocket (if any). Any associated WebSocket\n     * session reference is cleared. The tunnel entry is removed from the registry.\n     *\n     * @param tunnelId identifier of the tunnel to close\n     */\n    public void closeTunnel(final UUID tunnelId) {\n        final var tunnel = byTunnelId.remove(tunnelId);\n        if (tunnel == null) {\n            return;\n        }\n        synchronized (tunnel) {\n            // Interrupt loops\n            if (tunnel.acceptLoopFuture != null) {\n                tunnel.acceptLoopFuture.cancel(true);\n                tunnel.acceptLoopFuture = null;\n            }\n            if (tunnel.udpReceiveLoopFuture != null) {\n                tunnel.udpReceiveLoopFuture.cancel(true);\n                tunnel.udpReceiveLoopFuture = null;\n            }\n            // Close TCP acceptor first so accept loops break\n            final var server = tunnel.serverSocket;\n            if (server != null) {\n                try {\n                    server.close();\n                } catch (final Exception e) {\n                    log.debug(\"Failed to close ServerSocket: {}\", e.toString());\n                }\n                tunnel.serverSocket = null;\n            }\n            // Close all live TCP connections\n            for (final var connection : tunnel.connections.values()) {\n                connection.close();\n            }\n            tunnel.connections.clear();\n            // Close UDP socket\n            final var udp = tunnel.udpSocket;\n            if (udp != null) {\n                try {\n                    udp.close();\n                } catch (final Exception e) {\n                    log.debug(\"Failed to close DatagramSocket: {}\", e.toString());\n                }\n                tunnel.udpSocket = null;\n            }\n            tunnel.udpRemotes.clear();\n            final var session = tunnel.session;\n            if (session != null) {\n                sessionToTunnelId.remove(session.getId());\n                if (session.isOpen()) {\n                    try {\n                        session.close();\n                    } catch (final IOException e) {\n                        log.debug(\"Failed to close WebSocket session for tunnel {}: {}\", tunnelId, e.getMessage());\n                    }\n                }\n                tunnel.session = null;\n            }\n        }\n    }\n\n    private void acceptLoop(final Tunnel tunnel) {\n        try {\n            while (!tunnel.serverSocket.isClosed() && !Thread.currentThread().isInterrupted()) {\n                final var socket = tunnel.serverSocket.accept();\n                ioPool.submit(() -> handleNewConnection(tunnel, socket));\n            }\n        } catch (final Exception e) {\n            log.info(\"Accept loop ended for tunnel {}: {}\", tunnel.tunnelId, e.getMessage());\n        }\n    }\n\n    /**\n     * Handles a new TCP connection by peeking at the initial bytes to detect HTTP requests.\n     * If an HTTP request is detected, the connection is closed.\n     *\n     * @param tunnel the tunnel associated with the connection\n     * @param socket the newly accepted socket\n     */\n    private void handleNewConnection(final Tunnel tunnel, final Socket socket) {\n        if (tunnel.session == null || !tunnel.session.isOpen()) {\n            try {\n                socket.close();\n            } catch (final IOException ignore) {\n                // ignore\n            }\n            return;\n        }\n        String connId = null;\n        try {\n            socket.setSoTimeout(30000); // 30s timeout for initial handshake\n            connId = UUID.randomUUID().toString();\n            final var pushbackIn = new PushbackInputStream(socket.getInputStream(), 16);\n            final var connection = new Connection(connId, socket, pushbackIn);\n            tunnel.connections.put(connId, connection);\n\n            if (!sendOpen(tunnel, connId)) {\n                throw new IOException(\"Failed to send OPEN message to client\");\n            }\n\n            // Start a task to cleanup this connection if it's not opened within a reasonable time\n            final var finalConnId = connId;\n            final var cleanupTask = scheduler.schedule(() -> {\n                final var conn = tunnel.connections.get(finalConnId);\n                if (conn != null && !conn.pumpStarted) {\n                    log.warn(\"Connection {} for tunnel {} was not opened by client within 60s. Closing.\",\n                        finalConnId, tunnel.tunnelId);\n                    onClientClose(tunnel.tunnelId, finalConnId);\n                }\n            }, 60, TimeUnit.SECONDS);\n            connection.setCleanupTask(cleanupTask);\n        } catch (final Exception e) {\n            log.error(\"Failed to handle new connection for tunnel {}: {}\", tunnel.tunnelId, e.toString());\n            final var conn = connId != null ? tunnel.connections.remove(connId) : null;\n            if (conn != null) {\n                conn.close();\n            } else {\n                try {\n                    socket.close();\n                } catch (final IOException ignore) {\n                    log.warn(\"Failed to close socket for tunnel {}: {}\", tunnel.tunnelId, e.getMessage());\n                }\n            }\n        }\n    }\n\n    private void pumpFromPublic(final Tunnel tunnel, final Connection connection) {\n        final var buffer = new byte[8192];\n        try {\n            connection.socket.setSoTimeout((int) properties.webSocket().sessionIdleTimeout().toMillis());\n            // Peek at initial bytes to detect HTTP requests\n            final var peekBuffer = new byte[16];\n            final var bytesRead = connection.in.read(peekBuffer);\n            if (bytesRead != -1) {\n                for (final var methodBytes : HTTP_METHODS_BYTES) {\n                    if (startsWith(peekBuffer, bytesRead, methodBytes)) {\n                        log.warn(\"Blocking HTTP request on TCP tunnel {}: {}\",\n                            tunnel.tunnelId, new String(peekBuffer, 0, bytesRead).trim());\n                        final var closeMsg = new WsTunnelMessage();\n                        closeMsg.setWsType(WsTunnelMessage.Type.CLOSE);\n                        closeMsg.setConnectionId(connection.connectionId);\n                        sendToClient(tunnel, closeMsg);\n                        connection.close();\n                        tunnel.connections.remove(connection.connectionId);\n                        return;\n                    }\n                }\n                // If not HTTP, send the peeked bytes and continue\n                if (!sendBinaryToClient(tunnel, connection.connectionId, peekBuffer, 0, bytesRead)) {\n                    return;\n                }\n            }\n\n            while (!Thread.currentThread().isInterrupted()) {\n                final var next = connection.in.read(buffer);\n                if (next == -1) {\n                    break;\n                }\n                if (!sendBinaryToClient(tunnel, connection.connectionId, buffer, 0, next)) {\n                    break;\n                }\n            }\n        } catch (final Exception e) {\n            log.error(\"Failed to read from public socket for tunnel {}: {}\", tunnel.tunnelId, e.getMessage(), e);\n        } finally {\n            log.info(\"Public socket closed for tunnel {}: {}\", tunnel.tunnelId, connection.connectionId);\n            connection.close();\n            tunnel.connections.remove(connection.connectionId);\n        }\n    }\n\n    private boolean startsWith(final byte[] buffer, final int bytesRead, final byte[] prefix) {\n        if (prefix.length > bytesRead) {\n            return false;\n        }\n        for (int i = 0; i < prefix.length; i++) {\n            if (buffer[i] != prefix[i]) {\n                return false;\n            }\n        }\n        return true;\n    }\n\n    private void udpReceiveLoop(final Tunnel tunnel) {\n        final var buffer = new byte[8192];\n        try {\n            while (tunnel.udpSocket != null && !tunnel.udpSocket.isClosed()\n                   && !Thread.currentThread().isInterrupted()) {\n                final var packet = new DatagramPacket(buffer, buffer.length);\n                tunnel.udpSocket.receive(packet);\n                tunnel.lastUdpActivity = System.currentTimeMillis();\n                final var remote = new InetSocketAddress(packet.getAddress(), packet.getPort());\n                final var connectionId = remote.getHostString() + \":\" + remote.getPort();\n                tunnel.udpRemotes.put(connectionId, remote);\n                sendBinaryToClient(tunnel, connectionId, packet.getData(), packet.getOffset(), packet.getLength());\n            }\n        } catch (final Exception e) {\n            log.info(\"UDP receive loop ended for tunnel {}: {}\", tunnel.tunnelId, e.toString());\n        }\n    }\n\n    /**\n     * Called when client acknowledges an OPEN with OPEN_OK. Starts pumping data\n     * from the public socket to the client over WebSocket for the given connection.\n     */\n    public void onClientOpenOk(final UUID tunnelId, final String connectionId) {\n        final var tunnel = byTunnelId.get(tunnelId);\n        if (tunnel == null) {\n            return;\n        }\n        final var connection = tunnel.connections.get(connectionId);\n        if (connection == null) {\n            return;\n        }\n        if (connection.cleanupTask != null) {\n            connection.cleanupTask.cancel(false);\n            connection.cleanupTask = null;\n        }\n        if (connection.pumpStarted) {\n            return;\n        }\n        synchronized (connection) {\n            if (connection.pumpStarted) {\n                return;\n            }\n            connection.pumpStarted = true;\n            connection.pumpFuture = ioPool.submit(() -> pumpFromPublic(tunnel, connection));\n        }\n    }\n\n    /**\n     * Backward compatibility handler for older clients that still send TEXT frames\n     * with base64-encoded payload inside {@link WsTunnelMessage} of type BINARY.\n     */\n    public void onClientBinary(final UUID tunnelId, final String connectionId, final String dataB64) {\n        final var tunnel = byTunnelId.get(tunnelId);\n        if (tunnel == null) {\n            return;\n        }\n        final var connection = tunnel.connections.get(connectionId);\n        if (connection == null) {\n            return;\n        }\n        final var out = connection.out;\n        if (out == null) {\n            return;\n        }\n        try {\n            out.write(Base64.getDecoder().decode(dataB64));\n            out.flush();\n        } catch (final IOException e) {\n            log.debug(\"Failed to write to public socket: {}\", e.toString());\n            onClientClose(tunnelId, connectionId);\n        }\n    }\n\n    /**\n     * Handles incoming binary WebSocket frames from the client. Data is routed directly\n     * to the corresponding public TCP socket without base64 encoding.\n     */\n    public void onClientBinaryBytes(final UUID tunnelId, final String connectionId, final byte[] data) {\n        final var tunnel = byTunnelId.get(tunnelId);\n        if (tunnel == null) {\n            return;\n        }\n        // If UDP is active on this tunnel, route as a datagram\n        if (tunnel.udpSocket != null) {\n            tunnel.lastUdpActivity = System.currentTimeMillis();\n            final var remote = tunnel.udpRemotes.get(connectionId);\n            if (remote == null) {\n                return;\n            }\n            try {\n                final var packet = new DatagramPacket(data, 0, data.length, remote);\n                tunnel.udpSocket.send(packet);\n            } catch (final IOException e) {\n                log.debug(\"Failed to send UDP packet: {}\", e.toString());\n            }\n            return;\n        }\n\n        // Else assume TCP\n        final var connection = tunnel.connections.get(connectionId);\n        if (connection == null) {\n            return;\n        }\n        final var out = connection.out;\n        if (out == null) {\n            return;\n        }\n        try {\n            out.write(data);\n            out.flush();\n        } catch (final IOException e) {\n            log.debug(\"Failed to write to public socket: {}. Closing connection.\", e.toString());\n            onClientClose(tunnel.tunnelId, connectionId);\n        }\n    }\n\n    /**\n     * Handles the closure of a client connection associated with a specific tunnel.\n     * If the tunnel and connection exist, the connection is removed and its socket is closed.\n     * If either the tunnel or connection does not exist, no operation is performed.\n     *\n     * @param tunnelId the identifier of the\n     */\n    public void onClientClose(final UUID tunnelId, final String connectionId) {\n        final var tunnel = byTunnelId.get(tunnelId);\n        if (tunnel == null) {\n            return;\n        }\n        if (tunnel.udpSocket != null) {\n            // Just remove mapping; no need to close the UDP socket itself\n            tunnel.udpRemotes.remove(connectionId);\n        } else {\n            final var connection = tunnel.connections.remove(connectionId);\n            if (connection != null) {\n                connection.close();\n            }\n        }\n    }\n\n    private boolean sendOpen(final Tunnel tunnel, final String connId) {\n        final var message = new WsTunnelMessage();\n        message.setWsType(WsTunnelMessage.Type.OPEN);\n        message.setConnectionId(connId);\n        return sendToClient(tunnel, message);\n    }\n\n    private boolean sendToClient(final Tunnel tunnel, final WsTunnelMessage message) {\n        try {\n            if (tunnel.session != null && tunnel.session.isOpen()) {\n                tunnel.session.sendMessage(new TextMessage(mapper.writeValueAsString(message)));\n                return true;\n            }\n        } catch (final Exception e) {\n            log.debug(\"Failed to send to client: {}\", e.toString());\n        }\n        return false;\n    }\n\n    private boolean sendBinaryToClient(final Tunnel tunnel,\n                                       final String connectionId,\n                                       final byte[] bytes,\n                                       final int offset,\n                                       final int length) {\n        try {\n            if (tunnel.session != null && tunnel.session.isOpen()) {\n                final var payload = BinaryWsFrame.encodeToByteBuffer(connectionId, bytes, offset, length);\n                final var binaryMessage = new BinaryMessage(payload);\n                tunnel.session.sendMessage(binaryMessage);\n                return true;\n            }\n        } catch (final Exception e) {\n            log.debug(\"Failed to send binary to client: {}\", e.getMessage());\n        }\n        return false;\n    }\n\n    @Data\n    public static class ExposedPort {\n        private final int port;\n    }\n\n    @Data\n    public static class Tunnel {\n        private final UUID tunnelId;\n        private long createdAt = System.currentTimeMillis();\n        private volatile WebSocketSession session;\n        private volatile ServerSocket serverSocket;\n        private volatile Future<?> acceptLoopFuture;\n        private final Map<String, Connection> connections = new ConcurrentHashMap<>();\n        private volatile DatagramSocket udpSocket;\n        private volatile Future<?> udpReceiveLoopFuture;\n        private final Map<String, InetSocketAddress> udpRemotes =\n            Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) {\n                @Override\n                protected boolean removeEldestEntry(final Map.Entry<String, InetSocketAddress> eldest) {\n                    return size() > 100;\n                }\n            });\n        private long lastUdpActivity = System.currentTimeMillis();\n\n        Tunnel(final UUID tunnelId) {\n            this.tunnelId = tunnelId;\n        }\n    }\n\n    @Data\n    public static class Connection {\n        final String connectionId;\n        Socket socket;\n        InputStream in;\n        OutputStream out;\n        volatile boolean pumpStarted = false;\n        volatile Future<?> pumpFuture;\n        volatile ScheduledFuture<?> cleanupTask;\n\n        Connection(final String connectionId,\n                   final Socket socket,\n                   final InputStream in) throws IOException {\n\n            this.connectionId = connectionId;\n            this.socket = socket;\n            this.in = in;\n            this.out = socket.getOutputStream();\n        }\n\n        void setCleanupTask(final ScheduledFuture<?> cleanupTask) {\n            this.cleanupTask = cleanupTask;\n        }\n\n        /**\n         * Closes the socket and nullifies all resource references.\n         */\n        void close() {\n            try {\n                if (cleanupTask != null) {\n                    cleanupTask.cancel(false);\n                    cleanupTask = null;\n                }\n            } catch (final Exception e) {\n                log.error(\"Failed to cancel cleanup task: {}\", e.getMessage());\n            }\n            try {\n                if (pumpFuture != null) {\n                    pumpFuture.cancel(true);\n                    pumpFuture = null;\n                }\n            } catch (final Exception e) {\n                log.error(\"Failed to cancel pump future: {}\", e.getMessage());\n            }\n            try {\n                if (socket != null && !socket.isClosed()) {\n                    socket.close();\n                }\n            } catch (final Exception e) {\n                log.error(\"Failed to close socket: {}\", e.toString());\n            }\n            socket = null;\n\n            if (in != null) {\n                try {\n                    in.close();\n                } catch (final Exception e) {\n                    log.error(\"Failed to close input stream: {}\", e.getMessage());\n                }\n                in = null;\n            }\n            if (out != null) {\n                try {\n                    out.close();\n                } catch (final Exception e) {\n                    log.error(\"Failed to close output stream: {}\", e.getMessage());\n                }\n                out = null;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "net-proxy/src/main/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelWebSocketHandler.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.netproxy.tunnel;\n\nimport java.net.URI;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.UUID;\n\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.socket.BinaryMessage;\nimport org.springframework.web.socket.CloseStatus;\nimport org.springframework.web.socket.TextMessage;\nimport org.springframework.web.socket.WebSocketSession;\nimport org.springframework.web.socket.handler.AbstractWebSocketHandler;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.TunnelType;\nimport tech.amak.portbuddy.common.tunnel.BinaryWsFrame;\nimport tech.amak.portbuddy.common.tunnel.ControlMessage;\nimport tech.amak.portbuddy.common.tunnel.MessageEnvelope;\nimport tech.amak.portbuddy.common.tunnel.WsTunnelMessage;\nimport tech.amak.portbuddy.common.utils.IdUtils;\nimport tech.amak.portbuddy.netproxy.config.AppProperties;\n\n@Slf4j\n@Component\n@RequiredArgsConstructor\npublic class NetTunnelWebSocketHandler extends AbstractWebSocketHandler {\n\n    private final NetTunnelRegistry registry;\n    private final ObjectMapper mapper;\n    private final AppProperties properties;\n\n    @Override\n    public void afterConnectionEstablished(final WebSocketSession session) throws Exception {\n        final var tunnelId = extractTunnelId(session);\n        if (tunnelId == null) {\n            session.close(CloseStatus.BAD_DATA);\n            return;\n        }\n        // Parse query params: type and port\n        final var params = parseQueryParams(session.getUri());\n        final var typeStr = params.get(\"type\");\n        final var portStr = params.get(\"port\");\n        if (typeStr == null || portStr == null) {\n            session.close(CloseStatus.BAD_DATA);\n            return;\n        }\n        final TunnelType tunnelType;\n        try {\n            tunnelType = \"udp\".equalsIgnoreCase(typeStr) ? TunnelType.UDP : TunnelType.TCP;\n        } catch (final Exception ignore) {\n            session.close(CloseStatus.BAD_DATA);\n            return;\n        }\n        final Integer desiredPort;\n        try {\n            desiredPort = Integer.parseInt(portStr);\n        } catch (final Exception ignore) {\n            session.close(CloseStatus.BAD_DATA);\n            return;\n        }\n\n        // Prepare exposure and then attach the session\n        final NetTunnelRegistry.ExposedPort exposedPort;\n        try {\n            exposedPort = registry.expose(tunnelId, tunnelType, desiredPort);\n        } catch (final Exception e) {\n            log.warn(\"Failed to expose {} on {}: {}\", tunnelType, desiredPort, e.toString());\n            session.close(CloseStatus.SERVER_ERROR);\n            return;\n        }\n        // TODO: validate Authorization header/JWT\n        registry.attachSession(tunnelId, session);\n        final var decoratedSession = registry.getSession(tunnelId);\n        log.info(\"Net tunnel WS established: {} type={} port={}\", tunnelId, tunnelType, desiredPort);\n\n        // Inform client about actual public details in case port was re-assigned\n        try {\n            final var info = new WsTunnelMessage();\n            info.setWsType(WsTunnelMessage.Type.EXPOSED);\n            info.setPublicHost(properties.publicHost());\n            info.setPublicPort(exposedPort.getPort());\n            if (decoratedSession != null) {\n                decoratedSession.sendMessage(new TextMessage(mapper.writeValueAsString(info)));\n            }\n        } catch (final Exception e) {\n            log.debug(\"Failed to send EXPOSED info: {}\", e.toString());\n        }\n    }\n\n    @Override\n    protected void handleTextMessage(final WebSocketSession session, final TextMessage textMessage) throws Exception {\n        final var tunnelId = extractTunnelId(session);\n        final var payload = textMessage.getPayload();\n        // Route by envelope kind: CTRL (heartbeat), WS (control/data)\n        final var env = mapper.readValue(payload, MessageEnvelope.class);\n        if (env.getKind() != null && env.getKind().equals(\"CTRL\")) {\n            final var ctrl = mapper.readValue(payload, ControlMessage.class);\n            if (ctrl.getType() == ControlMessage.Type.PING) {\n                final var pong = new ControlMessage();\n                pong.setType(ControlMessage.Type.PONG);\n                pong.setTs(System.currentTimeMillis());\n                final var decoratedSession = registry.getSession(tunnelId);\n                if (decoratedSession != null) {\n                    decoratedSession.sendMessage(new TextMessage(mapper.writeValueAsString(pong)));\n                }\n            }\n            return;\n        }\n        if (env.getKind() != null && env.getKind().equals(\"WS\")) {\n            final var message = mapper.readValue(payload, WsTunnelMessage.class);\n            switch (message.getWsType()) {\n                case OPEN_OK -> registry.onClientOpenOk(tunnelId, message.getConnectionId());\n                case BINARY -> {\n                    // Backward compatibility: accept base64 text payloads\n                    registry.onClientBinary(tunnelId, message.getConnectionId(), message.getDataB64());\n                }\n                case CLOSE -> registry.onClientClose(tunnelId, message.getConnectionId());\n                default -> log.debug(\"Ignoring WS control type: {}\", message.getWsType());\n            }\n            return;\n        }\n        // Unknown kinds are ignored\n    }\n\n    @Override\n    protected void handleBinaryMessage(final WebSocketSession session, final BinaryMessage message) {\n        final var tunnelId = extractTunnelId(session);\n        final var payload = message.getPayload();\n        final var decoded = BinaryWsFrame.decode(payload);\n        if (decoded == null) {\n            return;\n        }\n        registry.onClientBinaryBytes(tunnelId, decoded.connectionId(), decoded.data());\n        payload.clear();\n    }\n\n    @Override\n    public void afterConnectionClosed(final WebSocketSession session, final CloseStatus status) {\n        // Detach session and close exposed sockets for this tunnel immediately\n        try {\n            final var tunnelId = extractTunnelId(session);\n            if (tunnelId != null) {\n                registry.closeTunnel(tunnelId);\n            }\n        } catch (final Exception e) {\n            log.debug(\"Failed to close tunnel on WS close: {}\", e.toString());\n        } finally {\n            registry.detachSession(session);\n        }\n    }\n\n    private UUID extractTunnelId(final WebSocketSession session) {\n        return IdUtils.extractTunnelId(session.getUri());\n    }\n\n    private Map<String, String> parseQueryParams(final URI uri) {\n        final var map = new HashMap<String, String>();\n        if (uri == null) {\n            return map;\n        }\n        final var query = uri.getQuery();\n        if (query == null || query.isBlank()) {\n            return map;\n        }\n        final var parts = query.split(\"&\");\n        for (final var part : parts) {\n            final var idx = part.indexOf('=');\n            if (idx <= 0) {\n                continue;\n            }\n            final var key = part.substring(0, idx);\n            final var value = part.substring(idx + 1);\n            map.put(key, value);\n        }\n        return map;\n    }\n}\n"
  },
  {
    "path": "net-proxy/src/main/java/tech/amak/portbuddy/netproxy/web/NetProxyController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.netproxy.web;\n\nimport java.util.UUID;\n\nimport org.springframework.http.MediaType;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.common.TunnelType;\nimport tech.amak.portbuddy.common.dto.ExposeResponse;\nimport tech.amak.portbuddy.netproxy.config.AppProperties;\nimport tech.amak.portbuddy.netproxy.tunnel.NetTunnelRegistry;\n\n@RestController\n@RequestMapping(path = \"/api/net-proxy\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\npublic class NetProxyController {\n\n    private final NetTunnelRegistry registry;\n    private final AppProperties properties;\n\n    @PostMapping(\"/expose\")\n    public ExposeResponse expose(final @RequestParam(\"tunnelId\") UUID tunnelId,\n                                 final @RequestParam(\"type\") TunnelType type,\n                                 final @RequestParam(value = \"desiredPort\") int desiredPort)\n        throws Exception {\n        final var exposedPort = registry.expose(tunnelId, type, desiredPort);\n        return new ExposeResponse(null, null, properties.publicHost(), exposedPort.getPort(), tunnelId, null);\n    }\n\n    /**\n     * Closes a TCP/UDP tunnel by its ID. Invoked by the server when an account is blocked\n     * or when an admin explicitly closes a tunnel.\n     *\n     * @param tunnelId unique identifier of the tunnel to close\n     */\n    @PostMapping(\"/close\")\n    public void close(final @RequestParam(\"tunnelId\") UUID tunnelId) {\n        registry.closeTunnel(tunnelId);\n    }\n}\n"
  },
  {
    "path": "net-proxy/src/main/resources/application.yml",
    "content": "server:\n  port: ${NET_PROXY_PORT:8070}\n\neureka:\n  client:\n    registryFetchIntervalSeconds: 2\n    eurekaServiceUrlPollIntervalSeconds: 60\n    service-url:\n      defaultZone: ${EUREKA_ZONE:http://portbuddy:portbuddy@localhost:8761/eureka}\n  instance:\n    metadata-map:\n      public-host: ${app.public-host}\n      region: ${NET_PROXY_REGION:Netherlands/Amsterdam}\n      coordinates: ${NET_PROXY_COORDINATES:52.378,4.9}\n\nspring:\n  application:\n    name: net-proxy\n  security:\n    oauth2:\n      resourceserver:\n        jwt:\n          # Validate incoming JWTs using the Server's public JWKS\n          jwk-set-uri: lb://port-buddy-server/.well-known/jwks.json\n\napp:\n  public-host: ${NET_PROXY_PUBLIC_HOST:localhost}\n  web-socket:\n    max-text-message-size: 1MB\n    max-binary-message-size: 1MB\n    session-idle-timeout: 10m\n    send-time-limit: 10s\n    send-buffer-size-limit: 1MB\n  jwt:\n    jwk-set-uri: lb://port-buddy-server/.well-known/jwks.json\n    issuer: port-buddy\n\nlogging:\n  level:\n    root: info\n    com.netflix.discovery: warn\n  file:\n    name: log/app.log\n  logback:\n    rollingpolicy:\n      max-history: 14\n      total-size-cap: 1000MB\n      max-file-size: 100MB\n"
  },
  {
    "path": "net-proxy/src/test/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelLeakVerificationTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.netproxy.tunnel;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.timeout;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.io.IOException;\nimport java.net.Socket;\nimport java.time.Duration;\nimport java.util.UUID;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.ScheduledExecutorService;\n\nimport org.junit.jupiter.api.Test;\nimport org.springframework.test.util.ReflectionTestUtils;\nimport org.springframework.util.unit.DataSize;\nimport org.springframework.web.socket.BinaryMessage;\nimport org.springframework.web.socket.TextMessage;\nimport org.springframework.web.socket.WebSocketSession;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport tech.amak.portbuddy.common.TunnelType;\nimport tech.amak.portbuddy.netproxy.config.AppProperties;\n\nclass NetTunnelLeakVerificationTest {\n\n    private final ObjectMapper mapper = new ObjectMapper();\n    private final AppProperties properties = new AppProperties(\n        \"localhost\",\n        new AppProperties.WebSocket(\n            DataSize.ofMegabytes(10),\n            DataSize.ofMegabytes(10),\n            Duration.ofMinutes(10),\n            Duration.ofSeconds(10),\n            DataSize.ofMegabytes(1)\n        ),\n        new AppProperties.Jwt(\"port-buddy\", \"http://localhost:8080\")\n    );\n\n    @Test\n    void testConnectionCleanupOnSendFailure() throws IOException, InterruptedException {\n        final var registry = new NetTunnelRegistry(mapper, properties);\n        final var tunnelId = UUID.randomUUID();\n        final var session = mock(WebSocketSession.class);\n        when(session.getId()).thenReturn(UUID.randomUUID().toString());\n        when(session.isOpen()).thenReturn(true);\n\n        // Simulate failure on sendMessage\n        doThrow(new IOException(\"Simulated failure\")).when(session).sendMessage(any(BinaryMessage.class));\n\n        registry.attachSession(tunnelId, session);\n        final var exposedPort = registry.expose(tunnelId, TunnelType.TCP, 10020);\n\n        try (final var clientSocket = new Socket(\"localhost\", exposedPort.getPort())) {\n            clientSocket.setSoTimeout(5000);\n\n            // Wait for registry to send OPEN\n            verify(session, timeout(2000)).sendMessage(any(TextMessage.class));\n\n            final var tunnel = registry.byTunnelId.get(tunnelId);\n            final var connectionId = tunnel.getConnections().keySet().iterator().next();\n\n            // Signal OPEN_OK\n            registry.onClientOpenOk(tunnelId, connectionId);\n\n            // Send some data from client\n            clientSocket.getOutputStream().write(\"some data\".getBytes());\n            clientSocket.getOutputStream().flush();\n\n            // The sendBinaryToClient should fail, and pumpFromPublic should exit and cleanup\n            // We wait a bit for the async processing\n            final var start = System.currentTimeMillis();\n            while (tunnel.getConnections().containsKey(connectionId) && (System.currentTimeMillis() - start) < 5000) {\n                Thread.sleep(100);\n            }\n\n            assertNull(tunnel.getConnections().get(connectionId),\n                \"Connection should have been cleaned up after send failure\");\n        } finally {\n            registry.closeTunnel(tunnelId);\n            registry.shutdown();\n        }\n    }\n\n    @Test\n    void testExecutorShutdown() {\n        final var registry = new NetTunnelRegistry(mapper, properties);\n        final var ioPool = (ExecutorService) ReflectionTestUtils.getField(registry, \"ioPool\");\n        final var scheduler = (ScheduledExecutorService) ReflectionTestUtils.getField(registry, \"scheduler\");\n\n        assertFalse(ioPool.isShutdown());\n        assertFalse(scheduler.isShutdown());\n\n        registry.shutdown();\n\n        assertTrue(ioPool.isShutdown());\n        assertTrue(scheduler.isShutdown());\n    }\n}\n"
  },
  {
    "path": "net-proxy/src/test/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelOrphanCleanupTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.netproxy.tunnel;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertNull;\n\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.Test;\nimport org.springframework.util.unit.DataSize;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport tech.amak.portbuddy.common.TunnelType;\nimport tech.amak.portbuddy.netproxy.config.AppProperties;\n\nclass NetTunnelOrphanCleanupTest {\n\n    private final ObjectMapper mapper = new ObjectMapper();\n    private final AppProperties properties = new AppProperties(\n        \"localhost\",\n        new AppProperties.WebSocket(\n            DataSize.ofMegabytes(10),\n            DataSize.ofMegabytes(10),\n            Duration.ofMinutes(10),\n            Duration.ofSeconds(10),\n            DataSize.ofMegabytes(1)\n        ),\n        new AppProperties.Jwt(\"port-buddy\", \"http://localhost:8080\")\n    );\n\n    @Test\n    void testOrphanedTunnelCleanup() throws IOException, InterruptedException {\n        try {\n            final var registry = new NetTunnelRegistry(mapper, properties);\n            final var tunnelId = UUID.randomUUID();\n\n            // Expose a tunnel - this creates it in byTunnelId\n            registry.expose(tunnelId, TunnelType.TCP, 10010);\n\n            assertNotNull(registry.byTunnelId.get(tunnelId), \"Tunnel should be present after expose\");\n\n            try {\n                final var cleanupMethod = NetTunnelRegistry.class.getDeclaredMethod(\"cleanupOrphanedTunnels\");\n                cleanupMethod.setAccessible(true);\n\n                // First call immediately - should NOT remove because createdAt is too recent (timeout is 5 mins)\n                cleanupMethod.invoke(registry);\n                assertNotNull(registry.byTunnelId.get(tunnelId), \"Tunnel should still be present immediately\");\n\n                // Instead of waiting 5 minutes, we'll set the createdAt timestamp back\n                final var tunnel = registry.byTunnelId.get(tunnelId);\n                tunnel.setCreatedAt(System.currentTimeMillis() - (6 * 60 * 1000)); // 6 mins ago\n\n                // Second call - should remove\n                cleanupMethod.invoke(registry);\n                assertNull(registry.byTunnelId.get(tunnelId), \"Tunnel should be removed after timeout\");\n            } catch (final Exception e) {\n                throw new RuntimeException(e);\n            }\n        } finally {\n            // No cleanup needed for static fields\n        }\n    }\n}\n"
  },
  {
    "path": "net-proxy/src/test/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelRegistryConcurrencyTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.netproxy.tunnel;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport java.io.IOException;\nimport java.net.Socket;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.Test;\nimport org.springframework.util.unit.DataSize;\nimport org.springframework.web.socket.WebSocketSession;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.TunnelType;\nimport tech.amak.portbuddy.netproxy.config.AppProperties;\n\n@Slf4j\nclass NetTunnelRegistryConcurrencyTest {\n\n    private final ObjectMapper mapper = new ObjectMapper();\n    private final AppProperties properties = new AppProperties(\n        \"localhost\",\n        new AppProperties.WebSocket(\n            DataSize.ofMegabytes(10),\n            DataSize.ofMegabytes(10),\n            Duration.ofMinutes(10),\n            Duration.ofSeconds(10),\n            DataSize.ofMegabytes(1)\n        ),\n        new AppProperties.Jwt(\"port-buddy\", \"http://localhost:8080\")\n    );\n\n    @Test\n    void testManyConcurrentConnections() throws IOException, InterruptedException {\n        final var registry = new NetTunnelRegistry(mapper, properties);\n        final var tunnelId = UUID.randomUUID();\n        final var session = mock(WebSocketSession.class);\n        when(session.getId()).thenReturn(UUID.randomUUID().toString());\n        when(session.isOpen()).thenReturn(true);\n\n        registry.attachSession(tunnelId, session);\n        final var exposedPort = registry.expose(tunnelId, TunnelType.TCP, 10005);\n\n        final int connectionCount = 300; // More than the previous 200 limit\n        final List<Socket> sockets = new ArrayList<>();\n\n        try {\n            for (int i = 0; i < connectionCount; i++) {\n                sockets.add(new Socket(\"localhost\", exposedPort.getPort()));\n            }\n\n            // Give it a moment to process all connections\n            Thread.sleep(2000);\n\n            // Check that all connections are registered\n            final var tunnel = registry.byTunnelId.get(tunnelId);\n            assertEquals(connectionCount, tunnel.getConnections().size(),\n                \"Should have \" + connectionCount + \" active connections\");\n\n        } finally {\n            for (final var socket : sockets) {\n                try {\n                    socket.close();\n                } catch (final IOException ignore) {\n                    log.debug(\"Failed to close socket during test cleanup\", ignore);\n                }\n            }\n            registry.closeTunnel(tunnelId);\n        }\n    }\n}\n"
  },
  {
    "path": "net-proxy/src/test/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelRegistryTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.netproxy.tunnel;\n\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.timeout;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.io.IOException;\nimport java.net.Socket;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.Test;\nimport org.springframework.util.unit.DataSize;\nimport org.springframework.web.socket.TextMessage;\nimport org.springframework.web.socket.WebSocketSession;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport tech.amak.portbuddy.common.TunnelType;\nimport tech.amak.portbuddy.netproxy.config.AppProperties;\n\nclass NetTunnelRegistryTest {\n\n    private final ObjectMapper mapper = new ObjectMapper();\n    private final AppProperties properties = new AppProperties(\n        \"localhost\",\n        new AppProperties.WebSocket(\n            DataSize.ofMegabytes(10),\n            DataSize.ofMegabytes(10),\n            Duration.ofMinutes(10),\n            Duration.ofSeconds(10),\n            DataSize.ofMegabytes(1)\n        ),\n        new AppProperties.Jwt(\"port-buddy\", \"http://localhost:8080\")\n    );\n\n    @Test\n    void testBlockHttpOnTcpTunnel() throws IOException, InterruptedException {\n        final var registry = new NetTunnelRegistry(mapper, properties);\n        final var tunnelId = UUID.randomUUID();\n        final var session = mock(WebSocketSession.class);\n        when(session.getId()).thenReturn(UUID.randomUUID().toString());\n        when(session.isOpen()).thenReturn(true);\n\n        registry.attachSession(tunnelId, session);\n        final var exposedPort = registry.expose(tunnelId, TunnelType.TCP, 10001);\n\n        try (final var clientSocket = new Socket(\"localhost\", exposedPort.getPort())) {\n            clientSocket.setSoTimeout(5000);\n\n            // Wait for registry to send OPEN\n            verify(session, timeout(2000)).sendMessage(any(TextMessage.class));\n\n            // Signal that client is ready (OPEN_OK) to trigger pumpFromPublic\n            registry.onClientOpenOk(tunnelId,\n                registry.byTunnelId.get(tunnelId).getConnections().keySet().iterator().next());\n\n            final var out = clientSocket.getOutputStream();\n            out.write(\"GET / HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n\".getBytes(StandardCharsets.UTF_8));\n            out.flush();\n\n            // Wait a bit to see if anything happens\n            Thread.sleep(1000);\n\n            // If it blocked, the socket should be closed on the server side\n            try {\n                final int read = clientSocket.getInputStream().read();\n                assertTrue(read == -1, \"Socket should be closed by server, but read: \" + read);\n            } catch (final IOException e) {\n                // Connection reset is also a valid indication that the server closed the socket\n                assertTrue(e.getMessage().contains(\"reset\") || e.getMessage().contains(\"closed\"),\n                    \"Expected connection reset or closed, but got: \" + e.getMessage());\n            }\n        } finally {\n            registry.closeTunnel(tunnelId);\n        }\n    }\n\n    @Test\n    void testAllowNonHttpOnTcpTunnel() throws IOException, InterruptedException {\n        final var registry = new NetTunnelRegistry(mapper, properties);\n        final var tunnelId = UUID.randomUUID();\n        final var session = mock(WebSocketSession.class);\n        when(session.getId()).thenReturn(UUID.randomUUID().toString());\n        when(session.isOpen()).thenReturn(true);\n\n        registry.attachSession(tunnelId, session);\n        final var exposedPort = registry.expose(tunnelId, TunnelType.TCP, 10002);\n\n        try (final var clientSocket = new Socket(\"localhost\", exposedPort.getPort())) {\n            final var out = clientSocket.getOutputStream();\n            out.write(new byte[] {0, 1, 2, 3});\n            out.flush();\n\n            // Registry should send OPEN message\n            verify(session, timeout(1000)).sendMessage(any(TextMessage.class));\n        } finally {\n            registry.closeTunnel(tunnelId);\n        }\n    }\n\n    @Test\n    void testAllowPostgresSslRequest() throws IOException {\n        final var registry = new NetTunnelRegistry(mapper, properties);\n        final var tunnelId = UUID.randomUUID();\n        final var session = mock(WebSocketSession.class);\n        when(session.getId()).thenReturn(UUID.randomUUID().toString());\n        when(session.isOpen()).thenReturn(true);\n\n        registry.attachSession(tunnelId, session);\n        final var exposedPort = registry.expose(tunnelId, TunnelType.TCP, 10003);\n\n        try (final var clientSocket = new Socket(\"localhost\", exposedPort.getPort())) {\n            final var out = clientSocket.getOutputStream();\n            // PostgreSQL SSLRequest: Int32(8), Int32(80877103)\n            final byte[] sslRequest = {0, 0, 0, 8, 0x04, (byte) 0xd2, 0x16, 0x2f};\n            out.write(sslRequest);\n            out.flush();\n\n            // Registry should send OPEN message\n            verify(session, timeout(1000)).sendMessage(any(TextMessage.class));\n        } finally {\n            registry.closeTunnel(tunnelId);\n        }\n    }\n\n    @Test\n    void testConnectionWithNoDataSentInitially() throws IOException, InterruptedException {\n        final var registry = new NetTunnelRegistry(mapper, properties);\n        final var tunnelId = UUID.randomUUID();\n        final var session = mock(WebSocketSession.class);\n        when(session.getId()).thenReturn(UUID.randomUUID().toString());\n        when(session.isOpen()).thenReturn(true);\n\n        registry.attachSession(tunnelId, session);\n        final var exposedPort = registry.expose(tunnelId, TunnelType.TCP, 10004);\n\n        try (final var clientSocket = new Socket(\"localhost\", exposedPort.getPort())) {\n            // Wait for registry to send OPEN without sending anything from client\n            verify(session, timeout(2000)).sendMessage(any(TextMessage.class));\n        } finally {\n            registry.closeTunnel(tunnelId);\n        }\n    }\n}\n"
  },
  {
    "path": "net-proxy/src/test/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelUdpEvictionTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\npackage tech.amak.portbuddy.netproxy.tunnel;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertNull;\n\nimport java.net.InetSocketAddress;\nimport java.time.Duration;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.Test;\nimport org.springframework.util.unit.DataSize;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport tech.amak.portbuddy.netproxy.config.AppProperties;\n\nclass NetTunnelUdpEvictionTest {\n\n    private final ObjectMapper mapper = new ObjectMapper();\n    private final AppProperties properties = new AppProperties(\n        \"localhost\",\n        new AppProperties.WebSocket(\n            DataSize.ofMegabytes(10),\n            DataSize.ofMegabytes(10),\n            Duration.ofMinutes(10),\n            Duration.ofSeconds(10),\n            DataSize.ofMegabytes(1)\n        ),\n        new AppProperties.Jwt(\"port-buddy\", \"http://localhost:8080\")\n    );\n\n    @Test\n    void testUdpRemoteLruEviction() {\n        final var registry = new NetTunnelRegistry(mapper, properties);\n        final var tunnelId = UUID.randomUUID();\n        registry.byTunnelId.put(tunnelId, new NetTunnelRegistry.Tunnel(tunnelId));\n        \n        final var tunnel = registry.byTunnelId.get(tunnelId);\n        assertNotNull(tunnel);\n        \n        // Fill the map to its capacity (1000)\n        for (int i = 0; i < 100; i++) {\n            final var id = \"remote-\" + i;\n            tunnel.getUdpRemotes().put(id, new InetSocketAddress(\"127.0.0.1\", 10000 + i));\n        }\n        \n        assertEquals(100, tunnel.getUdpRemotes().size());\n        assertNotNull(tunnel.getUdpRemotes().get(\"remote-0\"));\n        \n        // Add one more entry to trigger eviction\n        tunnel.getUdpRemotes().put(\"remote-100\", new InetSocketAddress(\"127.0.0.1\", 11000));\n        \n        assertEquals(100, tunnel.getUdpRemotes().size());\n        assertNotNull(tunnel.getUdpRemotes().get(\"remote-0\"),\n            \"remote-0 should still be present because it was recently accessed\");\n        assertNull(tunnel.getUdpRemotes().get(\"remote-1\"),\n            \"remote-1 should have been evicted as the eldest entry\");\n        assertNotNull(tunnel.getUdpRemotes().get(\"remote-100\"));\n    }\n}\n"
  },
  {
    "path": "pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n\n    <groupId>tech.amak</groupId>\n    <artifactId>port-buddy</artifactId>\n    <version>1.0-SNAPSHOT</version>\n    <packaging>pom</packaging>\n\n    <name>Port Buddy</name>\n    <description>A tool to share a port opened on the local host to the public network.</description>\n    <url>https://portbuddy.dev</url>\n\n    <licenses>\n        <license>\n            <name>Apache License, Version 2.0</name>\n            <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>\n            <distribution>repo</distribution>\n        </license>\n    </licenses>\n\n    <modules>\n        <module>common</module>\n        <module>cli</module>\n        <module>server</module>\n        <module>net-proxy</module>\n        <module>web</module>\n        <module>gateway</module>\n        <module>eureka</module>\n        <module>ssl-service</module>\n    </modules>\n\n    <properties>\n        <java.version>25</java.version>\n        <maven.compiler.source>25</maven.compiler.source>\n        <maven.compiler.target>25</maven.compiler.target>\n        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>\n        <spring-boot.version>3.5.7</spring-boot.version>\n        <spring-cloud.version>2025.0.0</spring-cloud.version>\n        <picocli.version>4.7.6</picocli.version>\n        <lombok.version>1.18.42</lombok.version>\n        <checkstyle.version>10.17.0</checkstyle.version>\n        <node.version>v24.11.1</node.version>\n        <npm.version>11.6.2</npm.version>\n    </properties>\n\n    <dependencyManagement>\n        <dependencies>\n            <dependency>\n                <groupId>org.springframework.boot</groupId>\n                <artifactId>spring-boot-dependencies</artifactId>\n                <version>${spring-boot.version}</version>\n                <type>pom</type>\n                <scope>import</scope>\n            </dependency>\n            <dependency>\n                <groupId>org.springframework.cloud</groupId>\n                <artifactId>spring-cloud-dependencies</artifactId>\n                <version>${spring-cloud.version}</version>\n                <type>pom</type>\n                <scope>import</scope>\n            </dependency>\n        </dependencies>\n    </dependencyManagement>\n\n    <build>\n        <pluginManagement>\n            <plugins>\n                <plugin>\n                    <groupId>org.apache.maven.plugins</groupId>\n                    <artifactId>maven-compiler-plugin</artifactId>\n                    <version>3.13.0</version>\n                    <configuration>\n                        <release>${java.version}</release>\n                        <annotationProcessorPaths>\n                            <path>\n                                <groupId>org.projectlombok</groupId>\n                                <artifactId>lombok</artifactId>\n                                <version>${lombok.version}</version>\n                            </path>\n                        </annotationProcessorPaths>\n                    </configuration>\n                </plugin>\n                <plugin>\n                    <groupId>org.apache.maven.plugins</groupId>\n                    <artifactId>maven-surefire-plugin</artifactId>\n                    <version>3.5.0</version>\n                </plugin>\n                <plugin>\n                    <groupId>org.apache.maven.plugins</groupId>\n                    <artifactId>maven-failsafe-plugin</artifactId>\n                    <version>3.5.0</version>\n                </plugin>\n                <plugin>\n                    <groupId>org.apache.maven.plugins</groupId>\n                    <artifactId>maven-checkstyle-plugin</artifactId>\n                    <version>3.4.0</version>\n                    <configuration>\n                        <configLocation>https://raw.githubusercontent.com/amak-tech/checkstyle-java/refs/heads/main/checkstyle.xml</configLocation>\n                        <failsOnError>true</failsOnError>\n                        <failOnViolation>true</failOnViolation>\n                        <violationSeverity>warning</violationSeverity>\n                        <consoleOutput>true</consoleOutput>\n                    </configuration>\n                    <executions>\n                        <execution>\n                            <id>validate</id>\n                            <phase>validate</phase>\n                            <goals>\n                                <goal>check</goal>\n                            </goals>\n                        </execution>\n                    </executions>\n                </plugin>\n                <plugin>\n                    <groupId>org.springframework.boot</groupId>\n                    <artifactId>spring-boot-maven-plugin</artifactId>\n                    <version>${spring-boot.version}</version>\n                </plugin>\n            </plugins>\n        </pluginManagement>\n    </build>\n</project>"
  },
  {
    "path": "server/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n    <parent>\n        <groupId>tech.amak</groupId>\n        <artifactId>port-buddy</artifactId>\n        <version>1.0-SNAPSHOT</version>\n    </parent>\n    <artifactId>server</artifactId>\n    <name>port-buddy-server</name>\n\n    <dependencies>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-web</artifactId>\n        </dependency>\n        <!-- Email support -->\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-mail</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-configuration-processor</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-websocket</artifactId>\n        </dependency>\n        <!-- Templates for emails -->\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-thymeleaf</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-actuator</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-data-jpa</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.cloud</groupId>\n            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.cloud</groupId>\n            <artifactId>spring-cloud-starter-openfeign</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.postgresql</groupId>\n            <artifactId>postgresql</artifactId>\n            <version>42.7.4</version>\n        </dependency>\n        <dependency>\n            <groupId>org.flywaydb</groupId>\n            <artifactId>flyway-core</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.flywaydb</groupId>\n            <artifactId>flyway-database-postgresql</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-security</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-oauth2-client</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.projectlombok</groupId>\n            <artifactId>lombok</artifactId>\n            <version>${lombok.version}</version>\n            <scope>provided</scope>\n        </dependency>\n        <dependency>\n            <groupId>tech.amak</groupId>\n            <artifactId>common</artifactId>\n            <version>${project.version}</version>\n        </dependency>\n        <dependency>\n            <groupId>com.stripe</groupId>\n            <artifactId>stripe-java</artifactId>\n            <version>31.1.0</version>\n        </dependency>\n        <!-- ShedLock for cluster-safe scheduling -->\n        <dependency>\n            <groupId>net.javacrumbs.shedlock</groupId>\n            <artifactId>shedlock-spring</artifactId>\n            <version>7.2.0</version>\n        </dependency>\n        <dependency>\n            <groupId>net.javacrumbs.shedlock</groupId>\n            <artifactId>shedlock-provider-jdbc-template</artifactId>\n            <version>7.2.0</version>\n        </dependency>\n\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-test</artifactId>\n            <scope>test</scope>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.security</groupId>\n            <artifactId>spring-security-test</artifactId>\n            <scope>test</scope>\n        </dependency>\n    </dependencies>\n\n    <build>\n        <plugins>\n            <plugin>\n                <groupId>org.springframework.boot</groupId>\n                <artifactId>spring-boot-maven-plugin</artifactId>\n                <executions>\n                    <execution>\n                        <goals>\n                            <goal>repackage</goal>\n                        </goals>\n                    </execution>\n                </executions>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-checkstyle-plugin</artifactId>\n            </plugin>\n        </plugins>\n    </build>\n</project>\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/ServerApplication.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.boot.context.properties.ConfigurationPropertiesScan;\nimport org.springframework.cloud.openfeign.EnableFeignClients;\nimport org.springframework.scheduling.annotation.EnableAsync;\n\n@SpringBootApplication\n@ConfigurationPropertiesScan\n@EnableFeignClients\n@EnableAsync\npublic class ServerApplication {\n\n    public static void main(final String[] args) {\n        SpringApplication.run(ServerApplication.class, args);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/client/NetProxyClient.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.client;\n\nimport static org.springframework.http.HttpHeaders.AUTHORIZATION;\n\nimport java.util.UUID;\n\nimport org.springframework.cloud.openfeign.FeignClient;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.context.request.RequestContextHolder;\nimport org.springframework.web.context.request.ServletRequestAttributes;\n\nimport feign.RequestInterceptor;\nimport tech.amak.portbuddy.common.dto.ExposeResponse;\n\n@FeignClient(\n    name = \"net-proxy\",\n    configuration = NetProxyClient.Configuration.class\n)\npublic interface NetProxyClient {\n\n    @PostMapping(\"/api/net-proxy/expose\")\n    ExposeResponse exposePort(@RequestParam(\"tunnelId\") UUID tunnelId,\n                              @RequestParam(\"type\") String protocol,\n                              @RequestParam(value = \"desiredPort\", required = false) Integer desiredPort);\n\n    @PostMapping(\"/api/net-proxy/close\")\n    void closeTunnel(@RequestParam(\"tunnelId\") UUID tunnelId);\n\n    class Configuration {\n\n        @Bean\n        public RequestInterceptor authorizationHeaderForwarder() {\n            return template -> {\n                final var attributes = RequestContextHolder.getRequestAttributes();\n                if (attributes instanceof ServletRequestAttributes requestAttributes) {\n                    final var auth = requestAttributes.getRequest().getHeader(AUTHORIZATION);\n                    if (auth != null && !auth.isBlank()) {\n                        template.header(AUTHORIZATION, auth);\n                    }\n                }\n            };\n        }\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/client/SslServiceClient.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.client;\n\nimport static org.springframework.http.HttpHeaders.AUTHORIZATION;\n\nimport org.springframework.cloud.openfeign.FeignClient;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.context.request.RequestContextHolder;\nimport org.springframework.web.context.request.ServletRequestAttributes;\n\nimport feign.RequestInterceptor;\n\n@FeignClient(\n    name = \"ssl-service\",\n    configuration = SslServiceClient.Configuration.class\n)\npublic interface SslServiceClient {\n\n    @PostMapping(\"/api/certificates/jobs\")\n    void submitJob(@RequestParam(\"domain\") String domain,\n                   @RequestParam(\"requestedBy\") String requestedBy,\n                   @RequestParam(value = \"managed\", defaultValue = \"false\") boolean managed);\n\n    class Configuration {\n\n        @Bean\n        public RequestInterceptor authorizationHeaderForwarder() {\n            return template -> {\n                final var attributes = RequestContextHolder.getRequestAttributes();\n                if (attributes instanceof ServletRequestAttributes requestAttributes) {\n                    final var auth = requestAttributes.getRequest().getHeader(AUTHORIZATION);\n                    if (auth != null && !auth.isBlank()) {\n                        template.header(AUTHORIZATION, auth);\n                    }\n                }\n            };\n        }\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/config/AppProperties.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.config;\n\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\n\nimport org.springframework.boot.context.properties.ConfigurationProperties;\nimport org.springframework.core.io.Resource;\nimport org.springframework.util.unit.DataSize;\n\nimport tech.amak.portbuddy.common.Plan;\n\n@ConfigurationProperties(prefix = \"app\")\npublic record AppProperties(\n    Gateway gateway,\n    WebSocket webSocket,\n    Jwt jwt,\n    Mail mail,\n    Cli cli,\n    PortReservations portReservations,\n    Subscriptions subscriptions,\n    Stripe stripe\n) {\n    public record Subscriptions(\n        Duration gracePeriod,\n        Duration checkInterval,\n        Tunnels tunnels\n    ) {\n        public record Tunnels(Map<Plan, Integer> base, Map<Plan, Integer> increment) {\n        }\n    }\n\n    public record Gateway(\n        String url,\n        String domain,\n        String subdomainUrlTemplate,\n        String notFoundPage,\n        String passcodePage,\n        DataSize maxRequestBodySize\n    ) {\n        public String subdomainHost() {\n            return \".\" + domain;\n        }\n    }\n\n    public record WebSocket(\n        DataSize maxTextMessageSize,\n        DataSize maxBinaryMessageSize,\n        Duration sessionIdleTimeout\n    ) {\n    }\n\n    public record Jwt(\n        String issuer,\n        Duration ttl,\n        Rsa rsa\n    ) {\n        public record Rsa(\n            String currentKeyId,\n            List<RsaKey> keys\n        ) {\n        }\n\n        public record RsaKey(\n            String id,\n            Resource publicKeyPem,\n            Resource privateKeyPem\n        ) {\n        }\n    }\n\n    public record Mail(\n        String fromAddress,\n        String fromName\n    ) {\n    }\n\n    public record PortReservations(\n        Range range\n    ) {\n        public record Range(\n            int min,\n            int max\n        ) {\n        }\n    }\n\n    public record Cli(\n        String minVersion\n    ) {\n    }\n\n    public record Stripe(\n        String webhookSecret,\n        String apiKey,\n        PriceIds priceIds\n    ) {\n        public record PriceIds(\n            String pro,\n            String team,\n            String extraTunnel\n        ) {\n        }\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/config/SchedulingConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.config;\n\nimport javax.sql.DataSource;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.scheduling.annotation.EnableScheduling;\n\nimport net.javacrumbs.shedlock.core.LockProvider;\nimport net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;\nimport net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;\n\n@Configuration\n@EnableScheduling\n@EnableSchedulerLock(defaultLockAtMostFor = \"PT5M\", defaultLockAtLeastFor = \"PT5S\")\npublic class SchedulingConfig {\n\n    @Bean\n    public LockProvider lockProvider(final DataSource dataSource) {\n        return new JdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder()\n            .withJdbcTemplate(new org.springframework.jdbc.core.JdbcTemplate(dataSource))\n            .usingDbTime()\n            .withTableName(\"shedlock\")\n            .build());\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/config/ThreatFoxProperties.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.config;\n\nimport java.time.Duration;\n\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n@ConditionalOnProperty(name = \"threatfox.enabled\", havingValue = \"true\")\n@ConfigurationProperties(prefix = \"threatfox\")\npublic record ThreatFoxProperties(\n    boolean enabled,\n    String authKey,\n    Duration fetchInterval\n) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/config/TunnelsProperties.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.config;\n\nimport java.time.Duration;\n\nimport org.springframework.boot.context.properties.ConfigurationProperties;\nimport org.springframework.stereotype.Component;\n\nimport lombok.Getter;\nimport lombok.Setter;\n\n/** Configuration for tunnels housekeeping. */\n@Getter\n@Setter\n@Component(\"tunnelsProperties\")\n@ConfigurationProperties(prefix = \"app.tunnels\")\npublic class TunnelsProperties {\n\n    /**\n     * How long a tunnel may stay without heartbeats before it is considered stale and closed.\n     * Defaults to 2 minutes.\n     */\n    private Duration heartbeatTimeout = Duration.ofMinutes(2);\n\n    /**\n     * How often the server checks for stale tunnels.\n     * Defaults to 30 seconds.\n     */\n    private Duration checkInterval = Duration.ofSeconds(30);\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/entity/AccountEntity.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.entity;\n\nimport java.time.OffsetDateTime;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.UUID;\n\nimport org.hibernate.annotations.CreationTimestamp;\nimport org.hibernate.annotations.UpdateTimestamp;\n\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.EnumType;\nimport jakarta.persistence.Enumerated;\nimport jakarta.persistence.FetchType;\nimport jakarta.persistence.Id;\nimport jakarta.persistence.OneToMany;\nimport jakarta.persistence.Table;\nimport lombok.Getter;\nimport lombok.NoArgsConstructor;\nimport lombok.Setter;\nimport tech.amak.portbuddy.common.Plan;\n\n@Getter\n@Setter\n@NoArgsConstructor\n@Entity\n@Table(name = \"accounts\")\npublic class AccountEntity {\n\n    @Id\n    @Column(name = \"id\", nullable = false)\n    private UUID id;\n\n    @Column(name = \"name\", nullable = false)\n    private String name;\n\n    @Enumerated(EnumType.STRING)\n    @Column(name = \"plan\", nullable = false)\n    private Plan plan;\n\n    @Column(name = \"extra_tunnels\", nullable = false)\n    private int extraTunnels = 0;\n\n    @Column(name = \"stripe_customer_id\")\n    private String stripeCustomerId;\n\n    @Column(name = \"stripe_subscription_id\")\n    private String stripeSubscriptionId;\n\n    @Column(name = \"subscription_status\")\n    private String subscriptionStatus;\n\n    @Column(name = \"blocked\", nullable = false)\n    private boolean blocked = false;\n\n    @CreationTimestamp\n    @Column(name = \"created_at\", nullable = false)\n    private OffsetDateTime createdAt;\n\n    @UpdateTimestamp\n    @Column(name = \"updated_at\", nullable = false)\n    private OffsetDateTime updatedAt;\n\n    @OneToMany(mappedBy = \"account\", fetch = FetchType.LAZY)\n    private List<UserAccountEntity> users = new ArrayList<>();\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/entity/ApiKeyEntity.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.entity;\n\nimport java.time.OffsetDateTime;\nimport java.util.UUID;\n\nimport org.hibernate.annotations.CreationTimestamp;\n\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.Id;\nimport jakarta.persistence.Table;\nimport lombok.Getter;\nimport lombok.NoArgsConstructor;\nimport lombok.Setter;\n\n@Getter\n@Setter\n@NoArgsConstructor\n@Entity\n@Table(name = \"api_keys\")\npublic class ApiKeyEntity {\n\n    @Id\n    @Column(name = \"id\", nullable = false)\n    private UUID id;\n\n    @Column(name = \"user_id\", nullable = false)\n    private UUID userId;\n\n    @Column(name = \"account_id\", nullable = false)\n    private UUID accountId;\n\n    @Column(name = \"label\", nullable = false)\n    private String label;\n\n    @Column(name = \"token_hash\", nullable = false, unique = true)\n    private String tokenHash;\n\n    @CreationTimestamp\n    @Column(name = \"created_at\", nullable = false)\n    private OffsetDateTime createdAt;\n\n    @Column(name = \"last_used_at\")\n    private OffsetDateTime lastUsedAt;\n\n    @Column(name = \"revoked\", nullable = false)\n    private boolean revoked;\n\n    @Column(name = \"revoked_at\")\n    private OffsetDateTime revokedAt;\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/entity/DomainEntity.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.entity;\n\nimport java.time.OffsetDateTime;\nimport java.util.UUID;\n\nimport org.hibernate.annotations.CreationTimestamp;\nimport org.hibernate.annotations.SQLDelete;\nimport org.hibernate.annotations.SQLRestriction;\nimport org.hibernate.annotations.UpdateTimestamp;\n\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.FetchType;\nimport jakarta.persistence.Id;\nimport jakarta.persistence.JoinColumn;\nimport jakarta.persistence.ManyToOne;\nimport jakarta.persistence.PrePersist;\nimport jakarta.persistence.PreUpdate;\nimport jakarta.persistence.Table;\nimport lombok.Getter;\nimport lombok.NoArgsConstructor;\nimport lombok.Setter;\n\n@Getter\n@Setter\n@NoArgsConstructor\n@Entity\n@Table(name = \"domains\")\n@SQLDelete(sql = \"UPDATE domains SET deleted = true WHERE id = ?\")\n@SQLRestriction(\"deleted = false\")\npublic class DomainEntity {\n\n    @Id\n    @Column(name = \"id\", nullable = false)\n    private UUID id;\n\n    @Column(name = \"subdomain\", nullable = false, unique = true)\n    private String subdomain;\n\n    @Column(name = \"domain\", nullable = false)\n    private String domain;\n\n    @Column(name = \"custom_domain\")\n    private String customDomain;\n\n    @Column(name = \"cname_verified\", nullable = false)\n    private boolean cnameVerified = false;\n\n    @Column(name = \"ssl_active\", nullable = false)\n    private boolean sslActive = false;\n\n    @Column(name = \"passcode_hash\")\n    private String passcodeHash;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"account_id\", nullable = false)\n    private AccountEntity account;\n\n    @Column(name = \"deleted\", nullable = false)\n    private boolean deleted = false;\n\n    @CreationTimestamp\n    @Column(name = \"created_at\", nullable = false)\n    private OffsetDateTime createdAt;\n\n    @UpdateTimestamp\n    @Column(name = \"updated_at\", nullable = false)\n    private OffsetDateTime updatedAt;\n\n    @PrePersist\n    @PreUpdate\n    private void toLowerCase() {\n        if (subdomain != null) {\n            subdomain = subdomain.toLowerCase();\n        }\n        if (domain != null) {\n            domain = domain.toLowerCase();\n        }\n        if (customDomain != null) {\n            customDomain = customDomain.toLowerCase();\n        }\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/entity/InvitationEntity.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.entity;\n\nimport java.time.OffsetDateTime;\nimport java.util.UUID;\n\nimport org.hibernate.annotations.CreationTimestamp;\n\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.FetchType;\nimport jakarta.persistence.Id;\nimport jakarta.persistence.JoinColumn;\nimport jakarta.persistence.ManyToOne;\nimport jakarta.persistence.Table;\nimport lombok.Getter;\nimport lombok.NoArgsConstructor;\nimport lombok.Setter;\n\n@Getter\n@Setter\n@NoArgsConstructor\n@Entity\n@Table(name = \"invitations\")\npublic class InvitationEntity {\n\n    @Id\n    @Column(name = \"id\", nullable = false)\n    private UUID id;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"account_id\", nullable = false)\n    private AccountEntity account;\n\n    @Column(name = \"email\", nullable = false)\n    private String email;\n\n    @Column(name = \"token\", nullable = false, unique = true)\n    private String token;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"invited_by_id\", nullable = false)\n    private UserEntity invitedBy;\n\n    @Column(name = \"accepted_at\")\n    private OffsetDateTime acceptedAt;\n\n    @Column(name = \"expires_at\", nullable = false)\n    private OffsetDateTime expiresAt;\n\n    @CreationTimestamp\n    @Column(name = \"created_at\", nullable = false)\n    private OffsetDateTime createdAt;\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/entity/PasswordResetTokenEntity.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.entity;\n\nimport java.time.OffsetDateTime;\nimport java.util.UUID;\n\nimport org.hibernate.annotations.CreationTimestamp;\n\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.FetchType;\nimport jakarta.persistence.Id;\nimport jakarta.persistence.JoinColumn;\nimport jakarta.persistence.ManyToOne;\nimport jakarta.persistence.Table;\nimport lombok.Getter;\nimport lombok.NoArgsConstructor;\nimport lombok.Setter;\n\n@Getter\n@Setter\n@NoArgsConstructor\n@Entity\n@Table(name = \"password_reset_tokens\")\npublic class PasswordResetTokenEntity {\n\n    @Id\n    @Column(name = \"id\", nullable = false)\n    private UUID id;\n\n    @Column(name = \"token\", nullable = false, unique = true)\n    private String token;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"user_id\", nullable = false)\n    private UserEntity user;\n\n    @Column(name = \"expiry_date\", nullable = false)\n    private OffsetDateTime expiryDate;\n\n    @CreationTimestamp\n    @Column(name = \"created_at\", nullable = false)\n    private OffsetDateTime createdAt;\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/entity/PortReservationEntity.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.entity;\n\nimport java.time.OffsetDateTime;\nimport java.util.UUID;\n\nimport org.hibernate.annotations.CreationTimestamp;\nimport org.hibernate.annotations.SQLDelete;\nimport org.hibernate.annotations.UpdateTimestamp;\nimport org.hibernate.annotations.Where;\n\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.FetchType;\nimport jakarta.persistence.Id;\nimport jakarta.persistence.JoinColumn;\nimport jakarta.persistence.ManyToOne;\nimport jakarta.persistence.Table;\nimport lombok.Getter;\nimport lombok.NoArgsConstructor;\nimport lombok.Setter;\n\n@Getter\n@Setter\n@NoArgsConstructor\n@Entity\n@Table(name = \"port_reservations\")\n@SQLDelete(sql = \"UPDATE port_reservations SET deleted = true, updated_at = NOW() WHERE id = ?\")\n@Where(clause = \"deleted = false\")\npublic class PortReservationEntity {\n\n    @Id\n    @Column(name = \"id\", nullable = false)\n    private UUID id;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"account_id\", nullable = false)\n    private AccountEntity account;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"user_id\")\n    private UserEntity user;\n\n    @Column(name = \"public_host\", nullable = false)\n    private String publicHost;\n\n    @Column(name = \"public_port\", nullable = false)\n    private Integer publicPort;\n\n    @Column(name = \"name\")\n    private String name;\n\n    @CreationTimestamp\n    @Column(name = \"created_at\", nullable = false)\n    private OffsetDateTime createdAt;\n\n    @UpdateTimestamp\n    @Column(name = \"updated_at\", nullable = false)\n    private OffsetDateTime updatedAt;\n\n    @Column(name = \"deleted\", nullable = false)\n    private boolean deleted = false;\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/entity/Role.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.entity;\n\n/**\n * Represents the roles that a user can have in the system.\n */\npublic enum Role {\n    ADMIN,\n    ACCOUNT_ADMIN,\n    USER\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/entity/StripeEventEntity.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.entity;\n\nimport java.time.OffsetDateTime;\n\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.Id;\nimport jakarta.persistence.Table;\nimport lombok.Getter;\nimport lombok.NoArgsConstructor;\nimport lombok.Setter;\n\n@Getter\n@Setter\n@NoArgsConstructor\n@Entity\n@Table(name = \"stripe_events\")\npublic class StripeEventEntity {\n\n    @Id\n    @Column(name = \"id\", nullable = false)\n    private String id;\n\n    @Column(name = \"type\", nullable = false)\n    private String type;\n\n    @Column(name = \"payload\", nullable = false, columnDefinition = \"TEXT\")\n    private String payload;\n\n    @Column(name = \"status\", nullable = false)\n    private String status;\n\n    @Column(name = \"error_message\", columnDefinition = \"TEXT\")\n    private String errorMessage;\n\n    @Column(name = \"created_at\", nullable = false, updatable = false)\n    private OffsetDateTime createdAt;\n\n    @Column(name = \"processed_at\")\n    private OffsetDateTime processedAt;\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/entity/TunnelEntity.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.entity;\n\nimport java.time.OffsetDateTime;\nimport java.util.UUID;\n\nimport org.hibernate.annotations.CreationTimestamp;\nimport org.hibernate.annotations.UpdateTimestamp;\n\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.EnumType;\nimport jakarta.persistence.Enumerated;\nimport jakarta.persistence.FetchType;\nimport jakarta.persistence.Id;\nimport jakarta.persistence.JoinColumn;\nimport jakarta.persistence.ManyToOne;\nimport jakarta.persistence.Table;\nimport lombok.Getter;\nimport lombok.NoArgsConstructor;\nimport lombok.Setter;\nimport tech.amak.portbuddy.common.TunnelType;\n\n@Getter\n@Setter\n@NoArgsConstructor\n@Entity\n@Table(name = \"tunnels\")\npublic class TunnelEntity {\n\n    @Id\n    @Column(name = \"id\", nullable = false)\n    private UUID id;\n\n    @Enumerated(EnumType.STRING)\n    @Column(name = \"type\", nullable = false)\n    private TunnelType type;\n\n    @Enumerated(EnumType.STRING)\n    @Column(name = \"status\", nullable = false)\n    private TunnelStatus status;\n\n    // Ownership\n    @Column(name = \"account_id\", nullable = false)\n    private UUID accountId;\n\n    @Column(name = \"user_id\")\n    private UUID userId;\n\n    @Column(name = \"api_key_id\")\n    private UUID apiKeyId;\n\n    // Local resource details\n    @Column(name = \"local_scheme\")\n    private String localScheme;\n\n    @Column(name = \"local_host\")\n    private String localHost;\n\n    @Column(name = \"local_port\")\n    private Integer localPort;\n\n    // Public exposure details\n    @Column(name = \"public_url\")\n    private String publicUrl;\n\n    @Column(name = \"public_host\")\n    private String publicHost;\n\n    @Column(name = \"public_port\")\n    private Integer publicPort;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"domain_id\")\n    private DomainEntity domain;\n\n    // Optional temporary passcode hash set for this tunnel via CLI\n    @Column(name = \"temp_passcode_hash\")\n    private String tempPasscodeHash;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"port_reservation_id\")\n    private PortReservationEntity portReservation;\n\n    @Column(name = \"last_heartbeat_at\")\n    private OffsetDateTime lastHeartbeatAt;\n\n    @CreationTimestamp\n    @Column(name = \"created_at\", nullable = false)\n    private OffsetDateTime createdAt;\n\n    @UpdateTimestamp\n    @Column(name = \"updated_at\", nullable = false)\n    private OffsetDateTime updatedAt;\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/entity/TunnelStatus.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.entity;\n\n/** Tunnel lifecycle status. */\npublic enum TunnelStatus {\n    PENDING,\n    CONNECTED,\n    CLOSED\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/entity/UserAccountEntity.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.entity;\n\nimport java.io.Serializable;\nimport java.time.OffsetDateTime;\nimport java.util.Set;\nimport java.util.UUID;\n\nimport org.hibernate.annotations.CreationTimestamp;\nimport org.hibernate.annotations.JdbcTypeCode;\nimport org.hibernate.annotations.UpdateTimestamp;\nimport org.hibernate.type.SqlTypes;\n\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Embeddable;\nimport jakarta.persistence.EmbeddedId;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.EnumType;\nimport jakarta.persistence.Enumerated;\nimport jakarta.persistence.FetchType;\nimport jakarta.persistence.JoinColumn;\nimport jakarta.persistence.ManyToOne;\nimport jakarta.persistence.MapsId;\nimport jakarta.persistence.Table;\nimport lombok.AllArgsConstructor;\nimport lombok.EqualsAndHashCode;\nimport lombok.Getter;\nimport lombok.NoArgsConstructor;\nimport lombok.Setter;\n\n@Getter\n@Setter\n@NoArgsConstructor\n@AllArgsConstructor\n@Entity\n@Table(name = \"user_accounts\")\npublic class UserAccountEntity {\n\n    @EmbeddedId\n    private UserAccountId id;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @MapsId(\"userId\")\n    @JoinColumn(name = \"user_id\")\n    private UserEntity user;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @MapsId(\"accountId\")\n    @JoinColumn(name = \"account_id\")\n    private AccountEntity account;\n\n    @Enumerated(EnumType.STRING)\n    @JdbcTypeCode(SqlTypes.ARRAY)\n    @Column(name = \"roles\", nullable = false)\n    private Set<Role> roles;\n\n    @Column(name = \"last_used_at\", nullable = false)\n    private OffsetDateTime lastUsedAt;\n\n    @CreationTimestamp\n    @Column(name = \"created_at\", nullable = false)\n    private OffsetDateTime createdAt;\n\n    @UpdateTimestamp\n    @Column(name = \"updated_at\", nullable = false)\n    private OffsetDateTime updatedAt;\n\n    /**\n     * Constructs a new UserAccountEntity linking a user to an account with specific roles.\n     *\n     * @param user    the user.\n     * @param account the account.\n     * @param roles   the roles of the user within the account.\n     */\n    public UserAccountEntity(final UserEntity user, final AccountEntity account, final Set<Role> roles) {\n        this.user = user;\n        this.account = account;\n        this.roles = roles;\n        this.id = new UserAccountId(user.getId(), account.getId());\n        this.lastUsedAt = OffsetDateTime.now();\n    }\n\n    /**\n     * Gets the user's email by delegating to the associated UserEntity.\n     *\n     * @return the user's email.\n     */\n    public String getEmail() {\n        return user.getEmail();\n    }\n\n    /**\n     * Gets the user's first name by delegating to the associated UserEntity.\n     *\n     * @return the user's first name.\n     */\n    public String getFirstName() {\n        return user.getFirstName();\n    }\n\n    /**\n     * Gets the user's last name by delegating to the associated UserEntity.\n     *\n     * @return the user's last name.\n     */\n    public String getLastName() {\n        return user.getLastName();\n    }\n\n    @Getter\n    @Setter\n    @NoArgsConstructor\n    @AllArgsConstructor\n    @EqualsAndHashCode\n    @Embeddable\n    public static class UserAccountId implements Serializable {\n        private UUID userId;\n        private UUID accountId;\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/entity/UserEntity.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.entity;\n\nimport java.time.OffsetDateTime;\nimport java.util.Set;\nimport java.util.UUID;\n\nimport org.hibernate.annotations.CreationTimestamp;\nimport org.hibernate.annotations.UpdateTimestamp;\n\nimport jakarta.persistence.CascadeType;\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.Id;\nimport jakarta.persistence.OneToMany;\nimport jakarta.persistence.Table;\nimport lombok.Getter;\nimport lombok.NoArgsConstructor;\nimport lombok.Setter;\n\n@Getter\n@Setter\n@NoArgsConstructor\n@Entity\n@Table(name = \"users\")\npublic class UserEntity {\n\n    @Id\n    @Column(name = \"id\", nullable = false)\n    private UUID id;\n\n    @OneToMany(mappedBy = \"user\", cascade = CascadeType.ALL, orphanRemoval = true)\n    private Set<UserAccountEntity> accounts;\n\n    @Column(name = \"email\", nullable = false, length = 320)\n    private String email;\n\n    @Column(name = \"first_name\")\n    private String firstName;\n\n    @Column(name = \"last_name\")\n    private String lastName;\n\n    @Column(name = \"auth_provider\", nullable = false)\n    private String authProvider;\n\n    @Column(name = \"external_id\", nullable = false)\n    private String externalId;\n\n    @Column(name = \"avatar_url\")\n    private String avatarUrl;\n\n    @Column(name = \"password\")\n    private String password;\n\n\n    @CreationTimestamp\n    @Column(name = \"created_at\", nullable = false)\n    private OffsetDateTime createdAt;\n\n    @UpdateTimestamp\n    @Column(name = \"updated_at\", nullable = false)\n    private OffsetDateTime updatedAt;\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/repo/AccountRepository.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.repo;\n\nimport java.time.OffsetDateTime;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.data.repository.query.Param;\n\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.web.admin.dto.AdminAccountRow;\nimport tech.amak.portbuddy.server.web.admin.dto.AdminStatsRow;\n\npublic interface AccountRepository extends JpaRepository<AccountEntity, UUID> {\n    Optional<AccountEntity> findByStripeCustomerId(String stripeCustomerId);\n\n    @Query(\"SELECT a FROM AccountEntity a WHERE a.subscriptionStatus <> 'active' AND a.updatedAt < :cutoff\")\n    List<AccountEntity> findBySubscriptionStatusNotActiveAndUpdatedAtBefore(@Param(\"cutoff\") OffsetDateTime cutoff);\n\n    @Query(value = \"\"\"\n        SELECT a.id AS account_id,\n               a.name AS name,\n               a.plan AS plan,\n               a.extra_tunnels AS extra_tunnels,\n               COALESCE(SUM(CASE WHEN t.status = 'CONNECTED' THEN 1 ELSE 0 END), 0) AS active_tunnels,\n               a.blocked AS blocked,\n               a.created_at AS created_at\n        FROM accounts a\n        LEFT JOIN tunnels t ON t.account_id = a.id\n        WHERE (:search IS NULL \n                           OR a.name ILIKE CONCAT('%', :search, '%') \n                           OR CAST(a.id AS TEXT) ILIKE CONCAT('%', :search, '%'))\n        GROUP BY a.id, a.name, a.plan, a.extra_tunnels, a.blocked, a.created_at\n        ORDER BY active_tunnels DESC, a.created_at DESC\n        \"\"\", nativeQuery = true)\n    List<AdminAccountRow> findAdminAccounts(@Param(\"search\") String search);\n\n    @Query(value = \"\"\"\n        WITH RECURSIVE days AS (\n            SELECT CURRENT_DATE - INTERVAL '29 days' as day\n            UNION ALL\n            SELECT day + INTERVAL '1 day'\n            FROM days\n            WHERE day < CURRENT_DATE\n        )\n        SELECT\n            d.day::date as \"date\",\n            (SELECT count(*) FROM users u WHERE u.created_at::date = d.day) as new_users_count,\n            (SELECT count(*) FROM tunnels t WHERE t.created_at::date = d.day) as tunnels_count,\n            (SELECT count(*) FROM stripe_events s WHERE s.created_at::date = d.day) as payment_events\n        FROM days d\n        ORDER BY d.day DESC\n        \"\"\", nativeQuery = true)\n    List<AdminStatsRow> findDailyStats();\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/repo/ApiKeyRepository.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.repo;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.springframework.data.jpa.repository.JpaRepository;\n\nimport tech.amak.portbuddy.server.db.entity.ApiKeyEntity;\n\npublic interface ApiKeyRepository extends JpaRepository<ApiKeyEntity, UUID> {\n\n    List<ApiKeyEntity> findAllByUserId(UUID userId);\n\n    List<ApiKeyEntity> findAllByAccountId(UUID accountId);\n\n    Optional<ApiKeyEntity> findByIdAndUserId(UUID id, UUID userId);\n\n    Optional<ApiKeyEntity> findByIdAndAccountId(UUID id, UUID accountId);\n\n    Optional<ApiKeyEntity> findByTokenHashAndRevokedFalse(String tokenHash);\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/repo/DomainRepository.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.repo;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.data.repository.query.Param;\nimport org.springframework.stereotype.Repository;\n\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.DomainEntity;\n\n@Repository\npublic interface DomainRepository extends JpaRepository<DomainEntity, UUID> {\n    boolean existsBySubdomain(String subdomain);\n\n    @Query(value = \"SELECT count(*) > 0 FROM domains WHERE subdomain = LOWER(:subdomain)\", nativeQuery = true)\n    boolean existsBySubdomainGlobal(@Param(\"subdomain\") String subdomain);\n\n    List<DomainEntity> findAllByAccount(AccountEntity account);\n\n    Optional<DomainEntity> findByAccountAndSubdomain(AccountEntity account, String subdomain);\n\n    Optional<DomainEntity> findByIdAndAccount(UUID id, AccountEntity account);\n\n    Optional<DomainEntity> findBySubdomain(String subdomain);\n\n    Optional<DomainEntity> findByCustomDomain(String customDomain);\n\n    long countByAccount(AccountEntity account);\n\n    long countByAccountAndCustomDomainIsNotNull(AccountEntity account);\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/repo/InvitationRepository.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.repo;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.springframework.data.jpa.repository.JpaRepository;\n\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.InvitationEntity;\n\npublic interface InvitationRepository extends JpaRepository<InvitationEntity, UUID> {\n\n    Optional<InvitationEntity> findByToken(String token);\n\n    List<InvitationEntity> findAllByAccountAndAcceptedAtIsNull(AccountEntity account);\n\n    Optional<InvitationEntity> findByAccountAndEmailAndAcceptedAtIsNull(AccountEntity account, String email);\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/repo/PasswordResetTokenRepository.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.repo;\n\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.springframework.data.jpa.repository.JpaRepository;\n\nimport tech.amak.portbuddy.server.db.entity.PasswordResetTokenEntity;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\n\npublic interface PasswordResetTokenRepository extends JpaRepository<PasswordResetTokenEntity, UUID> {\n    Optional<PasswordResetTokenEntity> findByToken(String token);\n\n    void deleteByUser(UserEntity user);\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/repo/PortReservationRepository.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.repo;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.data.repository.query.Param;\n\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.PortReservationEntity;\n\npublic interface PortReservationRepository extends JpaRepository<PortReservationEntity, UUID> {\n\n    List<PortReservationEntity> findAllByAccount(AccountEntity account);\n\n    Optional<PortReservationEntity> findByIdAndAccount(UUID id, AccountEntity account);\n\n    boolean existsByPublicHostAndPublicPort(String publicHost, Integer publicPort);\n\n    long countByPublicHost(String publicHost);\n\n    @Query(\"select max(pr.publicPort) from PortReservationEntity pr where pr.publicHost = :host\")\n    Optional<Integer> findMaxPortByHost(@Param(\"host\") String publicHost);\n\n    Optional<PortReservationEntity> findByAccountAndPublicHostAndPublicPort(AccountEntity account,\n                                                                            String host,\n                                                                            Integer port);\n\n    List<PortReservationEntity> findAllByAccountAndPublicPort(AccountEntity account, Integer port);\n\n    boolean existsByAccountAndName(AccountEntity account, String name);\n\n    Optional<PortReservationEntity> findByAccountAndNameIgnoreCase(AccountEntity account, String name);\n\n    /**\n     * Finds the minimal free public port for the specified host within the provided inclusive range\n     * using a single SQL query. Only non-deleted reservations are considered busy.\n     */\n    @Query(value = \"\"\"\n        select gs.port\n        from generate_series(:min, :max) as gs(port)\n        left join port_reservations pr\n               on pr.public_host = :host\n              and pr.public_port = gs.port\n              and pr.deleted = false\n        where pr.public_port is null\n        order by gs.port\n        limit 1\n        \"\"\", nativeQuery = true)\n    Optional<Integer> findMinimalFreePort(@Param(\"host\") String host,\n                                          @Param(\"min\") int min,\n                                          @Param(\"max\") int max);\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/repo/StripeEventRepository.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.repo;\n\nimport org.springframework.data.jpa.repository.JpaRepository;\n\nimport tech.amak.portbuddy.server.db.entity.StripeEventEntity;\n\npublic interface StripeEventRepository extends JpaRepository<StripeEventEntity, String> {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/repo/TunnelRepository.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.repo;\n\nimport java.time.OffsetDateTime;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Modifying;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.data.repository.query.Param;\nimport org.springframework.stereotype.Repository;\n\nimport tech.amak.portbuddy.server.db.entity.DomainEntity;\nimport tech.amak.portbuddy.server.db.entity.PortReservationEntity;\nimport tech.amak.portbuddy.server.db.entity.TunnelEntity;\nimport tech.amak.portbuddy.server.db.entity.TunnelStatus;\nimport tech.amak.portbuddy.server.web.admin.dto.AdminTunnelRow;\n\n@Repository\npublic interface TunnelRepository extends JpaRepository<TunnelEntity, UUID> {\n\n    boolean existsByDomainAndStatus(DomainEntity domain, TunnelStatus status);\n\n    boolean existsByDomainAndStatusNot(DomainEntity domain, TunnelStatus status);\n\n    Optional<TunnelEntity> findFirstByAccountIdAndLocalHostAndLocalPortAndDomainIsNotNullOrderByCreatedAtDesc(\n        UUID accountId, String localHost, Integer localPort);\n\n    default Optional<TunnelEntity> findUsedTunnel(final UUID accountId,\n                                                  final String localHost,\n                                                  final Integer localPort) {\n        return findFirstByAccountIdAndLocalHostAndLocalPortAndDomainIsNotNullOrderByCreatedAtDesc(\n            accountId, localHost, localPort);\n    }\n\n    Page<TunnelEntity> findAllByAccountId(UUID accountId, Pageable pageable);\n\n    boolean existsByPortReservationAndStatusNot(PortReservationEntity portReservation, TunnelStatus status);\n\n    Optional<TunnelEntity> findFirstByAccountIdAndLocalHostAndLocalPortAndPortReservationIsNotNullOrderByCreatedAtDesc(\n        UUID accountId, String localHost, Integer localPort);\n\n    @Query(value = \"\"\"\n        SELECT t\n        FROM TunnelEntity t\n        LEFT JOIN FETCH t.portReservation\n        LEFT JOIN FETCH t.domain\n        WHERE t.accountId = :accountId\n        ORDER BY t.lastHeartbeatAt DESC NULLS LAST, t.createdAt DESC\"\"\")\n    Page<TunnelEntity> pageByAccountOrderByLastHeartbeatDescNullsLast(\n        @Param(\"accountId\") UUID accountId, Pageable pageable);\n\n    long countByAccountIdAndStatusIn(UUID accountId, List<TunnelStatus> statuses);\n\n    List<TunnelEntity> findByAccountIdAndStatusInOrderByLastHeartbeatAtAscCreatedAtAsc(\n        UUID accountId, List<TunnelStatus> statuses);\n\n    long countByStatusIn(List<TunnelStatus> statuses);\n\n    /**\n     * Closes tunnels that are in CONNECTED status but have stale or missing heartbeat.\n     * Uses native SQL to also update the updated_at timestamp.\n     *\n     * @param cutoff heartbeats older than this timestamp are considered stale\n     * @return number of rows updated\n     */\n    @Modifying(clearAutomatically = true, flushAutomatically = true)\n    @Query(value = \"\"\"\n        UPDATE tunnels SET status = 'CLOSED', updated_at = NOW()\n        WHERE created_at < :cutoff\n                  AND status <> 'CLOSED'\n                  AND (last_heartbeat_at IS NULL OR last_heartbeat_at < :cutoff)\n        RETURNING id\"\"\",\n        nativeQuery = true)\n    List<UUID> closeStaleConnected(@Param(\"cutoff\") final OffsetDateTime cutoff);\n\n    @Query(value = \"\"\"\n        SELECT t.id AS id,\n               t.type AS type,\n               CONCAT(t.local_host, ':', t.local_port) AS local_address,\n               COALESCE(t.public_url, CONCAT(t.public_host, ':', t.public_port)) AS public_address,\n               t.last_heartbeat_at AS last_activity,\n               CONCAT(u.first_name, ' ', u.last_name) AS user_name,\n               t.user_id AS user_id,\n               t.account_id AS account_id\n        FROM tunnels t\n        LEFT JOIN users u ON u.id = t.user_id\n        WHERE t.status = 'CONNECTED'\n          AND (:search IS NULL\n               OR t.public_url ILIKE CONCAT('%', :search, '%') \n               OR t.public_host ILIKE CONCAT('%', :search, '%')\n               OR u.email ILIKE CONCAT('%', :search, '%')\n               OR u.first_name ILIKE CONCAT('%', :search, '%')\n               OR u.last_name ILIKE CONCAT('%', :search, '%'))\n        ORDER BY t.last_heartbeat_at DESC NULLS LAST, t.created_at DESC\n        \"\"\", nativeQuery = true)\n    List<AdminTunnelRow> findAdminActiveTunnels(@Param(\"search\") String search);\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/repo/UserAccountRepository.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.repo;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.data.repository.query.Param;\n\nimport tech.amak.portbuddy.server.db.entity.UserAccountEntity;\n\npublic interface UserAccountRepository extends JpaRepository<UserAccountEntity, UserAccountEntity.UserAccountId> {\n\n    List<UserAccountEntity> findAllByUserId(UUID userId);\n\n    @Query(\"\"\"\n        SELECT ua\n        FROM UserAccountEntity ua\n            JOIN FETCH ua.account\n        WHERE ua.user.id = :userId\n        ORDER BY ua.lastUsedAt DESC\n        LIMIT 1\"\"\")\n    Optional<UserAccountEntity> findLatestUsedByUserId(@Param(\"userId\") UUID userId);\n\n    @Query(\"\"\"\n        SELECT ua\n        FROM UserAccountEntity ua\n            JOIN FETCH ua.account\n        WHERE ua.user.id = :userId AND ua.account.id = :accountId\"\"\")\n    Optional<UserAccountEntity> findByUserIdAndAccountId(@Param(\"userId\") UUID userId,\n                                                         @Param(\"accountId\") UUID accountId);\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/db/repo/UserRepository.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.db.repo;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.springframework.data.jpa.repository.EntityGraph;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Query;\nimport org.springframework.data.repository.query.Param;\n\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.web.admin.dto.AdminUserRow;\n\npublic interface UserRepository extends JpaRepository<UserEntity, UUID> {\n\n    @EntityGraph(attributePaths = \"accounts\")\n    @Override\n    Optional<UserEntity> findById(UUID id);\n\n    Optional<UserEntity> findByAuthProviderAndExternalId(String authProvider, String externalId);\n\n    Optional<UserEntity> findByEmailIgnoreCase(String email);\n\n    @Query(\"SELECT ua.user FROM UserAccountEntity ua WHERE ua.account = :account\")\n    List<UserEntity> findAllByAccount(@Param(\"account\") AccountEntity account);\n\n    @Query(value = \"\"\"\n        SELECT u.id AS id,\n               a.id AS account_id,\n               CONCAT(u.first_name, ' ', u.last_name) AS name,\n               u.email AS email,\n               COALESCE(SUM(CASE WHEN t.status = 'CONNECTED' THEN 1 ELSE 0 END), 0) AS active_tunnels,\n               a.blocked AS blocked,\n               u.created_at AS created_at\n        FROM users u\n        INNER JOIN user_accounts ua ON ua.user_id = u.id\n        INNER JOIN accounts a ON a.id = ua.account_id\n        LEFT JOIN tunnels t ON t.account_id = a.id\n        WHERE (:search IS NULL\n           OR u.email ILIKE CONCAT('%', :search, '%')\n           OR u.first_name ILIKE CONCAT('%', :search, '%')\n           OR u.last_name ILIKE CONCAT('%', :search, '%')\n           OR CAST(u.id AS TEXT) ILIKE CONCAT('%', :search, '%'))\n        GROUP BY u.id, a.id, u.first_name, u.last_name, u.email, a.blocked, u.created_at\n        ORDER BY active_tunnels DESC, u.created_at DESC\n        \"\"\", nativeQuery = true)\n    List<AdminUserRow> findAdminUsers(@Param(\"search\") String search);\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/mail/EmailService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.mail;\n\nimport java.nio.charset.StandardCharsets;\nimport java.util.Map;\n\nimport org.springframework.lang.Nullable;\nimport org.springframework.mail.javamail.JavaMailSender;\nimport org.springframework.mail.javamail.MimeMessageHelper;\nimport org.springframework.scheduling.annotation.Async;\nimport org.springframework.stereotype.Service;\nimport org.thymeleaf.context.Context;\nimport org.thymeleaf.spring6.SpringTemplateEngine;\n\nimport jakarta.mail.MessagingException;\nimport jakarta.mail.internet.InternetAddress;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.server.config.AppProperties;\n\n/**\n * Service for sending HTML email based on Thymeleaf templates.\n */\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class EmailService {\n\n    private final JavaMailSender mailSender;\n    private final SpringTemplateEngine templateEngine;\n    private final AppProperties properties;\n\n    /**\n     * Sends an HTML email rendered from a Thymeleaf template.\n     *\n     * @param to       recipient email\n     * @param subject  email subject\n     * @param template template name under templates directory (e.g. \"email/welcome\")\n     * @param model    model variables for the template\n     */\n    @Async\n    public void sendTemplate(final String to,\n                             final String subject,\n                             final String template,\n                             final @Nullable Map<String, Object> model) {\n        try {\n            final var message = mailSender.createMimeMessage();\n            final var helper = new MimeMessageHelper(message, MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED,\n                StandardCharsets.UTF_8.name());\n\n            final var fromName = properties.mail().fromName();\n            final var fromAddress = properties.mail().fromAddress();\n            helper.setFrom(new InternetAddress(fromAddress, fromName));\n            helper.setTo(to);\n            helper.setSubject(subject);\n\n            final var context = new Context();\n            if (model != null) {\n                model.forEach(context::setVariable);\n            }\n            final var html = templateEngine.process(template, context);\n            helper.setText(html, true);\n\n            mailSender.send(message);\n        } catch (final MessagingException e) {\n            log.warn(\"Email send failed (MessagingException): {}\", e.getMessage());\n        } catch (final Exception e) {\n            log.warn(\"Email send failed: {}\", e.getMessage());\n        }\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/mail/UserCreatedEvent.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.mail;\n\nimport java.util.UUID;\n\n/**\n * Application domain event published when a new user is created in the system.\n */\npublic record UserCreatedEvent(UUID userId,\n                               UUID accountId,\n                               String email,\n                               String firstName,\n                               String lastName,\n                               String passwordResetLink) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/mail/WelcomeEmailService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.mail;\n\nimport java.util.HashMap;\n\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.event.TransactionPhase;\nimport org.springframework.transaction.event.TransactionalEventListener;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.server.config.AppProperties;\n\n/**\n * Sends a welcome email to newly registered users.\n */\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class WelcomeEmailService {\n\n    private final EmailService emailService;\n    private final AppProperties properties;\n\n    /**\n     * Handles user created events and sends a welcome email after the transaction is committed.\n     *\n     * @param event domain event carrying user details\n     */\n    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)\n    public void onUserCreated(final UserCreatedEvent event) {\n        try {\n            final var webAppUrl = properties.gateway().url();\n            final var fullName = buildFullName(event.firstName(), event.lastName());\n\n            final var model = new HashMap<String, Object>();\n            model.put(\"subject\", \"Welcome to Port Buddy\");\n            model.put(\"greeting\", fullName == null ? \"Welcome to Port Buddy!\" : \"Welcome, \" + fullName + \"!\");\n            model.put(\"intro\", \"Let’s get your local and private services online in seconds.\");\n            if (event.passwordResetLink() != null) {\n                model.put(\"ctaText\", \"Setup My Account\");\n                model.put(\"ctaUrl\", event.passwordResetLink());\n            } else {\n                model.put(\"ctaText\", \"Open My Account\");\n                model.put(\"ctaUrl\", webAppUrl + \"/app\");\n            }\n            model.put(\"webAppUrl\", webAppUrl);\n            model.put(\"featuresTitle\", \"What you can do with Port Buddy\");\n            model.put(\"feature1Title\", \"Expose HTTP apps\");\n            model.put(\"feature1Desc\", \"Share your local web app with a secure public URL (HTTP & WebSocket).\");\n            model.put(\"feature2Title\", \"Share TCP services\");\n            model.put(\"feature2Desc\",\n                \"Give teammates access to databases or any TCP service with a temporary public port.\");\n            model.put(\"feature3Title\", \"Simple CLI\");\n            model.put(\"feature3Desc\", \"One command to expose a port. Auth with API token. Pro plan is free.\");\n\n            log.info(\"sending welcome emails to user {}\", event.userId());\n            emailService.sendTemplate(event.email(), \"Welcome to Port Buddy\", \"email/welcome\", model);\n            log.info(\"welcome emails to user {} is sent\", event.userId());\n        } catch (final Exception e) {\n            log.warn(\"Failed to send welcome email: {}\", e.getMessage());\n        }\n    }\n\n    private static String buildFullName(final String firstName, final String lastName) {\n        String result = firstName;\n        if (result == null && lastName == null) {\n            return null;\n        }\n        if (result == null) {\n            result = \"\";\n        }\n        if (lastName != null && !lastName.isBlank()) {\n            result = (result + \" \" + lastName).trim();\n        }\n        return result.isBlank() ? null : result;\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/security/ApiTokenAuthFilter.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.security;\n\nimport static java.util.List.of;\n\nimport java.io.IOException;\n\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.security.authentication.UsernamePasswordAuthenticationToken;\nimport org.springframework.security.core.authority.SimpleGrantedAuthority;\nimport org.springframework.security.core.context.SecurityContextHolder;\nimport org.springframework.stereotype.Component;\nimport org.springframework.util.StringUtils;\nimport org.springframework.web.filter.OncePerRequestFilter;\n\nimport jakarta.servlet.FilterChain;\nimport jakarta.servlet.ServletException;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.server.service.ApiTokenService;\n\n\n/**\n * ApiTokenAuthFilter is a Spring Security filter that processes each incoming HTTP request\n * to authenticate users based on an API token provided in the request headers.\n * This filter looks for the API token in the \"Authorization\" header (prefixed with \"Bearer \")\n * or the \"X-API-Token\" header. The token is then validated using the ApiTokenService, and if\n * valid, the corresponding user ID is extracted and set in the Security Context.\n * This filter ensures that any valid request carrying an API token will have the appropriate\n * authentication details set for use during subsequent processing.\n * The filter is executed once per request and extends the OncePerRequestFilter class provided\n * by the Spring Framework.\n */\n@Component\n@RequiredArgsConstructor\n@Slf4j\npublic class ApiTokenAuthFilter extends OncePerRequestFilter {\n\n    private final ApiTokenService apiTokenService;\n\n    @Override\n    protected void doFilterInternal(final HttpServletRequest request,\n                                    final HttpServletResponse response,\n                                    final FilterChain filterChain)\n        throws ServletException, IOException {\n        try {\n            final var header = request.getHeader(HttpHeaders.AUTHORIZATION);\n            String token = null;\n            if (StringUtils.hasText(header) && header.startsWith(\"Bearer \")) {\n                token = header.substring(7);\n            }\n            if (!StringUtils.hasText(token)) {\n                token = request.getHeader(\"X-API-Token\");\n            }\n\n            if (StringUtils.hasText(token) && SecurityContextHolder.getContext().getAuthentication() == null) {\n                final var userIdOpt = apiTokenService.validateAndGetUserId(token);\n                if (userIdOpt.isPresent()) {\n                    final var userId = userIdOpt.get();\n                    final var auth = new UsernamePasswordAuthenticationToken(\n                        userId, null, of(new SimpleGrantedAuthority(\"ROLE_USER\")));\n                    SecurityContextHolder.getContext().setAuthentication(auth);\n                }\n            }\n        } catch (final Exception e) {\n            log.debug(\"API token auth error: {}\", e.toString());\n        }\n        filterChain.doFilter(request, response);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/security/JwtConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.security;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;\nimport org.springframework.security.oauth2.jwt.JwtDecoder;\nimport org.springframework.security.oauth2.jwt.JwtEncoder;\nimport org.springframework.security.oauth2.jwt.JwtIssuerValidator;\nimport org.springframework.security.oauth2.jwt.JwtTimestampValidator;\nimport org.springframework.security.oauth2.jwt.NimbusJwtDecoder;\nimport org.springframework.security.oauth2.jwt.NimbusJwtEncoder;\n\nimport com.nimbusds.jose.JWSAlgorithm;\nimport com.nimbusds.jose.jwk.source.JWKSource;\nimport com.nimbusds.jose.proc.JWSVerificationKeySelector;\nimport com.nimbusds.jose.proc.SecurityContext;\nimport com.nimbusds.jwt.proc.DefaultJWTProcessor;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.config.AppProperties;\n\n@Configuration\n@RequiredArgsConstructor\npublic class JwtConfig {\n\n    private final RsaKeyProvider rsaKeyProvider;\n    private final AppProperties properties;\n\n    @Bean\n    public JWKSource<SecurityContext> jwkSource() {\n        return rsaKeyProvider.jwkSource();\n    }\n\n    @Bean\n    public JwtEncoder jwtEncoder(final JWKSource<SecurityContext> jwkSource) {\n        return new NimbusJwtEncoder(jwkSource);\n    }\n\n    @Bean\n    public JwtDecoder jwtDecoder(final JWKSource<SecurityContext> jwkSource) {\n        final var jwtProcessor = new DefaultJWTProcessor<>();\n        final var keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource);\n        jwtProcessor.setJWSKeySelector(keySelector);\n        final var decoder = new NimbusJwtDecoder(jwtProcessor);\n        final var withIssuer = new JwtIssuerValidator(properties.jwt().issuer());\n        final var validator = new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(), withIssuer);\n        decoder.setJwtValidator(validator);\n        return decoder;\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/security/JwtService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.security;\n\nimport java.time.Instant;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.stream.Collectors;\n\nimport org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;\nimport org.springframework.security.oauth2.jwt.JwsHeader;\nimport org.springframework.security.oauth2.jwt.Jwt;\nimport org.springframework.security.oauth2.jwt.JwtClaimsSet;\nimport org.springframework.security.oauth2.jwt.JwtEncoder;\nimport org.springframework.security.oauth2.jwt.JwtEncoderParameters;\nimport org.springframework.stereotype.Service;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.Role;\n\n@Service\n@RequiredArgsConstructor\npublic class JwtService {\n\n    public static final String TOKEN_TYPE = \"JWT\";\n\n    private final JwtEncoder jwtEncoder;\n    private final AppProperties properties;\n    private final RsaKeyProvider rsaKeyProvider;\n\n    /**\n     * Creates a JWT token using the specified claims and subject. The generated token includes\n     * details such as issuer, issued time, expiration time, subject, and any custom claims provided.\n     *\n     * @param claims  a map containing custom claims to include in the token. Can be null if no custom claims are\n     *                required.\n     * @param subject the subject of the token, typically representing the identity of the authenticated user.\n     * @return a string representation of the generated JWT token.\n     */\n    public String createToken(final Map<String, Object> claims, final String subject) {\n        return createToken(claims, subject, null);\n    }\n\n    /**\n     * Creates a JWT token using the specified claims and subject. The generated token includes\n     * details such as issuer, issued time, expiration time, subject, and any custom claims provided.\n     *\n     * @param claims  a map containing custom claims to include in the token. Can be null if no custom claims are\n     *                required.\n     * @param subject the subject of the token, typically representing the identity of the authenticated user.\n     * @param roles   the roles assigned to the user.\n     * @return a string representation of the generated JWT token.\n     */\n    public String createToken(final Map<String, Object> claims, final String subject, final Set<Role> roles) {\n        final var now = Instant.now();\n        final var expiresAt = now.plus(properties.jwt().ttl());\n\n        final var builder = JwtClaimsSet.builder()\n            .issuer(properties.jwt().issuer())\n            .issuedAt(now)\n            .expiresAt(expiresAt)\n            .subject(subject);\n        if (claims != null) {\n            claims.forEach(builder::claim);\n        }\n        if (roles != null && !roles.isEmpty()) {\n            builder.claim(Oauth2SuccessHandler.ROLES_CLAIM, roles.stream().map(Enum::name).collect(Collectors.toSet()));\n        }\n        final var header = JwsHeader.with(SignatureAlgorithm.RS256)\n            .type(TOKEN_TYPE)\n            .keyId(rsaKeyProvider.getCurrentKid())\n            .build();\n        final var jwt = jwtEncoder.encode(JwtEncoderParameters.from(header, builder.build()));\n        return jwt.getTokenValue();\n    }\n\n    public static UUID resolveUserId(final Jwt jwt) {\n        return UUID.fromString(jwt.getSubject());\n    }\n\n    /**\n     * Resolves the account id from the JWT token.\n     *\n     * @param jwt the JWT token.\n     * @return the account id.\n     */\n    public static UUID resolveAccountId(final Jwt jwt) {\n        final var claim = jwt.getClaimAsString(Oauth2SuccessHandler.ACCOUNT_ID_CLAIM);\n        if (claim == null) {\n            throw new IllegalArgumentException(\"Account ID claim is missing.\");\n        }\n        return UUID.fromString(claim);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/security/Oauth2SuccessHandler.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.security;\n\nimport static java.nio.charset.StandardCharsets.UTF_8;\n\nimport java.io.IOException;\nimport java.net.URLEncoder;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\nimport org.springframework.core.ParameterizedTypeReference;\nimport org.springframework.security.core.Authentication;\nimport org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;\nimport org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;\nimport org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;\nimport org.springframework.security.oauth2.core.user.OAuth2User;\nimport org.springframework.security.web.authentication.AuthenticationSuccessHandler;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.client.RestClient;\n\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.service.user.MissingEmailException;\nimport tech.amak.portbuddy.server.service.user.UserProvisioningService;\n\n@Component\npublic class Oauth2SuccessHandler implements AuthenticationSuccessHandler {\n\n    public static final String EMAIL_CLAIM = \"email\";\n    public static final String NAME_CLAIM = \"name\";\n    public static final String PICTURE_CLAIM = \"picture\";\n    public static final String AVATAR_URL_CLAIM = \"avatar_url\";\n    public static final String FIRST_NAME_CLAIM = \"given_name\";\n    public static final String LAST_NAME_CLAIM = \"family_name\";\n    public static final String ACCOUNT_ID_CLAIM = \"aid\";\n    public static final String ACCOUNT_NAME_CLAIM = \"aname\";\n    public static final String USER_ID_CLAIM = \"uid\";\n    public static final String SUBJECT_CLAIM = \"sub\";\n    public static final String ID_CLAIM = \"id\";\n    public static final String ROLES_CLAIM = \"roles\";\n    private static final String UNKNOWN = \"unknown\";\n\n    private final JwtService jwtService;\n    private final AppProperties properties;\n    private final UserProvisioningService userProvisioningService;\n    private final OAuth2AuthorizedClientService authorizedClientService;\n    private final RestClient restClient;\n\n    /**\n     * Constructs an instance of {@code Oauth2SuccessHandler} with the required dependencies.\n     * Handles OAuth2 authentication success events and manages token creation, user provisioning,\n     * and authorized client services.\n     *\n     * @param jwtService              the service responsible for creating and handling JWT tokens.\n     * @param properties              the application properties configuration.\n     * @param userProvisioningService the service responsible for provisioning users based on OAuth2 authentication.\n     * @param authorizedClientService the service for managing OAuth2 authorized clients.\n     * @param restClientBuilder       the builder for constructing REST clients.\n     */\n    public Oauth2SuccessHandler(final JwtService jwtService,\n                                final AppProperties properties,\n                                final UserProvisioningService userProvisioningService,\n                                final OAuth2AuthorizedClientService authorizedClientService,\n                                final RestClient.Builder restClientBuilder) {\n        this.jwtService = jwtService;\n        this.properties = properties;\n        this.userProvisioningService = userProvisioningService;\n        this.authorizedClientService = authorizedClientService;\n        this.restClient = restClientBuilder.build();\n    }\n\n    @Override\n    public void onAuthenticationSuccess(final HttpServletRequest request,\n                                        final HttpServletResponse response,\n                                        final Authentication authentication)\n        throws IOException {\n        final var provider = resolveProvider(authentication);\n        var externalId = UNKNOWN;\n        var email = (String) null;\n        var name = (String) null;\n        var picture = (String) null;\n        var firstName = (String) null;\n        var lastName = (String) null;\n\n        final var principal = authentication.getPrincipal();\n        if (principal instanceof DefaultOidcUser oidc) {\n            externalId = oidc.getSubject();\n            email = oidc.getEmail();\n            name = oidc.getFullName();\n            picture = (String) oidc.getClaims().getOrDefault(PICTURE_CLAIM, null);\n            firstName = (String) oidc.getClaims().getOrDefault(FIRST_NAME_CLAIM, null);\n            lastName = (String) oidc.getClaims().getOrDefault(LAST_NAME_CLAIM, null);\n        } else if (principal instanceof OAuth2User oauth2) {\n            final var attrs = oauth2.getAttributes();\n            externalId = String.valueOf(attrs.getOrDefault(SUBJECT_CLAIM, attrs.getOrDefault(ID_CLAIM, UNKNOWN)));\n            email = asNullableString(attrs, EMAIL_CLAIM);\n            name = asNullableString(attrs, NAME_CLAIM);\n            picture = asNullableString(attrs, PICTURE_CLAIM);\n            if (picture == null) {\n                picture = asNullableString(attrs, AVATAR_URL_CLAIM);\n            }\n            if (attrs.containsKey(FIRST_NAME_CLAIM) || attrs.containsKey(LAST_NAME_CLAIM)) {\n                firstName = asNullableString(attrs, FIRST_NAME_CLAIM);\n                lastName = asNullableString(attrs, LAST_NAME_CLAIM);\n            }\n\n            if (email == null && \"github\".equals(provider)) {\n                email = fetchGithubEmail(authentication);\n            }\n        }\n\n        if ((firstName == null || lastName == null) && name != null && !name.isBlank()) {\n            final var parts = name.trim().split(\" \", 2);\n            firstName = firstName == null ? parts[0] : firstName;\n            if (parts.length > 1) {\n                lastName = lastName == null ? parts[1] : lastName;\n            }\n        }\n\n        // Ensure user + account exist or updated\n        final var provisioned = provisionOrRedirectOnMissingEmail(\n            response, provider, externalId, email, firstName, lastName, picture);\n        if (provisioned == null) {\n            // Redirect already sent (e.g., missing email). Stop further processing.\n            return;\n        }\n\n        final var claims = new HashMap<String, Object>();\n        if (email != null) {\n            claims.put(EMAIL_CLAIM, email);\n        }\n        if (name != null) {\n            claims.put(NAME_CLAIM, name);\n        }\n        if (picture != null) {\n            claims.put(PICTURE_CLAIM, picture);\n        }\n        claims.put(ACCOUNT_ID_CLAIM, provisioned.accountId().toString());\n        claims.put(ACCOUNT_NAME_CLAIM, provisioned.accountName());\n        claims.put(USER_ID_CLAIM, provisioned.userId().toString());\n\n        final var token = jwtService.createToken(claims, provisioned.userId().toString(), provisioned.roles());\n        final var redirectUrl = properties.gateway().url() + \"/auth/callback?token=\" + URLEncoder.encode(token, UTF_8);\n        response.sendRedirect(redirectUrl);\n    }\n\n    private static String resolveProvider(final Authentication authentication) {\n        if (authentication instanceof OAuth2AuthenticationToken oauthToken) {\n            return oauthToken.getAuthorizedClientRegistrationId();\n        }\n        return \"unknown\";\n    }\n\n    private String fetchGithubEmail(final Authentication authentication) {\n        if (!(authentication instanceof OAuth2AuthenticationToken oauthToken)) {\n            return null;\n        }\n        final var client = authorizedClientService.loadAuthorizedClient(\n            oauthToken.getAuthorizedClientRegistrationId(),\n            oauthToken.getName());\n        if (client == null || client.getAccessToken() == null) {\n            return null;\n        }\n\n        try {\n            final var emails = restClient.get()\n                .uri(\"https://api.github.com/user/emails\")\n                .headers(headers -> headers.setBearerAuth(client.getAccessToken().getTokenValue()))\n                .retrieve()\n                .body(new ParameterizedTypeReference<List<Map<String, Object>>>() {\n                });\n\n            if (emails == null || emails.isEmpty()) {\n                return null;\n            }\n\n            // Prefer primary email\n            return emails.stream()\n                .filter(map ->\n                    Boolean.TRUE.equals(map.get(\"primary\")) && Boolean.TRUE.equals(map.get(\"verified\")))\n                .map(map -> (String) map.get(\"email\"))\n                .findFirst()\n                .orElseGet(() -> emails.stream()\n                    .filter(map -> Boolean.TRUE.equals(map.get(\"verified\")))\n                    .map(map -> (String) map.get(\"email\"))\n                    .findFirst()\n                    .orElse(null));\n        } catch (final Exception e) {\n            return null;\n        }\n    }\n\n    private static String asNullableString(final Map<String, Object> attrs, final String key) {\n        final var value = attrs.get(key);\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private UserProvisioningService.ProvisionedUser provisionOrRedirectOnMissingEmail(\n        final HttpServletResponse response,\n        final String provider,\n        final String externalId,\n        final String email,\n        final String firstName,\n        final String lastName,\n        final String picture) throws IOException {\n        try {\n            return userProvisioningService.provision(provider, externalId, email, firstName, lastName, picture);\n        } catch (final MissingEmailException ex) {\n            final var url = properties.gateway().url()\n                            + \"/auth/callback?error=\"\n                            + URLEncoder.encode(\"missing_email\", UTF_8)\n                            + \"&message=\"\n                            + URLEncoder.encode(\"Email is required to sign in\", UTF_8);\n            response.sendRedirect(url);\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/security/RsaKeyProvider.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.security;\n\nimport java.nio.charset.StandardCharsets;\nimport java.security.KeyFactory;\nimport java.security.interfaces.RSAPrivateKey;\nimport java.security.interfaces.RSAPublicKey;\nimport java.security.spec.PKCS8EncodedKeySpec;\nimport java.security.spec.X509EncodedKeySpec;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Objects;\n\nimport org.springframework.stereotype.Component;\n\nimport com.nimbusds.jose.jwk.JWK;\nimport com.nimbusds.jose.jwk.JWKSet;\nimport com.nimbusds.jose.jwk.RSAKey;\nimport com.nimbusds.jose.jwk.source.ImmutableJWKSet;\nimport com.nimbusds.jose.jwk.source.JWKSource;\nimport com.nimbusds.jose.proc.SecurityContext;\n\nimport lombok.Getter;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.server.config.AppProperties;\n\n/**\n * Loads RSA keys from configuration and exposes a JWKSource for signing and a public JWKSet for discovery.\n */\n@Component\n@Slf4j\npublic class RsaKeyProvider {\n\n    private final ImmutableJWKSet<SecurityContext> jwkSource;\n    @Getter\n    private final JWKSet publicJwkSet;\n    @Getter\n    private final String currentKid;\n\n    /**\n     * Constructs an instance of {@code RsaKeyProvider} by loading RSA keys from the provided application properties.\n     * This constructor validates the configuration of RSA keys and ensures the presence of at least one valid key.\n     *\n     * @param properties the application properties containing the JWT and RSA key configuration; must not be null\n     *                   and must properly configure the `app.jwt.rsa` and its required fields:\n     *                   <ul>\n     *                     <li>`app.jwt.rsa.currentKeyId`: the ID of the current RSA key, used for signing JWTs</li>\n     *                     <li>`app.jwt.rsa.keys`: a list of RSA key configurations,\n     *                      each including public and optionally private keys</li>\n     *                   </ul>\n     *                   An exception is thrown if any fields are improperly configured.\n     */\n    public RsaKeyProvider(final AppProperties properties) {\n        final var jwt = Objects.requireNonNull(properties.jwt(), \"app.jwt must be configured\");\n        final var rsa = Objects.requireNonNull(jwt.rsa(), \"app.jwt.rsa must be configured\");\n        this.currentKid = Objects.requireNonNull(rsa.currentKeyId(), \"app.jwt.rsa.currentKeyId must be set\");\n        final var keys = Objects.requireNonNull(rsa.keys(), \"app.jwt.rsa.keys must be set\");\n        if (keys.isEmpty()) {\n            throw new IllegalStateException(\"app.jwt.rsa.keys must contain at least one key\");\n        }\n\n        final List<RSAKey> all = new ArrayList<>();\n        final List<RSAKey> publicOnly = new ArrayList<>();\n        for (final var key : keys) {\n            final var id = Objects.requireNonNull(key.id(), \"RSA key id must be set\");\n            try {\n                final var pub = parsePublicKey(key.publicKeyPem().getContentAsString(StandardCharsets.UTF_8));\n                final var builder = new RSAKey.Builder(pub).keyID(id);\n                publicOnly.add(builder.build());\n\n                final var privatePem = key.privateKeyPem().getContentAsString(StandardCharsets.UTF_8);\n                if (!privatePem.isBlank()) {\n                    final var rsaPrivateKey = parsePrivateKey(privatePem);\n                    all.add(new RSAKey.Builder(pub).privateKey(rsaPrivateKey).keyID(id).build());\n                } else {\n                    // Public-only key; not usable for signing\n                    all.add(builder.build());\n                }\n            } catch (final Exception e) {\n                throw new IllegalStateException(\"Failed to parse RSA key id=\" + id + \": \" + e.getMessage(), e);\n            }\n        }\n\n        final var allJwks = new ArrayList<JWK>(all);\n        final var publicJwks = new ArrayList<JWK>(publicOnly);\n        this.jwkSource = new ImmutableJWKSet<>(new JWKSet(allJwks));\n        this.publicJwkSet = new JWKSet(publicJwks);\n        log.info(\"Loaded {} RSA keys; current kid={}\", all.size(), this.currentKid);\n    }\n\n    public JWKSource<SecurityContext> jwkSource() {\n        return jwkSource;\n    }\n\n    private static RSAPublicKey parsePublicKey(final String pem) throws Exception {\n        final var content = stripPemHeaders(pem, \"PUBLIC KEY\");\n        final var decoded = Base64.getMimeDecoder().decode(content);\n        final var kf = KeyFactory.getInstance(\"RSA\");\n        return (RSAPublicKey) kf.generatePublic(new X509EncodedKeySpec(decoded));\n    }\n\n    private static RSAPrivateKey parsePrivateKey(final String pem) throws Exception {\n        final var content = stripPemHeaders(pem, \"PRIVATE KEY\");\n        final var decoded = Base64.getMimeDecoder().decode(content);\n        final var kf = KeyFactory.getInstance(\"RSA\");\n        return (RSAPrivateKey) kf.generatePrivate(new PKCS8EncodedKeySpec(decoded));\n    }\n\n    private static String stripPemHeaders(final String pem, final String type) {\n        if (pem == null) {\n            throw new IllegalArgumentException(\"PEM is null\");\n        }\n        return pem\n            .replace(\"-----BEGIN \" + type + \"-----\", \"\")\n            .replace(\"-----END \" + type + \"-----\", \"\")\n            .replaceAll(\"\\\\s\", \"\");\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/security/SecurityConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.security;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.context.annotation.Lazy;\nimport org.springframework.core.annotation.Order;\nimport org.springframework.http.HttpMethod;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;\nimport org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;\nimport org.springframework.security.config.http.SessionCreationPolicy;\nimport org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;\nimport org.springframework.security.crypto.password.PasswordEncoder;\nimport org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;\nimport org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;\nimport org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;\nimport org.springframework.security.web.context.RequestAttributeSecurityContextRepository;\n\nimport lombok.RequiredArgsConstructor;\n\n@Configuration\n@EnableWebSecurity\n@EnableMethodSecurity\n@RequiredArgsConstructor\npublic class SecurityConfig {\n\n    private final ApiTokenAuthFilter apiTokenAuthFilter;\n    private final @Lazy Oauth2SuccessHandler oauth2SuccessHandler;\n\n    @Bean\n    @Order(1)\n    public SecurityFilterChain apiSecurityFilterChain(final HttpSecurity http) throws Exception {\n        http\n            .securityMatcher(\"/api/**\")\n            .cors(AbstractHttpConfigurer::disable)\n            .csrf(AbstractHttpConfigurer::disable)\n            .authorizeHttpRequests(auth -> auth\n                .requestMatchers(HttpMethod.POST,\n                    \"/api/auth/token-exchange\",\n                    \"/api/auth/login\",\n                    \"/api/auth/register\",\n                    \"/api/webhooks/stripe\").permitAll()\n                .requestMatchers(\"/api/auth/password-reset/**\").permitAll()\n                .requestMatchers(\"/api/internal/**\").permitAll()\n                .anyRequest().authenticated()\n            )\n            .addFilterBefore(apiTokenAuthFilter, BearerTokenAuthenticationFilter.class)\n            // Enforce stateless API: no HTTP session will be created or used for authentication\n            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))\n            // Store SecurityContext only in the request, never in an HTTP session or cookie\n            .securityContext(sc -> sc.securityContextRepository(new RequestAttributeSecurityContextRepository()))\n            .oauth2ResourceServer(oauth2 -> oauth2\n                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))\n            )\n            .logout(logout -> logout\n                // Logout for stateless API: handle POST /api/auth/logout on this server (relative URL)\n                .logoutUrl(\"/api/auth/logout\")\n                .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.NO_CONTENT))\n                .permitAll()\n            );\n        return http.build();\n    }\n\n    @Bean\n    @Order(2)\n    public SecurityFilterChain webSecurityFilterChain(final HttpSecurity http) throws Exception {\n        http\n            .cors(AbstractHttpConfigurer::disable)\n            .csrf(AbstractHttpConfigurer::disable)\n            .authorizeHttpRequests(auth -> auth\n                .requestMatchers(\n                    \"/\", \"/index.html\", \"/assets/**\", \"/favicon.*\",\n                    \"/actuator/health**\", \"/ingress/**\", \"/ws/**\", \"/_ws/**\", \"/oauth2/**\", \"/login**\",\n                    \"/.well-known/jwks.json\"\n                ).permitAll()\n                .anyRequest().permitAll()\n            )\n            .oauth2Login(oauth -> oauth\n                // OAuth2 is used only to mint a JWT and redirect back. API calls must use Authorization: Bearer <token>\n                .successHandler(oauth2SuccessHandler)\n            );\n        return http.build();\n    }\n\n    @Bean\n    public PasswordEncoder passwordEncoder() {\n        return new BCryptPasswordEncoder();\n    }\n\n    @Bean\n    public JwtAuthenticationConverter jwtAuthenticationConverter() {\n        final var converter = new JwtGrantedAuthoritiesConverter();\n        converter.setAuthoritiesClaimName(\"roles\");\n        converter.setAuthorityPrefix(\"ROLE_\");\n\n        final var authConverter = new JwtAuthenticationConverter();\n        authConverter.setJwtGrantedAuthoritiesConverter(converter);\n        return authConverter;\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/security/ThreatBlockedException.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.security;\n\npublic class ThreatBlockedException extends SecurityException {\n\n    public ThreatBlockedException(final String message) {\n        super(message);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/ApiTokenService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.security.SecureRandom;\nimport java.time.Instant;\nimport java.time.OffsetDateTime;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.server.db.entity.ApiKeyEntity;\nimport tech.amak.portbuddy.server.db.repo.ApiKeyRepository;\n\n/**\n * API token service backed by the database. Stores only token hashes.\n */\n@Service\n@Slf4j\npublic class ApiTokenService {\n\n    private final SecureRandom secureRandom = new SecureRandom();\n    private final ApiKeyRepository apiKeyRepository;\n\n    public ApiTokenService(final ApiKeyRepository apiKeyRepository) {\n        this.apiKeyRepository = apiKeyRepository;\n    }\n\n    /**\n     * Creates a new API token for the specified user and stores its hash in the DB.\n     *\n     * @param accountId the account ID (UUID)\n     * @param userId    the user ID (UUID)\n     * @param label     optional label for the token\n     * @return token id and raw token (displayed once)\n     */\n    @Transactional\n    public CreatedToken createToken(final UUID accountId, final UUID userId, final String label) {\n        final var rawToken = generateRawToken();\n        final var tokenHash = sha256(rawToken);\n\n        final var apiKey = new ApiKeyEntity();\n        apiKey.setId(UUID.randomUUID());\n        apiKey.setAccountId(accountId);\n        apiKey.setUserId(userId);\n        apiKey.setLabel(label == null || label.isBlank() ? \"cli\" : label);\n        apiKey.setTokenHash(tokenHash);\n        apiKey.setRevoked(false);\n\n        final var saved = apiKeyRepository.save(apiKey);\n        log.info(\"Created API token id={} for userId={} accountId={}\", saved.getId(), userId, accountId);\n        return new CreatedToken(saved.getId().toString(), rawToken);\n    }\n\n    /**\n     * Lists tokens for the account without exposing secrets.\n     */\n    @Transactional(readOnly = true)\n    public List<TokenView> listTokens(final UUID accountId) {\n        return apiKeyRepository.findAllByAccountId(accountId).stream()\n            .map(apiKey -> new TokenView(\n                apiKey.getId().toString(),\n                apiKey.getLabel(),\n                toInstant(apiKey.getCreatedAt()),\n                apiKey.isRevoked(),\n                toInstant(apiKey.getLastUsedAt())))\n            .toList();\n    }\n\n    /**\n     * Marks a token as revoked.\n     */\n    @Transactional\n    public boolean revoke(final UUID accountId, final String tokenId) {\n        final var tid = UUID.fromString(tokenId);\n        return apiKeyRepository.findByIdAndAccountId(tid, accountId)\n            .map(apiKey -> {\n                apiKey.setRevoked(true);\n                apiKey.setRevokedAt(OffsetDateTime.now());\n                apiKeyRepository.save(apiKey);\n                log.info(\"Revoked API token id={} for accountId={}\", tid, accountId);\n                return true;\n            })\n            .orElseGet(() -> {\n                log.warn(\"Token {} not found for account {}\", tokenId, accountId);\n                return false;\n            });\n    }\n\n    /**\n     * Validates raw token and returns user id if valid.\n     */\n    @Transactional\n    public Optional<String> validateAndGetUserId(final String rawToken) {\n        if (rawToken == null || rawToken.isBlank()) {\n            return Optional.empty();\n        }\n        final var hash = sha256(rawToken);\n        final var entityOpt = apiKeyRepository.findByTokenHashAndRevokedFalse(hash);\n        if (entityOpt.isEmpty()) {\n            return Optional.empty();\n        }\n        final var entity = entityOpt.get();\n        entity.setLastUsedAt(OffsetDateTime.now());\n        apiKeyRepository.save(entity);\n        return Optional.of(entity.getUserId().toString());\n    }\n\n    /**\n     * Validates raw token and returns user id, account id and api key id if valid.\n     */\n    @Transactional\n    public Optional<ValidatedApiKey> validateAndGetApiKey(final String rawToken) {\n        if (rawToken == null || rawToken.isBlank()) {\n            return Optional.empty();\n        }\n        final var hash = sha256(rawToken);\n        final var entityOpt = apiKeyRepository.findByTokenHashAndRevokedFalse(hash);\n        if (entityOpt.isEmpty()) {\n            return Optional.empty();\n        }\n        final var entity = entityOpt.get();\n        entity.setLastUsedAt(OffsetDateTime.now());\n        apiKeyRepository.save(entity);\n        return Optional.of(new ValidatedApiKey(\n            entity.getUserId(),\n            entity.getAccountId(),\n            entity.getId()));\n    }\n\n    private String generateRawToken() {\n        final var bytes = new byte[32];\n        secureRandom.nextBytes(bytes);\n        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);\n    }\n\n    private String sha256(final String val) {\n        try {\n            final var md = MessageDigest.getInstance(\"SHA-256\");\n            final var bytes = md.digest(val.getBytes(StandardCharsets.UTF_8));\n            return Base64.getEncoder().encodeToString(bytes);\n        } catch (final Exception e) {\n            throw new IllegalStateException(\"SHA-256 not available\", e);\n        }\n    }\n\n    public record CreatedToken(String id, String token) {\n    }\n\n    public record TokenView(String id, String label, Instant createdAt, boolean revoked, Instant lastUsedAt) {\n    }\n\n    public record ValidatedApiKey(UUID userId, UUID accountId, UUID apiKeyId) {\n    }\n\n    private static Instant toInstant(final OffsetDateTime odt) {\n        return odt == null ? null : odt.toInstant();\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/DomainService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport java.security.SecureRandom;\nimport java.util.Hashtable;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.UUID;\nimport javax.naming.Context;\nimport javax.naming.NamingException;\nimport javax.naming.directory.Attribute;\nimport javax.naming.directory.Attributes;\nimport javax.naming.directory.DirContext;\nimport javax.naming.directory.InitialDirContext;\n\nimport org.springframework.security.crypto.password.PasswordEncoder;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.server.client.SslServiceClient;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.DomainEntity;\nimport tech.amak.portbuddy.server.db.entity.TunnelStatus;\nimport tech.amak.portbuddy.server.db.repo.DomainRepository;\nimport tech.amak.portbuddy.server.db.repo.TunnelRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class DomainService {\n\n    private static final int MAX_RETRIES = 30;\n\n    private final DomainRepository domainRepository;\n    private final TunnelRepository tunnelRepository;\n    private final AppProperties properties;\n    private final PasswordEncoder passwordEncoder;\n    private final SslServiceClient sslServiceClient;\n    private final UserRepository userRepository;\n\n    private final SecureRandom random = new SecureRandom();\n\n    @Transactional(readOnly = true)\n    public List<DomainEntity> getDomains(final AccountEntity account) {\n        return domainRepository.findAllByAccount(account);\n    }\n\n    /**\n     * Creates a new domain for the given account. A unique subdomain is\n     * generated and assigned to the domain entity associated with the account.\n     * If a unique subdomain cannot be generated after the maximum number of retries,\n     * an exception is thrown.\n     *\n     * @param account The account entity to which the new domain will be associated.\n     * @return The newly created domain entity with its associated subdomain and account.\n     * @throws RuntimeException if a unique subdomain cannot be generated within the maximum attempts.\n     */\n    @Transactional\n    public Optional<DomainEntity> createDomain(final AccountEntity account) {\n        String subdomain;\n        int retries = 0;\n        do {\n            subdomain = generateRandomSubdomain();\n            retries++;\n        } while (domainRepository.existsBySubdomainGlobal(subdomain) && retries < MAX_RETRIES);\n\n        if (domainRepository.existsBySubdomainGlobal(subdomain)) {\n            log.warn(\"Failed to generate unique subdomain after all attempts\");\n            return Optional.empty();\n        }\n\n        final var domain = new DomainEntity();\n        domain.setId(UUID.randomUUID());\n        domain.setSubdomain(subdomain);\n        domain.setDomain(properties.gateway().domain());\n        domain.setAccount(account);\n\n        log.info(\"Assigned subdomain {} to account {}\", subdomain, account.getId());\n        return Optional.of(domainRepository.save(domain));\n    }\n\n    @Transactional\n    public void assignRandomDomain(final AccountEntity account) {\n        createDomain(account);\n    }\n\n    /**\n     * Updates the subdomain of a specified domain entity associated with a given account.\n     * If the domain is currently being used by an active tunnel, the update will not be allowed.\n     * Additionally, it ensures that the new subdomain is unique globally before updating.\n     *\n     * @param id           the unique identifier of the domain to update\n     * @param account      the account associated with the domain\n     * @param newSubdomain the new subdomain to assign to the domain\n     * @return the updated DomainEntity if the operation is successful\n     * @throws RuntimeException if the domain is not found for the given id and account\n     * @throws RuntimeException if the domain is currently used by an active tunnel\n     * @throws RuntimeException if the new subdomain is already taken globally\n     */\n    @Transactional\n    public DomainEntity updateDomain(final UUID id, final AccountEntity account, final String newSubdomain) {\n        final var domain = domainRepository.findByIdAndAccount(id, account)\n            .orElseThrow(() -> new RuntimeException(\"Domain not found\"));\n\n        if (isTunnelActive(domain)) {\n            throw new RuntimeException(\"Cannot update domain used by active tunnel\");\n        }\n\n        final var normalizedSubdomain = newSubdomain != null ? newSubdomain.toLowerCase() : null;\n        if (!domain.getSubdomain().equals(normalizedSubdomain)) {\n            if (domainRepository.existsBySubdomainGlobal(normalizedSubdomain)) {\n                throw new RuntimeException(\"Subdomain \" + normalizedSubdomain + \" is already taken\");\n            }\n            domain.setSubdomain(normalizedSubdomain);\n            domain.setCnameVerified(false);\n            domain.setSslActive(false);\n            return domainRepository.save(domain);\n        }\n        return domain;\n    }\n\n    /**\n     * Deletes a domain associated with the specified account.\n     * Ensures the domain exists and is not being used by an active tunnel before deletion.\n     *\n     * @param id      The unique identifier of the domain to be deleted.\n     * @param account The account entity associated with the domain to validate ownership.\n     * @throws RuntimeException if the domain is not found or is being used by an active tunnel.\n     */\n    @Transactional\n    public void deleteDomain(final UUID id, final AccountEntity account) {\n        final var domain = domainRepository.findByIdAndAccount(id, account)\n            .orElseThrow(() -> new RuntimeException(\"Domain not found\"));\n\n        if (isTunnelActive(domain)) {\n            throw new RuntimeException(\"Cannot delete domain used by active tunnel\");\n        }\n\n        domainRepository.delete(domain);\n        log.info(\"Deleted domain {} for account {}\", domain.getSubdomain(), account.getId());\n    }\n\n    /**\n     * Sets or updates a passcode for the specified domain belonging to the provided account.\n     * The passcode will be stored as a hash using the configured {@link PasswordEncoder}.\n     *\n     * @param id       the unique identifier of the domain\n     * @param account  the owner account of the domain\n     * @param passcode the new raw passcode value to set\n     * @return the updated domain entity\n     */\n    @Transactional\n    public DomainEntity setPasscode(final UUID id, final AccountEntity account, final String passcode) {\n        if (passcode == null || passcode.isBlank()) {\n            throw new RuntimeException(\"Passcode must not be blank\");\n        }\n        if (passcode.length() < 4 || passcode.length() > 128) {\n            throw new RuntimeException(\"Passcode length must be between 4 and 128 characters\");\n        }\n\n        final var domain = domainRepository.findByIdAndAccount(id, account)\n            .orElseThrow(() -> new RuntimeException(\"Domain not found\"));\n\n        final var hash = passwordEncoder.encode(passcode);\n        domain.setPasscodeHash(hash);\n        return domainRepository.save(domain);\n    }\n\n    /**\n     * Clears passcode protection for the specified domain belonging to the provided account.\n     *\n     * @param id      the unique identifier of the domain\n     * @param account the owner account of the domain\n     */\n    @Transactional\n    public void clearPasscode(final UUID id, final AccountEntity account) {\n        final var domain = domainRepository.findByIdAndAccount(id, account)\n            .orElseThrow(() -> new RuntimeException(\"Domain not found\"));\n        domain.setPasscodeHash(null);\n        domainRepository.save(domain);\n    }\n\n    /**\n     * Updates the custom domain for the given domain entity.\n     *\n     * @param id           domain id\n     * @param account      account entity\n     * @param customDomain custom domain name\n     * @return updated domain entity\n     */\n    @Transactional\n    public DomainEntity updateCustomDomain(final UUID id, final AccountEntity account, final String customDomain) {\n        final var domain = domainRepository.findByIdAndAccount(id, account)\n            .orElseThrow(() -> new RuntimeException(\"Domain not found\"));\n\n        final var normalizedCustomDomain = customDomain != null ? customDomain.toLowerCase() : null;\n        if (!Objects.equals(domain.getCustomDomain(), normalizedCustomDomain)) {\n            domain.setCustomDomain(normalizedCustomDomain);\n            domain.setCnameVerified(false);\n            domain.setSslActive(false);\n            return domainRepository.save(domain);\n        }\n        return domain;\n    }\n\n    /**\n     * Deletes the custom domain from the given domain entity.\n     *\n     * @param id      domain id\n     * @param account account entity\n     */\n    @Transactional\n    public void deleteCustomDomain(final UUID id, final AccountEntity account) {\n        final var domain = domainRepository.findByIdAndAccount(id, account)\n            .orElseThrow(() -> new RuntimeException(\"Domain not found\"));\n        domain.setCustomDomain(null);\n        domain.setCnameVerified(false);\n        domain.setSslActive(false);\n        domainRepository.save(domain);\n    }\n\n    /**\n     * Verifies that the custom domain has a CNAME record pointing to the Port Buddy subdomain.\n     * Once verified, it triggers SSL certificate issuance.\n     *\n     * @param id      domain id\n     * @param account account entity\n     * @param userId  user id\n     * @return updated domain entity\n     */\n    @Transactional\n    public DomainEntity verifyCname(final UUID id, final AccountEntity account, final UUID userId) {\n        final var domain = domainRepository.findByIdAndAccount(id, account)\n            .orElseThrow(() -> new RuntimeException(\"Domain not found\"));\n\n        final var user = userRepository.findById(userId)\n            .orElseThrow(() -> new RuntimeException(\"User not found\"));\n\n        final var customDomain = domain.getCustomDomain();\n        if (customDomain == null || customDomain.isBlank()) {\n            throw new RuntimeException(\"Custom domain is not set\");\n        }\n\n        final var expectedCname = domain.getSubdomain() + \".\" + domain.getDomain();\n        final var isVerified = checkCname(customDomain, expectedCname);\n\n        if (isVerified) {\n            domain.setCnameVerified(true);\n            domainRepository.save(domain);\n\n            // Trigger SSL issuance\n            try {\n                sslServiceClient.submitJob(customDomain, user.getEmail(), true);\n            } catch (final Exception e) {\n                log.error(\"Failed to trigger SSL issuance for {}\", customDomain, e);\n            }\n        } else {\n            throw new RuntimeException(\"CNAME verification failed. Please ensure \" + customDomain\n                                       + \" points to \" + expectedCname);\n        }\n\n        return domain;\n    }\n\n    private boolean checkCname(final String domain, final String expectedTarget) {\n        final var env = new Hashtable<String, String>();\n        env.put(Context.INITIAL_CONTEXT_FACTORY, \"com.sun.jndi.dns.DnsContextFactory\");\n        DirContext ictx = null;\n        try {\n            ictx = new InitialDirContext(env);\n            final Attributes attrs = ictx.getAttributes(domain, new String[] {\"CNAME\"});\n            final Attribute cnameAttr = attrs.get(\"CNAME\");\n\n            if (cnameAttr != null) {\n                final String cname = (String) cnameAttr.get();\n                // Remove trailing dot if present\n                final String normalizedCname = cname.endsWith(\".\") ? cname.substring(0, cname.length() - 1) : cname;\n                return normalizedCname.equalsIgnoreCase(expectedTarget);\n            }\n        } catch (final NamingException e) {\n            log.debug(\"Failed to lookup CNAME for {}\", domain, e);\n        } finally {\n            if (ictx != null) {\n                try {\n                    ictx.close();\n                } catch (final NamingException e) {\n                    log.debug(\"Failed to close DNS context\", e);\n                }\n            }\n        }\n        return false;\n    }\n\n    /**\n     * Marks the domain with the specified custom domain as SSL active.\n     *\n     * @param customDomain the custom domain name\n     */\n    @Transactional\n    public void markSslActive(final String customDomain) {\n        if (customDomain == null) {\n            return;\n        }\n        domainRepository.findByCustomDomain(customDomain.toLowerCase()).ifPresent(domain -> {\n            domain.setSslActive(true);\n            domainRepository.save(domain);\n            log.info(\"Marked domain {} as SSL active\", customDomain);\n        });\n    }\n\n    /**\n     * Resolves a domain for the specified account and user, based on the requested domain\n     * or available domains associated with the account. If a specific domain is requested,\n     * it attempts to return the domain after verifying its availability. Otherwise, it picks\n     * an available domain, taking into account resource affinity.\n     *\n     * @param account         the account entity for which the domain resolution is performed\n     * @param requestedDomain the fully-qualified domain name requested by the user, or null if no specific domain is\n     *                        requested\n     * @param localHost       the local hostname of the user's resource requesting the domain\n     * @param localPort       the local port of the user's resource requesting the domain\n     * @return the resolved domain entity satisfying the resolution criteria\n     * @throws RuntimeException if the requested domain is unavailable or no domains are available for the account\n     */\n    @Transactional(readOnly = true)\n    public DomainEntity resolveDomain(final AccountEntity account,\n                                      final String requestedDomain,\n                                      final String localHost,\n                                      final Integer localPort) {\n        if (requestedDomain != null && !requestedDomain.isBlank()) {\n            // User requested specific domain\n            final var normalizedDomain = requestedDomain.toLowerCase();\n            String targetSubdomain = normalizedDomain;\n            final var baseDomain = properties.gateway().domain();\n            if (normalizedDomain.endsWith(\".\" + baseDomain)) {\n                targetSubdomain = normalizedDomain.substring(0, normalizedDomain.length() - baseDomain.length() - 1);\n            }\n\n            final var finalSubdomain = targetSubdomain;\n\n            return domainRepository.findByAccountAndSubdomain(account, finalSubdomain)\n                .filter(domain -> !isTunnelConnected(domain))\n                .orElseThrow(() -> new RuntimeException(\"Domain not found or unavailable: \" + requestedDomain));\n        }\n\n        // No specific domain requested\n        // Filter out CONNECTED domains\n        final var availableDomains = domainRepository.findAllByAccount(account).stream()\n            .filter(domain -> !isTunnelConnected(domain))\n            .toList();\n\n        if (availableDomains.isEmpty()) {\n            throw new RuntimeException(\"No available domains found. Please add a new domain at https://portbuddy.dev/app/domains\");\n        }\n\n        // Affinity check: Find the last used subdomain for this resource\n        final var lastTunnel = tunnelRepository.findUsedTunnel(account.getId(), localHost, localPort);\n\n        if (lastTunnel.isPresent() && lastTunnel.get().getDomain() != null) {\n            final var lastDomain = lastTunnel.get().getDomain();\n            final var matched = availableDomains.stream()\n                .filter(domain -> Objects.equals(domain.getId(), lastDomain.getId()))\n                .findFirst();\n            if (matched.isPresent()) {\n                return matched.get();\n            }\n        }\n\n        // Pick any\n        return availableDomains.getFirst();\n    }\n\n    private boolean isTunnelConnected(final DomainEntity domain) {\n        return tunnelRepository.existsByDomainAndStatus(domain, TunnelStatus.CONNECTED);\n    }\n\n    private boolean isTunnelActive(final DomainEntity domain) {\n        return tunnelRepository.existsByDomainAndStatusNot(domain, TunnelStatus.CLOSED);\n    }\n\n    private String generateRandomSubdomain() {\n        final var animals = new String[] {\"falcon\", \"lynx\", \"orca\", \"otter\", \"swift\", \"sparrow\", \"tiger\", \"puma\"};\n        final var name = animals[random.nextInt(animals.length)];\n        final var num = 1000 + random.nextInt(9000);\n        return name + \"-\" + num;\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/PaymentCleanupService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport java.time.OffsetDateTime;\n\nimport org.springframework.scheduling.annotation.Scheduled;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport net.javacrumbs.shedlock.spring.annotation.SchedulerLock;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\n\n/**\n * Periodically checks for accounts with failed payments or non-active subscriptions\n * and freezes their tunnels after a grace period.\n */\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class PaymentCleanupService {\n\n    private final AccountRepository accountRepository;\n    private final TunnelService tunnelService;\n    private final AppProperties appProperties;\n\n    /**\n     * Checks for accounts that need tunnel freezing due to non-active subscription status.\n     */\n    @Scheduled(\n        fixedDelayString =\n            \"#{@'app-tech.amak.portbuddy.server.config.AppProperties'.subscriptions().checkInterval().toMillis()}\",\n        initialDelayString =\n            \"#{@'app-tech.amak.portbuddy.server.config.AppProperties'.subscriptions().checkInterval().toMillis()}\"\n    )\n    @SchedulerLock(name = \"paymentCleanupTask\", lockAtMostFor = \"PT10M\", lockAtLeastFor = \"PT1M\")\n    @Transactional\n    public void cleanupFailedPayments() {\n        final var gracePeriod = appProperties.subscriptions().gracePeriod();\n        final var cutoff = OffsetDateTime.now().minus(gracePeriod);\n\n        log.debug(\"Checking for non-active accounts older than {}\", cutoff);\n\n        final var accountsToFreeze = accountRepository.findBySubscriptionStatusNotActiveAndUpdatedAtBefore(cutoff);\n\n        if (!accountsToFreeze.isEmpty()) {\n            log.info(\"Found {} accounts to freeze due to non-active subscription status\", accountsToFreeze.size());\n            for (final var account : accountsToFreeze) {\n                log.info(\"Freezing tunnels for account {} (status={}, updatedAt={})\",\n                    account.getId(), account.getSubscriptionStatus(), account.getUpdatedAt());\n                tunnelService.closeAllTunnels(account);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/PortReservationService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport java.util.Comparator;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.springframework.dao.DataIntegrityViolationException;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.PortReservationEntity;\nimport tech.amak.portbuddy.server.db.entity.TunnelStatus;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.db.repo.PortReservationRepository;\nimport tech.amak.portbuddy.server.db.repo.TunnelRepository;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class PortReservationService {\n\n    private static final int MAX_RETRIES = 10;\n\n    private final PortReservationRepository repository;\n    private final ProxyDiscoveryService proxyDiscoveryService;\n    private final TunnelRepository tunnelRepository;\n    private final AppProperties properties;\n\n    @Transactional(readOnly = true)\n    public List<PortReservationEntity> getReservations(final AccountEntity account) {\n        return repository.findAllByAccount(account);\n    }\n\n    /**\n     * Attempts to reserve a unique (publicHost, publicPort) pair for the given account following rules:\n     * - Discover available tcp-proxy public hosts and select the host with the least number of reservations.\n     * - Port assignments are incremental per host within configurable range [min,max].\n     * - If next port for the selected host is out of range, try the next host.\n     * - If no combination can be generated, throw an exception.\n     * Uniqueness is enforced by a DB unique constraint; in case of race conflicts, the operation retries.\n     */\n    @Transactional\n    public Optional<PortReservationEntity> createReservation(final AccountEntity account,\n                                                             final UserEntity user) {\n        final var hosts = proxyDiscoveryService.listPublicHosts();\n        if (hosts.isEmpty()) {\n            log.warn(\"No available tcp-proxy hosts found\");\n            return Optional.empty();\n        }\n\n        final var range = properties.portReservations().range();\n        final int min = range.min();\n        final int max = range.max();\n        if (min <= 0 || max <= 0 || min > max) {\n            throw new IllegalStateException(\"Invalid port range configuration: [\" + min + \", \" + max + \"]\");\n        }\n\n        int attempts = 0;\n        while (attempts++ < MAX_RETRIES) {\n            // Order hosts by least reservations\n            final var orderedHosts = hosts.stream()\n                .sorted(Comparator.comparingLong(repository::countByPublicHost))\n                .toList();\n\n            for (final String host : orderedHosts) {\n                final var nextPort = computeNextPort(host, min, max);\n                if (nextPort == null) {\n                    // This host is exhausted, try next\n                    continue;\n                }\n\n                try {\n                    final var reservation = new PortReservationEntity();\n                    reservation.setId(UUID.randomUUID());\n                    reservation.setAccount(account);\n                    reservation.setUser(user);\n                    reservation.setPublicHost(host);\n                    reservation.setPublicPort(nextPort);\n                    final var saved = repository.save(reservation);\n                    log.info(\"Reserved port {}:{} for account {}\", host, nextPort, account.getId());\n                    return Optional.of(saved);\n                } catch (final DataIntegrityViolationException e) {\n                    // Unique constraint violation possible due to race; retry\n                    log.warn(\"Port reservation conflict for {}:{}, will retry (attempt {}/{})\",\n                        host, nextPort, attempts, MAX_RETRIES);\n                }\n            }\n\n            // If we got here, we either had conflicts on all hosts or all were exhausted; retry loop continues\n        }\n\n        log.warn(\"Failed to reserve a unique port after \" + MAX_RETRIES + \" attempts\");\n        return Optional.empty();\n    }\n\n    private Integer computeNextPort(final String host, final int min, final int max) {\n        // Efficiently find the minimal available port via a single DB query.\n        return repository.findMinimalFreePort(host, min, max).orElse(null);\n    }\n\n    /**\n     * Deletes a reservation associated with the specified account.\n     *\n     * @param id      the unique identifier of the reservation to delete\n     * @param account the account entity associated with the reservation\n     */\n    @Transactional\n    public void deleteReservation(final UUID id, final AccountEntity account) {\n        final var entity = repository.findByIdAndAccount(id, account)\n            .orElseThrow(() -> new RuntimeException(\"Reservation not found\"));\n        if (isReservationInUse(entity)) {\n            throw new IllegalStateException(\"Reservation is in use by active tunnels\");\n        }\n        repository.delete(entity);\n    }\n\n    /**\n     * Resolve a port reservation for a NET (TCP/UDP) expose request according to rules:\n     * - If explicit reservation (host:port or port) provided, ensure it belongs to the account and is not used by\n     * any active tunnel. If multiple reservations found for the same port, take the first one.\n     * - Otherwise, if there was a previous tunnel for the same local resource that used a reservation\n     * and it's free, reuse it.\n     * - Otherwise, pick the first existing reservation of the account that is not in use by any active tunnel.\n     * - If none exist, create a new reservation and return it.\n     */\n    @Transactional\n    public PortReservationEntity resolveForNetExpose(final AccountEntity account,\n                                                     final UserEntity user,\n                                                     final String localHost,\n                                                     final int localPort,\n                                                     final String explicitHostPort) {\n        // 1) Explicit reservation\n        if (explicitHostPort != null && !explicitHostPort.isBlank()) {\n            final var hostPort = explicitHostPort.trim();\n            final int colon = hostPort.lastIndexOf(':');\n            final PortReservationEntity reservation;\n            if (colon > 0 && colon < hostPort.length() - 1) {\n                // public_host:port format\n                final var host = hostPort.substring(0, colon);\n                final var port = Integer.parseInt(hostPort.substring(colon + 1));\n                reservation = repository.findByAccountAndPublicHostAndPublicPort(account, host, port)\n                    .orElseThrow(() ->\n                        new IllegalArgumentException(\"Port reservation not found for this account: \" + hostPort));\n            } else if (colon == -1) {\n                // Try as name first (lookup by account and port reservation name case insensitive)\n                reservation = repository.findByAccountAndNameIgnoreCase(account, hostPort)\n                    .or(() -> {\n                        try {\n                            final var port = Integer.parseInt(hostPort);\n                            final var reservations = repository.findAllByAccountAndPublicPort(account, port);\n                            if (reservations.isEmpty()) {\n                                throw new IllegalArgumentException(\n                                    \"Port reservation not found for this account and port: \" + port);\n                            }\n                            return Optional.of(reservations.getFirst());\n                        } catch (final NumberFormatException e) {\n                            throw new IllegalArgumentException(\n                                \"Port reservation name or port not found for this account: \" + hostPort);\n                        }\n                    })\n                    .orElseThrow(() ->\n                        new IllegalArgumentException(\"Port reservation not found for this account: \" + hostPort));\n\n            } else {\n                throw new IllegalArgumentException(\"Invalid --port-reservation value, expected host:port or port\");\n            }\n\n            if (isReservationInUse(reservation)) {\n                throw new IllegalStateException(\"Port reservation is currently in use: \" + hostPort);\n            }\n            return reservation;\n        }\n\n        // 2) Reuse by same local resource if possible\n        final var prev = tunnelRepository\n            .findFirstByAccountIdAndLocalHostAndLocalPortAndPortReservationIsNotNullOrderByCreatedAtDesc(\n                account.getId(), localHost, localPort);\n        if (prev.isPresent()) {\n            final var res = prev.get().getPortReservation();\n            if (res != null && !isReservationInUse(res)) {\n                return res;\n            }\n        }\n\n        // 3) First available among existing reservations\n        final var existing = repository.findAllByAccount(account).stream()\n            .sorted(Comparator.comparing(PortReservationEntity::getCreatedAt))\n            .filter(res -> !isReservationInUse(res))\n            .findFirst();\n\n        // 4) Create new reservation\n        return existing.orElseGet(() -> createReservation(account, user)\n            .orElseThrow(() -> new IllegalStateException(\"Failed to create a new port reservation\")));\n    }\n\n    private boolean isReservationInUse(final PortReservationEntity reservation) {\n        return tunnelRepository.existsByPortReservationAndStatusNot(reservation, TunnelStatus.CLOSED);\n    }\n\n    /**\n     * Updates an existing reservation host/port ensuring constraints.\n     */\n    @Transactional\n    public PortReservationEntity updateReservation(final AccountEntity account,\n                                                   final UUID id,\n                                                   final String host,\n                                                   final Integer port,\n                                                   final String name) {\n        final var entity = repository.findByIdAndAccount(id, account)\n            .orElseThrow(() -> new RuntimeException(\"Reservation not found\"));\n        if (isReservationInUse(entity)) {\n            throw new IllegalStateException(\"Reservation is in use by active tunnels\");\n        }\n\n        if (host != null) {\n            final var hosts = proxyDiscoveryService.listPublicHosts();\n            if (hosts.isEmpty() || !hosts.contains(host)) {\n                throw new IllegalArgumentException(\"Unknown public host: \" + host);\n            }\n            entity.setPublicHost(host);\n        }\n\n        if (port != null) {\n            final var range = properties.portReservations().range();\n            if (port < range.min() || port > range.max()) {\n                throw new IllegalArgumentException(\"Port is out of allowed range\");\n            }\n            entity.setPublicPort(port);\n        }\n\n        if (name != null && !name.equals(entity.getName())) {\n            if (repository.existsByAccountAndName(account, name)) {\n                throw new IllegalArgumentException(\"Reservation with name '\" + name + \"' already exists\");\n            }\n            entity.setName(name);\n        }\n\n        // Trigger unique check on save\n        return repository.saveAndFlush(entity);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/ProxyDiscoveryService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\nimport org.springframework.cloud.client.ServiceInstance;\nimport org.springframework.cloud.client.discovery.DiscoveryClient;\nimport org.springframework.stereotype.Service;\n\nimport lombok.RequiredArgsConstructor;\n\n@Service\n@RequiredArgsConstructor\npublic class ProxyDiscoveryService {\n\n    public static final String SERVICE_ID = \"net-proxy\";\n\n    private final DiscoveryClient discoveryClient;\n\n    /**\n     * Returns a list of public hosts of all available tcp-proxy instances registered in Eureka.\n     * The public host is resolved from instance metadata keys in the following order:\n     * - \"public-host\"\n     * - \"publicHost\"\n     * - \"app.public-host\"\n     * - \"app.publicHost\"\n     * Falls back to {@link ServiceInstance#getHost()} if none present.\n     */\n    public List<String> listPublicHosts() {\n        final var instances = discoveryClient.getInstances(SERVICE_ID);\n        final Set<String> hosts = new LinkedHashSet<>();\n        for (final ServiceInstance instance : instances) {\n            final var md = instance.getMetadata();\n            String host = null;\n            if (md != null) {\n                host = firstNonBlank(\n                    md.get(\"public-host\"),\n                    md.get(\"publicHost\"),\n                    md.get(\"app.public-host\"),\n                    md.get(\"app.publicHost\")\n                );\n            }\n            if (host == null || host.isBlank()) {\n                host = instance.getHost();\n            }\n            if (host != null && !host.isBlank()) {\n                hosts.add(host);\n            }\n        }\n        return hosts.stream().collect(Collectors.toList());\n    }\n\n    private String firstNonBlank(final String... values) {\n        for (final String v : values) {\n            if (v != null && !v.isBlank()) {\n                return v;\n            }\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/StaleTunnelsReaper.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport java.time.OffsetDateTime;\n\nimport org.springframework.scheduling.annotation.Scheduled;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport net.javacrumbs.shedlock.spring.annotation.SchedulerLock;\nimport tech.amak.portbuddy.server.config.TunnelsProperties;\nimport tech.amak.portbuddy.server.db.repo.TunnelRepository;\nimport tech.amak.portbuddy.server.tunnel.TunnelRegistry;\n\n/**\n * Periodically closes tunnels that stopped sending heartbeats.\n */\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class StaleTunnelsReaper {\n\n    private final TunnelRepository tunnelRepository;\n    private final TunnelsProperties tunnelsProperties;\n    private final TunnelRegistry tunnelRegistry;\n\n    /**\n     * Monitors stale tunnels and closes them.\n     */\n    @Scheduled(\n        fixedDelayString = \"#{@tunnelsProperties.checkInterval.toMillis()}\",\n        initialDelayString = \"#{@tunnelsProperties.checkInterval.toMillis()}\"\n    )\n    @SchedulerLock(name = \"staleTunnelsReaper\", lockAtMostFor = \"PT4M\", lockAtLeastFor = \"PT3S\")\n    @Transactional\n    public void closeStaleTunnels() {\n        final var timeout = tunnelsProperties.getHeartbeatTimeout();\n        final var cutoff = OffsetDateTime.now().minus(timeout);\n        final var closedIds = tunnelRepository.closeStaleConnected(cutoff);\n        if (!closedIds.isEmpty()) {\n            log.info(\"Closed {} stale tunnels (cutoff={})\", closedIds.size(), cutoff);\n            for (final var tunnelId : closedIds) {\n                tunnelRegistry.closeTunnel(tunnelId);\n            }\n        } else {\n            log.debug(\"No stale tunnels found (cutoff={})\", cutoff);\n        }\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/StripeService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport java.util.Map;\n\nimport org.springframework.stereotype.Service;\n\nimport com.stripe.Stripe;\nimport com.stripe.exception.StripeException;\nimport com.stripe.model.Customer;\nimport com.stripe.model.Subscription;\nimport com.stripe.model.checkout.Session;\nimport com.stripe.param.CustomerCreateParams;\nimport com.stripe.param.SubscriptionUpdateParams;\nimport com.stripe.param.billingportal.SessionCreateParams;\nimport com.stripe.param.checkout.SessionCreateParams.LineItem;\nimport com.stripe.param.checkout.SessionCreateParams.Mode;\n\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.Plan;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\n\n@Slf4j\n@Service\npublic class StripeService {\n\n    private final AppProperties properties;\n\n    public StripeService(final AppProperties properties) {\n        this.properties = properties;\n        Stripe.apiKey = properties.stripe().apiKey();\n    }\n\n    /**\n     * Creates a checkout session for the given account and plan.\n     *\n     * @param account       the account\n     * @param plan          the plan\n     * @param extraTunnels  the number of extra tunnels\n     * @return the checkout session URL\n     * @throws StripeException if Stripe API call fails\n     */\n    public String createCheckoutSession(final AccountEntity account, final Plan plan, final int extraTunnels)\n        throws StripeException {\n        log.info(\"Creating checkout session for account: {}, plan: {}, extraTunnels: {}\",\n            account.getId(), plan, extraTunnels);\n\n        if (account.getStripeSubscriptionId() != null) {\n            log.info(\"Account {} already has an active subscription: {}. It will be replaced.\",\n                account.getId(), account.getStripeSubscriptionId());\n        }\n\n        final var customerId = getOrCreateCustomer(account);\n        final var stripeProperties = properties.stripe();\n\n        final var priceId = switch (plan) {\n            case PRO -> stripeProperties.priceIds().pro();\n            case TEAM -> stripeProperties.priceIds().team();\n        };\n\n        final var paramsBuilder = com.stripe.param.checkout.SessionCreateParams.builder()\n            .setCustomer(customerId)\n            .setMode(Mode.SUBSCRIPTION)\n            .setSuccessUrl(properties.gateway().url() + \"/app/billing?success=true\")\n            .setCancelUrl(properties.gateway().url() + \"/app/billing?canceled=true\")\n            .addLineItem(LineItem.builder()\n                .setPrice(priceId)\n                .setQuantity(1L)\n                .build())\n            .putMetadata(\"accountId\", account.getId().toString())\n            .putMetadata(\"plan\", plan.name())\n            .putMetadata(\"extraTunnels\", String.valueOf(extraTunnels));\n\n        if (account.getStripeSubscriptionId() != null) {\n            paramsBuilder.putMetadata(\"oldSubscriptionId\", account.getStripeSubscriptionId());\n        }\n\n        // Include extra tunnels in the checkout session if requested\n        if (extraTunnels > 0) {\n            paramsBuilder.addLineItem(LineItem.builder()\n                .setPrice(stripeProperties.priceIds().extraTunnel())\n                .setQuantity((long) extraTunnels)\n                .build());\n        }\n\n        final var session = Session.create(paramsBuilder.build());\n        log.info(\"Created checkout session: id={}, url={}\", session.getId(), session.getUrl());\n        return session.getUrl();\n    }\n\n    /**\n     * Creates a checkout session for the given account and plan using current account's extra tunnels.\n     *\n     * @param account the account\n     * @param plan    the plan\n     * @return the checkout session URL\n     * @throws StripeException if Stripe API call fails\n     */\n    public String createCheckoutSession(final AccountEntity account, final Plan plan) throws StripeException {\n        return createCheckoutSession(account, plan, account.getExtraTunnels());\n    }\n\n    /**\n     * Cancels the given subscription in Stripe.\n     *\n     * @param subscriptionId the subscription ID\n     * @throws StripeException if Stripe API call fails\n     */\n    public void cancelSubscription(final String subscriptionId) throws StripeException {\n        if (subscriptionId == null) {\n            return;\n        }\n        log.info(\"Cancelling Stripe subscription: {}\", subscriptionId);\n        final var subscription = Subscription.retrieve(subscriptionId);\n        subscription.cancel();\n    }\n\n    /**\n     * Cancels the given subscription in Stripe and resets extra tunnels.\n     *\n     * @param account the account\n     * @throws StripeException if Stripe API call fails\n     */\n    public void cancelSubscription(final AccountEntity account) throws StripeException {\n        cancelSubscription(account.getStripeSubscriptionId());\n    }\n\n    /**\n     * Creates a billing portal session for the given account.\n     *\n     * @param account the account\n     * @return the billing portal session URL\n     * @throws StripeException if Stripe API call fails\n     */\n    public String createPortalSession(final AccountEntity account) throws StripeException {\n        log.info(\"Creating billing portal session for account: {}, customer: {}\", account.getId(),\n            account.getStripeCustomerId());\n        final var params = SessionCreateParams.builder()\n            .setCustomer(account.getStripeCustomerId())\n            .setReturnUrl(properties.gateway().url() + \"/app/billing\")\n            .build();\n\n        final var session = com.stripe.model.billingportal.Session.create(params);\n        log.info(\"Created billing portal session: id={}, url={}\", session.getId(), session.getUrl());\n        return session.getUrl();\n    }\n\n    /**\n     * Updates the number of extra tunnels for the given account.\n     *\n     * @param account  the account\n     * @param newCount the new count of extra tunnels\n     * @throws StripeException if Stripe API call fails\n     */\n    public void updateExtraTunnels(final AccountEntity account, final int newCount) throws StripeException {\n        log.info(\"Updating extra tunnels for account: {}, newCount: {}\", account.getId(), newCount);\n        if (account.getStripeSubscriptionId() == null) {\n            log.warn(\"Account {} has no Stripe subscription, cannot update tunnels in Stripe\", account.getId());\n            return;\n        }\n\n        final var subscription = Subscription.retrieve(account.getStripeSubscriptionId());\n        final var extraTunnelPriceId = properties.stripe().priceIds().extraTunnel();\n        final var subscriptionItemId = subscription.getItems().getData().stream()\n            .filter(item -> item.getPrice().getId().equals(extraTunnelPriceId))\n            .map(com.stripe.model.SubscriptionItem::getId)\n            .findFirst()\n            .orElse(null);\n\n        final var paramsBuilder = SubscriptionUpdateParams.builder()\n            .setProrationBehavior(SubscriptionUpdateParams.ProrationBehavior.ALWAYS_INVOICE);\n\n        if (subscriptionItemId != null) {\n            if (newCount == 0) {\n                log.info(\"Removing extra tunnels item from subscription: {}\", account.getStripeSubscriptionId());\n                // Remove the extra tunnels item\n                paramsBuilder.addItem(SubscriptionUpdateParams.Item.builder()\n                    .setId(subscriptionItemId)\n                    .setDeleted(true)\n                    .build());\n            } else {\n                log.info(\"Updating extra tunnels quantity to {} for subscription: {}\", newCount,\n                    account.getStripeSubscriptionId());\n                // Update quantity\n                paramsBuilder.addItem(SubscriptionUpdateParams.Item.builder()\n                    .setId(subscriptionItemId)\n                    .setQuantity((long) newCount)\n                    .build());\n            }\n        } else if (newCount > 0) {\n            log.info(\"Adding extra tunnels item (quantity: {}) to subscription: {}\", newCount,\n                account.getStripeSubscriptionId());\n            // Add extra tunnels item\n            paramsBuilder.addItem(SubscriptionUpdateParams.Item.builder()\n                .setPrice(extraTunnelPriceId)\n                .setQuantity((long) newCount)\n                .build());\n        } else {\n            log.debug(\"No changes needed for extra tunnels on subscription: {}\", account.getStripeSubscriptionId());\n            return;\n        }\n\n        subscription.update(paramsBuilder.build());\n        log.info(\"Successfully updated Stripe subscription for account: {}\", account.getId());\n    }\n\n    private String getOrCreateCustomer(final AccountEntity account) throws StripeException {\n        if (account.getStripeCustomerId() != null) {\n            log.debug(\"Using existing Stripe customer {} for account {}\", account.getStripeCustomerId(),\n                account.getId());\n            return account.getStripeCustomerId();\n        }\n\n        log.info(\"Creating new Stripe customer for account: {}\", account.getId());\n        final var params = CustomerCreateParams.builder()\n            .setName(account.getName())\n            .setMetadata(Map.of(\"accountId\", account.getId().toString()))\n            .build();\n\n        final var customer = Customer.create(params);\n        log.info(\"Created Stripe customer: id={}\", customer.getId());\n        return customer.getId();\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/StripeWebhookService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport org.springframework.stereotype.Service;\n\nimport com.stripe.exception.SignatureVerificationException;\nimport com.stripe.model.Event;\nimport com.stripe.net.Webhook;\n\n/**\n * Service for Stripe webhooks.\n */\n@Service\npublic class StripeWebhookService {\n    /**\n     * Constructs a Stripe event from the payload and signature.\n     *\n     * @param payload   the payload\n     * @param sigHeader the signature header\n     * @param secret    the webhook secret\n     * @return the event\n     * @throws SignatureVerificationException if signature is invalid\n     */\n    public Event constructEvent(final String payload, final String sigHeader, final String secret)\n        throws SignatureVerificationException {\n        return Webhook.constructEvent(payload, sigHeader, secret);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/TeamService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport java.time.OffsetDateTime;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\n\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.Plan;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.InvitationEntity;\nimport tech.amak.portbuddy.server.db.entity.Role;\nimport tech.amak.portbuddy.server.db.entity.UserAccountEntity;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.db.repo.InvitationRepository;\nimport tech.amak.portbuddy.server.db.repo.UserAccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.mail.EmailService;\nimport tech.amak.portbuddy.server.service.user.UserProvisioningService.ProvisionedUser;\n\n/**\n * Service for managing team members and invitations.\n */\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class TeamService {\n\n    private final InvitationRepository invitationRepository;\n    private final UserRepository userRepository;\n    private final UserAccountRepository userAccountRepository;\n    private final EmailService emailService;\n    private final AppProperties properties;\n\n    /**\n     * Returns all members of the team.\n     *\n     * @param account the account to get members for.\n     * @return list of team members.\n     */\n    public List<UserEntity> getMembers(final AccountEntity account) {\n        return userRepository.findAllByAccount(account);\n    }\n\n    /**\n     * Returns all pending invitations for the team.\n     *\n     * @param account the account to get invitations for.\n     * @return list of pending invitations.\n     */\n    public List<InvitationEntity> getPendingInvitations(final AccountEntity account) {\n        return invitationRepository.findAllByAccountAndAcceptedAtIsNull(account);\n    }\n\n    /**\n     * Invites a new member to the team.\n     *\n     * @param account   the account to invite to.\n     * @param invitedBy the user who is sending the invitation.\n     * @param email     the email of the invited person.\n     * @return the created invitation.\n     */\n    @Transactional\n    public InvitationEntity inviteMember(final AccountEntity account,\n                                         final UserEntity invitedBy,\n                                         final String email) {\n        if (account.getPlan() != Plan.TEAM) {\n            throw new IllegalStateException(\"Invitations are only available for Team plan.\");\n        }\n\n        userRepository.findByEmailIgnoreCase(email).ifPresent(user -> {\n            if (userAccountRepository.findByUserIdAndAccountId(user.getId(), account.getId()).isPresent()) {\n                throw new IllegalStateException(\"User is already a member of this team.\");\n            }\n        });\n\n        invitationRepository.findByAccountAndEmailAndAcceptedAtIsNull(account, email).ifPresent(inv -> {\n            throw new IllegalStateException(\"An invitation has already been sent to this email.\");\n        });\n\n        final var invitation = new InvitationEntity();\n        invitation.setId(UUID.randomUUID());\n        invitation.setAccount(account);\n        invitation.setEmail(email.toLowerCase());\n        invitation.setInvitedBy(invitedBy);\n        invitation.setToken(UUID.randomUUID().toString());\n        invitation.setExpiresAt(OffsetDateTime.now().plusDays(7));\n\n        final var saved = invitationRepository.save(invitation);\n\n        sendInvitationEmail(saved);\n\n        return saved;\n    }\n\n    /**\n     * Removes a member from the team.\n     *\n     * @param account     the account to remove from.\n     * @param userId      the user to remove.\n     * @param currentUser the user who is performing the removal.\n     */\n    @Transactional\n    public void removeMember(final AccountEntity account, final UUID userId, final UserEntity currentUser) {\n        if (currentUser.getId().equals(userId)) {\n            throw new IllegalArgumentException(\"You cannot remove yourself from the account.\");\n        }\n\n        final var userAccount = userAccountRepository.findByUserIdAndAccountId(userId, account.getId())\n            .orElseThrow(() -> new IllegalArgumentException(\"User does not belong to this account.\"));\n\n        userAccountRepository.delete(userAccount);\n    }\n\n    /**\n     * Cancels a pending invitation.\n     *\n     * @param account      the account.\n     * @param invitationId the invitation id.\n     */\n    @Transactional\n    public void cancelInvitation(final AccountEntity account, final UUID invitationId) {\n        final var invitation = invitationRepository.findById(invitationId)\n            .orElseThrow(() -> new IllegalArgumentException(\"Invitation not found.\"));\n\n        if (!invitation.getAccount().getId().equals(account.getId())) {\n            throw new IllegalArgumentException(\"Invitation does not belong to this account.\");\n        }\n\n        invitationRepository.delete(invitation);\n    }\n\n    /**\n     * Accepts an invitation.\n     *\n     * @param token the invitation token.\n     * @param user  the user who is accepting the invitation.\n     */\n    @Transactional\n    public void acceptInvitation(final String token, final UserEntity user) {\n        final var invitation = invitationRepository.findByToken(token)\n            .orElseThrow(() -> new IllegalArgumentException(\"Invalid or expired invitation token.\"));\n\n        if (invitation.getAcceptedAt() != null) {\n            throw new IllegalStateException(\"Invitation has already been accepted.\");\n        }\n\n        if (invitation.getExpiresAt().isBefore(OffsetDateTime.now())) {\n            throw new IllegalStateException(\"Invitation has expired.\");\n        }\n\n        // Add user to the account\n        final var userAccountId = invitation.getAccount().getId();\n        final var userAccount = userAccountRepository.findByUserIdAndAccountId(user.getId(), userAccountId)\n            .orElseGet(() -> new UserAccountEntity(user, invitation.getAccount(), new HashSet<>(List.of(Role.USER))));\n\n        userAccount.setLastUsedAt(OffsetDateTime.now());\n        userAccountRepository.save(userAccount);\n\n        invitation.setAcceptedAt(OffsetDateTime.now());\n        invitationRepository.save(invitation);\n\n        log.info(\"User {} accepted invitation to account {}\", user.getId(), invitation.getAccount().getId());\n    }\n\n    /**\n     * Switches the current account for the user.\n     *\n     * @param userId    the user id.\n     * @param accountId the account id to switch to.\n     * @return provisioned user information with the new account.\n     */\n    @Transactional\n    public ProvisionedUser switchAccount(final UUID userId, final UUID accountId) {\n        final var userAccount = userAccountRepository.findByUserIdAndAccountId(userId, accountId)\n            .orElseThrow(() -> new IllegalArgumentException(\"User does not belong to this account.\"));\n\n        userAccount.setLastUsedAt(OffsetDateTime.now());\n        userAccountRepository.save(userAccount);\n\n        return new ProvisionedUser(userId, accountId, userAccount.getAccount().getName(), userAccount.getRoles());\n    }\n\n    /**\n     * Resends a pending invitation.\n     *\n     * @param account      the account.\n     * @param invitationId the invitation id.\n     */\n    @Transactional\n    public void resendInvitation(final AccountEntity account, final UUID invitationId) {\n        final var invitation = invitationRepository.findById(invitationId)\n            .orElseThrow(() -> new IllegalArgumentException(\"Invitation not found.\"));\n\n        if (!invitation.getAccount().getId().equals(account.getId())) {\n            throw new IllegalArgumentException(\"Invitation does not belong to this account.\");\n        }\n\n        if (invitation.getAcceptedAt() != null) {\n            throw new IllegalStateException(\"Invitation has already been accepted.\");\n        }\n\n        invitation.setToken(UUID.randomUUID().toString());\n        invitation.setExpiresAt(OffsetDateTime.now().plusDays(7));\n        final var saved = invitationRepository.save(invitation);\n\n        sendInvitationEmail(saved);\n    }\n\n    private void sendInvitationEmail(final InvitationEntity invitation) {\n        final var model = Map.<String, Object>of(\n            \"inviterName\", invitation.getInvitedBy().getEmail(),\n            \"accountName\", invitation.getAccount().getName(),\n            \"inviteUrl\", properties.gateway().url() + \"/accept-invite?token=\" + invitation.getToken()\n        );\n\n        emailService.sendTemplate(invitation.getEmail(),\n            \"You've been invited to join \" + invitation.getAccount().getName() + \" on Port Buddy\",\n            \"email/team-invite\", model);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/TunnelService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport java.time.OffsetDateTime;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.Plan;\nimport tech.amak.portbuddy.common.TunnelType;\nimport tech.amak.portbuddy.common.dto.ExposeRequest;\nimport tech.amak.portbuddy.server.client.NetProxyClient;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.DomainEntity;\nimport tech.amak.portbuddy.server.db.entity.PortReservationEntity;\nimport tech.amak.portbuddy.server.db.entity.TunnelEntity;\nimport tech.amak.portbuddy.server.db.entity.TunnelStatus;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.TunnelRepository;\nimport tech.amak.portbuddy.server.service.threatfox.ThreatFoxService;\nimport tech.amak.portbuddy.server.tunnel.TunnelRegistry;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class TunnelService {\n\n    public static final List<TunnelStatus> ACTIVE_STATUSES = List.of(TunnelStatus.CONNECTED, TunnelStatus.PENDING);\n\n    private final TunnelRepository tunnelRepository;\n    private final AccountRepository accountRepository;\n    private final AppProperties properties;\n    private final Optional<ThreatFoxService> threatfoxService;\n    private final TunnelRegistry tunnelRegistry;\n    private final NetProxyClient netProxyClient;\n\n    /**\n     * Creates a new HTTP tunnel using the database entity id as the tunnel id.\n     * The record is saved with a 'PENDING' status and returned id is used as the tunnel identifier.\n     *\n     * @param account   The account creating the tunnel.\n     * @param userId    The unique identifier of the user creating the tunnel.\n     * @param apiKeyId  The optional API key identifier associated with the tunnel.\n     * @param request   The HTTP expose request containing details of the local HTTP service (scheme, host, port).\n     * @param publicUrl The public URL where the service will be accessible.\n     * @param domain    The domain entity used for the public HTTP endpoint.\n     * @return the created tunnel id (same as entity id string)\n     */\n    @Transactional\n    public TunnelEntity createHttpTunnel(final AccountEntity account,\n                                         final UUID userId,\n                                         final String apiKeyId,\n                                         final ExposeRequest request,\n                                         final String publicUrl,\n                                         final DomainEntity domain) {\n        checkTunnelLimit(account);\n        return createTunnel(account.getId(), userId, apiKeyId, request, publicUrl, domain);\n    }\n\n    /**\n     * Creates a pending TCP tunnel and returns its tunnel id (entity id).\n     * Public host/port can be set later via {@link #updateTunnelPublicConnection(UUID, String, Integer)}.\n     *\n     * @param account  The account creating the tunnel.\n     * @param userId   The unique identifier of the user creating the tunnel.\n     * @param apiKeyId The optional API key identifier associated with the tunnel.\n     * @param request  The expose request containing details of the local service.\n     * @return the created tunnel id (same as entity id string)\n     */\n    @Transactional\n    public TunnelEntity createNetTunnel(final AccountEntity account,\n                                        final UUID userId,\n                                        final String apiKeyId,\n                                        final ExposeRequest request) {\n        checkTunnelLimit(account);\n        return createTunnel(account.getId(), userId, apiKeyId, request, null, null);\n    }\n\n    private void checkSubscriptionStatus(final AccountEntity account) {\n        if (account.isBlocked()) {\n            throw new IllegalStateException(\"Account is blocked. Please contact support.\");\n        }\n        final var status = account.getSubscriptionStatus();\n        if (status == null) {\n            // Allow Pro plan with 0 extra tunnels without an active subscription record\n            if (account.getPlan() == Plan.PRO && account.getExtraTunnels() == 0) {\n                return;\n            }\n            throw new IllegalStateException(\"No active subscription found. Please check your billing information.\");\n        }\n        if (!\"active\".equals(status)) {\n            throw new IllegalStateException(\n                \"Subscription is not active (current status: %s). Please check your billing information.\"\n                    .formatted(status));\n        }\n    }\n\n    private void checkTunnelLimit(final AccountEntity account) {\n        checkSubscriptionStatus(account);\n\n        final var currentTunnels = tunnelRepository.countByAccountIdAndStatusIn(account.getId(), ACTIVE_STATUSES);\n\n        final int totalLimit = calculateTunnelLimit(account);\n\n        if (currentTunnels >= totalLimit) {\n            throw new IllegalStateException(\n                \"Tunnel limit reached for your plan (%d). Please upgrade or add more tunnels.\".formatted(totalLimit));\n        }\n    }\n\n    /**\n     * Calculates the total tunnel limit for an account (base plan limit + extra tunnels).\n     *\n     * @param account the account entity\n     * @return the total number of allowed tunnels\n     */\n    public int calculateTunnelLimit(final AccountEntity account) {\n        final var plan = account.getPlan();\n\n        final var baseLimit = properties.subscriptions().tunnels().base().get(plan);\n\n        return baseLimit + account.getExtraTunnels();\n    }\n\n    /**\n     * Checks if the account exceeds its tunnel limit and closes excess tunnels if necessary.\n     * Tunnels are closed starting from the ones with no heartbeat or the oldest heartbeat.\n     *\n     * @param account the account entity to check\n     */\n    @Transactional\n    public void enforceTunnelLimit(final AccountEntity account) {\n        final int limit = calculateTunnelLimit(account);\n        closeExcessTunnels(account, limit);\n    }\n\n    /**\n     * Closes all active tunnels for the given account.\n     *\n     * @param account the account entity\n     */\n    @Transactional\n    public void closeAllTunnels(final AccountEntity account) {\n        closeExcessTunnels(account, 0);\n    }\n\n    private void closeExcessTunnels(final AccountEntity account, final int limit) {\n        final List<TunnelEntity> activeTunnels = tunnelRepository\n            .findByAccountIdAndStatusInOrderByLastHeartbeatAtAscCreatedAtAsc(account.getId(), ACTIVE_STATUSES);\n\n        if (activeTunnels.size() > limit) {\n            final int toClose = activeTunnels.size() - limit;\n            log.info(\"Account {} has {} active tunnels (limit={}). Closing {} tunnels.\",\n                account.getId(), activeTunnels.size(), limit, toClose);\n\n            for (int i = 0; i < toClose; i++) {\n                final var tunnel = activeTunnels.get(i);\n                final var tunnelId = tunnel.getId();\n                log.info(\"Closing tunnel: tunnelId={} accountId={} type={}\",\n                    tunnelId, account.getId(), tunnel.getType());\n                try {\n                    if (tunnel.getType() == TunnelType.HTTP) {\n                        tunnelRegistry.closeTunnel(tunnelId);\n                    } else {\n                        netProxyClient.closeTunnel(tunnelId);\n                    }\n                } catch (final Exception e) {\n                    log.warn(\"Failed to close active tunnel {}: {}\", tunnelId, e.toString());\n                }\n                tunnel.setStatus(TunnelStatus.CLOSED);\n                tunnelRepository.save(tunnel);\n            }\n        }\n    }\n\n    private TunnelEntity createTunnel(final UUID accountId,\n                                      final UUID userId,\n                                      final String apiKeyId,\n                                      final ExposeRequest request,\n                                      final String publicUrl,\n                                      final DomainEntity domain) {\n\n        threatfoxService.ifPresent(threatfox ->\n            threatfox.checkThreat(request.host(), request.port()));\n\n        final var tunnel = new TunnelEntity();\n\n        tunnel.setId(UUID.randomUUID());\n        tunnel.setType(request.tunnelType());\n        tunnel.setStatus(TunnelStatus.PENDING);\n        tunnel.setAccountId(accountId);\n        tunnel.setUserId(userId);\n        tunnel.setLocalScheme(request.scheme());\n        tunnel.setLocalHost(request.host());\n        tunnel.setLocalPort(request.port());\n        tunnel.setPublicUrl(publicUrl);\n        tunnel.setDomain(domain);\n\n        if (apiKeyId != null && !apiKeyId.isBlank()) {\n            tunnel.setApiKeyId(UUID.fromString(apiKeyId));\n        }\n\n        // Ensure non-null timestamps for created_at/updated_at to satisfy DB NOT NULL constraints\n        // in case the persistence provider performs an update instead of insert for a new entity.\n        tunnel.setCreatedAt(OffsetDateTime.now());\n        tunnel.setUpdatedAt(OffsetDateTime.now());\n\n        tunnelRepository.save(tunnel);\n\n        log.info(\"Created pending {} tunnel record tunnelId={} accountId={} userId={}\",\n            tunnel.getType(), tunnel.getId(), accountId, userId);\n\n        return tunnel;\n    }\n\n    /**\n     * Updates public host and port for a TCP tunnel identified by tunnelId.\n     *\n     * @param tunnelId   The tunnel id (entity id string)\n     * @param publicHost Public host allocated by TCP proxy\n     * @param publicPort Public port allocated by TCP proxy\n     */\n    @Transactional\n    public void updateTunnelPublicConnection(final UUID tunnelId,\n                                             final String publicHost,\n                                             final Integer publicPort) {\n        findByTunnelId(tunnelId).ifPresent(entity -> {\n            entity.setPublicHost(publicHost);\n            entity.setPublicPort(publicPort);\n            tunnelRepository.save(entity);\n        });\n    }\n\n    /**\n     * Updates the tunnel with selected port reservation and sets public host/port from reservation.\n     */\n    @Transactional\n    public void assignReservation(final UUID tunnelId, final PortReservationEntity reservation) {\n        findByTunnelId(tunnelId).ifPresent(entity -> {\n            entity.setPortReservation(reservation);\n            entity.setPublicHost(reservation.getPublicHost());\n            entity.setPublicPort(reservation.getPublicPort());\n            tunnelRepository.save(entity);\n        });\n    }\n\n    /**\n     * Retrieves a tunnel entity based on the provided tunnel ID.\n     *\n     * @param tunnelId The unique identifier of the tunnel to retrieve.\n     * @return An {@code Optional} containing the {@code TunnelEntity} if found,\n     *     or an empty {@code Optional} if not found.\n     */\n    public Optional<TunnelEntity> findByTunnelId(final UUID tunnelId) {\n        return Optional.ofNullable(tunnelId)\n            .flatMap(tunnelRepository::findById);\n    }\n\n    /**\n     * Sets a temporary passcode hash on the tunnel entity.\n     */\n    @Transactional\n    public void setTempPasscodeHash(final UUID tunnelId, final String hash) {\n        findByTunnelId(tunnelId).ifPresent(entity -> {\n            entity.setTempPasscodeHash(hash);\n            tunnelRepository.save(entity);\n        });\n    }\n\n    /**\n     * Returns the temporary passcode hash for a tunnel, if present.\n     */\n    public Optional<String> getTempPasscodeHash(final UUID tunnelId) {\n        return findByTunnelId(tunnelId).map(TunnelEntity::getTempPasscodeHash);\n    }\n\n    /**\n     * Updates the status of a tunnel to 'CONNECTED' and sets its last heartbeat\n     * timestamp to the current time. This method retrieves the tunnel from the\n     * repository using the provided tunnel ID and applies the updates if the\n     * tunnel is found.\n     *\n     * @param tunnelId The unique identifier of the tunnel to update. If null or\n     *                 the tunnel is not found, no action is taken.\n     */\n    @Transactional\n    public void markConnected(final UUID tunnelId) {\n        findByTunnelId(tunnelId).ifPresent(entity -> {\n            accountRepository.findById(entity.getAccountId())\n                .ifPresent(this::checkSubscriptionStatus);\n\n            entity.setStatus(TunnelStatus.CONNECTED);\n            entity.setLastHeartbeatAt(OffsetDateTime.now());\n            tunnelRepository.save(entity);\n        });\n    }\n\n    /**\n     * Updates the last heartbeat timestamp of a tunnel to the current time. This method\n     * retrieves the tunnel from the repository using the provided tunnel ID and updates\n     * the last heartbeat timestamp if the tunnel is found.\n     *\n     * @param tunnelId The unique identifier of the tunnel whose heartbeat should be updated.\n     *                 If null or the tunnel is not found, no action is taken.\n     */\n    @Transactional\n    public void heartbeat(final UUID tunnelId) {\n        findByTunnelId(tunnelId).ifPresent(entity -> {\n            accountRepository.findById(entity.getAccountId())\n                .ifPresent(this::checkSubscriptionStatus);\n\n            entity.setLastHeartbeatAt(OffsetDateTime.now());\n            tunnelRepository.save(entity);\n        });\n    }\n\n    /**\n     * Updates the status of a tunnel to 'CLOSED'. This method retrieves the tunnel\n     * from the repository using the provided tunnel ID and updates the status if\n     * the tunnel is found.\n     *\n     * @param tunnelId The unique identifier of the tunnel to be marked as closed.\n     *                 If null or the tunnel is not found, no action is taken.\n     */\n    @Transactional\n    public void markClosed(final UUID tunnelId) {\n        findByTunnelId(tunnelId).ifPresent(entity -> {\n            entity.setStatus(TunnelStatus.CLOSED);\n            tunnelRepository.save(entity);\n        });\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/threatfox/ThreatFoxClient.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service.threatfox;\n\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;\nimport org.springframework.cloud.openfeign.FeignClient;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\n\nimport feign.RequestInterceptor;\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.config.ThreatFoxProperties;\n\n@FeignClient(\n    name = \"threatfox-client\",\n    url = \"${threatfox.url}\",\n    configuration = ThreatFoxClient.Configuration.class\n)\n@ConditionalOnProperty(value = \"threatfox.enabled\", havingValue = \"true\")\npublic interface ThreatFoxClient {\n\n    @PostMapping(\"/api/v1/\")\n    ThreatFoxResponse fetchIoc(@RequestBody final ThreatFoxRequest request);\n\n    @RequiredArgsConstructor\n    class Configuration {\n\n        private static final String AUTH_KEY = \"Auth-Key\";\n        private final ThreatFoxProperties properties;\n\n        @Bean\n        public RequestInterceptor authorizationHeaderForwarder() {\n            return template -> template.header(AUTH_KEY, properties.authKey());\n        }\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/threatfox/ThreatFoxIoc.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service.threatfox;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic record ThreatFoxIoc(\n    @JsonProperty(\"id\") String id,\n    @JsonProperty(\"ioc\") String ioc,\n    @JsonProperty(\"threat_type\") String threatType,\n    @JsonProperty(\"ioc_type\") String iocType,\n    @JsonProperty(\"malware\") String malware,\n    @JsonProperty(\"malware_printable\") String malwarePrintable,\n    @JsonProperty(\"confidence_level\") Integer confidenceLevel,\n    @JsonProperty(\"first_seen\") String firstSeen,\n    @JsonProperty(\"reporter\") String reporter\n) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/threatfox/ThreatFoxRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service.threatfox;\n\npublic record ThreatFoxRequest(\n    String query,\n    Integer days\n) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/threatfox/ThreatFoxResponse.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service.threatfox;\n\nimport java.util.List;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic record ThreatFoxResponse(\n    @JsonProperty(\"query_status\") String queryStatus,\n    @JsonProperty(\"data\") List<ThreatFoxIoc> data\n) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/threatfox/ThreatFoxService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service.threatfox;\n\nimport java.util.HashSet;\nimport java.util.Objects;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;\nimport org.springframework.scheduling.annotation.Scheduled;\nimport org.springframework.stereotype.Service;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.server.security.ThreatBlockedException;\n\n@Slf4j\n@Service\n@RequiredArgsConstructor\n@ConditionalOnProperty(name = \"threatfox.enabled\", havingValue = \"true\")\npublic class ThreatFoxService {\n\n    private static final String IOC_TYPE_URL = \"url\";\n    private static final Set<String> IOC_TYPES = Set.of(\"domain\", \"ip:port\", IOC_TYPE_URL);\n    private static final ThreatFoxRequest FETCH_IOC_REQUEST = new ThreatFoxRequest(\"get_iocs\", 7);\n\n    private final ThreatFoxClient client;\n    private volatile Set<String> cache = new HashSet<>();\n\n    /**\n     * Fetches threat intelligence data and processes it to update the internal cache.\n     * This method is scheduled to run periodically, determined by the configuration value\n     * specified in the {@code threatfox.fetch-interval} property. It makes a request to\n     * retrieve Indicators of Compromise (IOCs) from the ThreatFox API and processes the\n     * response to extract and normalize relevant data. The normalized data is stored in a\n     * cache, which can later be used to identify and block potential threats.\n     * If the fetch operation fails for any reason, an error message is logged to assist\n     * in diagnosing the issue.\n     * The scheduling configuration specifies the following:\n     * - {@code fixedDelayString}: Configured interval between method executions in milliseconds.\n     * - {@code initialDelay}: Delay before the first invocation, set to 0.\n     * Logs are generated for both successful and failed fetch operations.\n     *\n     * @throws RuntimeException If an unhandled exception occurs during the fetch or processing.\n     */\n    @Scheduled(\n        fixedDelayString = \"${threatfox.fetch-interval}\",\n        initialDelay = 0\n    )\n    public void fetchData() {\n        try {\n            final var threatFoxResponse = client.fetchIoc(FETCH_IOC_REQUEST);\n            process(threatFoxResponse);\n        } catch (final Exception e) {\n            log.error(\"[Threatfox] Fetch failed: {}\", e.getMessage());\n        }\n    }\n\n    private void process(final ThreatFoxResponse response) {\n        if (response == null || response.data() == null) {\n            log.warn(\"[Threatfox] returned empty response\");\n            return;\n        }\n\n        cache = response.data().stream().parallel()\n            .filter(ioc -> isRelevantType(ioc.iocType()))\n            .map(this::normalize)\n            .filter(Objects::nonNull)\n            .collect(Collectors.toSet());\n\n        log.info(\"[Threatfox] cache updated: {} iocs loaded\", cache.size());\n    }\n\n    private boolean isRelevantType(final String type) {\n        return IOC_TYPES.contains(type);\n    }\n\n    private String normalize(final ThreatFoxIoc ioc) {\n        final var iocValue = Objects.equals(ioc.iocType(), IOC_TYPE_URL)\n            ? extractDomain(ioc.ioc())\n            : ioc.ioc();\n        return normalize(iocValue);\n    }\n\n    private String normalize(final String ioc) {\n        if (ioc == null || ioc.isBlank()) {\n            return null;\n        }\n        return ioc.toLowerCase().trim();\n    }\n\n    private String extractDomain(final String url) {\n        final var doubleSlashIndex = url.indexOf(\"//\");\n        final var start = doubleSlashIndex == -1 ? 0 : doubleSlashIndex + 2;\n        final var lastSlashIndex = url.indexOf(\"/\", start);\n        return url.substring(start, lastSlashIndex == -1 ? url.length() : lastSlashIndex);\n    }\n\n    private boolean isBlacklisted(final String target) {\n        return cache.contains(target);\n    }\n\n    /**\n     * Checks if the provided host and port combination is blacklisted as a potential threat.\n     * <br>\n     * The method first normalizes the host by converting it to lowercase and trimming any\n     * leading or trailing whitespace. It then verifies if the host alone or the combination\n     * of host and port is present in the blacklist cache. If a match is found, a\n     * {@code ThreatBlockedException} is thrown to indicate the presence of a threat.\n     *\n     * @param host The domain or IP address to be checked. Must not be null or empty.\n     * @param port The port number associated with the host to be checked.\n     *             Should be a valid integer port number.\n     * @throws ThreatBlockedException If the host or host-port combination matches an entry in the blacklist.\n     */\n    public void checkThreat(final String host, final int port) {\n\n        final var normalizeHost = normalize(host);\n\n        if (normalizeHost == null) {\n            return;\n        }\n\n        if (isBlacklisted(normalizeHost)) {\n            log.warn(\"[Threatfox] Domain {} matches ioc\", host);\n            throw new ThreatBlockedException(\"Target domain is blacklisted: \" + host);\n        }\n\n        final var hostPort = normalizeHost + \":\" + port;\n\n        if (isBlacklisted(hostPort)) {\n            log.warn(\"[Threatfox] {} matches ioc\", hostPort);\n            throw new ThreatBlockedException(\"Target is blacklisted: \" + hostPort);\n        }\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/user/MissingEmailException.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service.user;\n\n/**\n * Thrown when a user provisioning attempt is made without an email address.\n */\npublic class MissingEmailException extends RuntimeException {\n\n    public MissingEmailException(final String message) {\n        super(message);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/user/PasswordResetService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service.user;\n\nimport java.time.Duration;\nimport java.time.OffsetDateTime;\nimport java.util.HashMap;\nimport java.util.UUID;\n\nimport org.springframework.security.crypto.password.PasswordEncoder;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.PasswordResetTokenEntity;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.db.repo.PasswordResetTokenRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.mail.EmailService;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class PasswordResetService {\n\n    private final UserRepository userRepository;\n    private final PasswordResetTokenRepository tokenRepository;\n    private final EmailService emailService;\n    private final PasswordEncoder passwordEncoder;\n    private final AppProperties properties;\n\n    /**\n     * Initiates the password reset process for the given email.\n     *\n     * @param email the user's email address\n     */\n    @Transactional\n    public void requestReset(final String email) {\n        final var userOpt = userRepository.findByEmailIgnoreCase(email);\n        if (userOpt.isEmpty()) {\n            log.info(\"Password reset requested for non-existent email: {}\", email);\n            // Do not reveal that user does not exist\n            return;\n        }\n\n        final var user = userOpt.get();\n\n        final var resetLink = generateResetPasswordLink(user, Duration.ofHours(1));\n\n        final var model = new HashMap<String, Object>();\n        model.put(\"subject\", \"Reset Your Password\");\n        model.put(\"resetLink\", resetLink);\n        model.put(\"webAppUrl\", properties.gateway().url());\n\n        final var firstName = user.getFirstName();\n        model.put(\"greeting\", firstName == null ? \"Hello!\" : \"Hello \" + firstName + \",\");\n\n        emailService.sendTemplate(user.getEmail(), \"Reset Your Password\", \"email/password-reset\", model);\n        log.info(\"Password reset email sent to user: {}\", user.getId());\n    }\n\n    /**\n     * Generates a reset password link for the given user and token time-to-live (TTL).\n     * This method invalidates any existing reset tokens for the user, creates a new\n     * token, saves it in the database, and returns the corresponding reset link.\n     *\n     * @param user the user entity for whom the reset password link is being generated\n     * @param ttl the duration for which the reset token is valid\n     * @return the complete reset password link containing the generated token\n     */\n    @Transactional\n    public String generateResetPasswordLink(final UserEntity user, final Duration ttl) {\n\n        // Invalidate existing tokens\n        tokenRepository.deleteByUser(user);\n\n        final var token = UUID.randomUUID().toString();\n        final var entity = new PasswordResetTokenEntity();\n        entity.setId(UUID.randomUUID());\n        entity.setToken(token);\n        entity.setUser(user);\n        // Token TTL\n        entity.setExpiryDate(OffsetDateTime.now().plus(ttl));\n        tokenRepository.save(entity);\n\n        return properties.gateway().url() + \"/reset-password?token=\" + token;\n    }\n\n    /**\n     * Validates if the reset token exists and is not expired.\n     *\n     * @param token the reset token\n     * @return true if valid, false otherwise\n     */\n    @Transactional(readOnly = true)\n    public boolean validateToken(final String token) {\n        final var tokenEntityOpt = tokenRepository.findByToken(token);\n        if (tokenEntityOpt.isEmpty()) {\n            return false;\n        }\n        final var tokenEntity = tokenEntityOpt.get();\n        return tokenEntity.getExpiryDate().isAfter(OffsetDateTime.now());\n    }\n\n    /**\n     * Resets the user's password using the valid token.\n     *\n     * @param token       the reset token\n     * @param newPassword the new password\n     */\n    @Transactional\n    public void resetPassword(final String token, final String newPassword) {\n        final var tokenEntity = tokenRepository.findByToken(token)\n            .orElseThrow(() -> new IllegalArgumentException(\"Invalid token\"));\n\n        if (tokenEntity.getExpiryDate().isBefore(OffsetDateTime.now())) {\n            throw new IllegalArgumentException(\"Token expired\");\n        }\n\n        final var user = tokenEntity.getUser();\n        user.setPassword(passwordEncoder.encode(newPassword));\n        userRepository.save(user);\n\n        tokenRepository.delete(tokenEntity);\n\n        // Send confirmation email\n        final var model = new HashMap<String, Object>();\n        model.put(\"subject\", \"Password Changed Successfully\");\n        model.put(\"webAppUrl\", properties.gateway().url() + \"/app\");\n        final var firstName = user.getFirstName();\n        model.put(\"greeting\", firstName == null ? \"Hello!\" : \"Hello \" + firstName + \",\");\n\n        emailService.sendTemplate(user.getEmail(), \"Password Changed Successfully\",\n            \"email/password-reset-success\", model);\n        log.info(\"Password reset successfully for user: {}\", user.getId());\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/service/user/UserProvisioningService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service.user;\n\nimport java.time.Duration;\nimport java.util.HashSet;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\n\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.security.crypto.password.PasswordEncoder;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.Plan;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.Role;\nimport tech.amak.portbuddy.server.db.entity.UserAccountEntity;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserAccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.mail.UserCreatedEvent;\nimport tech.amak.portbuddy.server.service.DomainService;\nimport tech.amak.portbuddy.server.service.PortReservationService;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class UserProvisioningService {\n\n    private final UserRepository userRepository;\n    private final AccountRepository accountRepository;\n    private final UserAccountRepository userAccountRepository;\n    private final PasswordEncoder passwordEncoder;\n    private final DomainService domainService;\n    private final PortReservationService portReservationService;\n    private final ApplicationEventPublisher eventPublisher;\n    private final PasswordResetService passwordResetService;\n\n    public record ProvisionedUser(UUID userId, UUID accountId, String accountName, Set<Role> roles) {\n    }\n\n    /**\n     * Creates a new local user with email and password.\n     */\n    @Transactional\n    public ProvisionedUser createLocalUser(final String email, final String name, final String password) {\n        final var normalizedEmail = normalizeEmail(email);\n        if (normalizedEmail == null) {\n            throw new IllegalArgumentException(\"Email is required\");\n        }\n\n        if (userRepository.findByEmailIgnoreCase(normalizedEmail).isPresent()) {\n            throw new IllegalArgumentException(\"User already exists\");\n        }\n\n        final var userName = Optional.ofNullable(name)\n            .filter(StringUtils::isNotBlank)\n            .orElse(\"Unknown Buddy\");\n\n        // Split name\n        String lastName = null;\n        final var parts = userName.trim().split(\"\\\\s+\", 2);\n        final var firstName = parts[0];\n        if (parts.length > 1) {\n            lastName = parts[1];\n        }\n\n        // Create new account and user\n        final var account = new AccountEntity();\n        account.setId(UUID.randomUUID());\n        account.setName(defaultAccountName(firstName, lastName, normalizedEmail));\n        account.setPlan(Plan.PRO);\n        accountRepository.save(account);\n\n        final var user = new UserEntity();\n        user.setId(UUID.randomUUID());\n        user.setEmail(normalizedEmail);\n        user.setFirstName(firstName);\n        user.setLastName(lastName);\n        user.setAuthProvider(\"local\");\n        user.setExternalId(normalizedEmail);\n        Optional.ofNullable(password)\n            .filter(StringUtils::isNotBlank)\n            .map(passwordEncoder::encode)\n            .ifPresent(user::setPassword);\n        userRepository.save(user);\n\n        final var userAccount = new UserAccountEntity(user, account, determineRoles(true));\n        userAccountRepository.save(userAccount);\n\n        // Try to create an initial port reservation and domain for the account by this user\n        try {\n            domainService.assignRandomDomain(account);\n            portReservationService.createReservation(account, user);\n        } catch (final Exception ex) {\n            log.error(\"Failed to create initial reservations for account {}: {}\", account.getId(), ex.getMessage(), ex);\n        }\n\n        String resetPasswordLink = null;\n\n        if (StringUtils.isBlank(password)) {\n            resetPasswordLink = passwordResetService.generateResetPasswordLink(user, Duration.ofDays(30));\n        }\n\n        // Publish event after user persisted; listener will send email AFTER_COMMIT\n        eventPublisher.publishEvent(new UserCreatedEvent(\n            user.getId(), account.getId(), user.getEmail(), user.getFirstName(), user.getLastName(), resetPasswordLink\n        ));\n\n        return new ProvisionedUser(user.getId(), account.getId(), account.getName(), userAccount.getRoles());\n    }\n\n    /**\n     * Ensures a user and an owning account exist for the given identity. If the user does not exist,\n     * a new account (default plan PRO) and user are created. If the user exists, profile fields are updated.\n     *\n     * @param provider   the OAuth2 provider registration id (e.g. google, github)\n     * @param externalId the unique external identifier from the provider (subject/id)\n     * @param email      the email address, can be null\n     * @param firstName  optional first name\n     * @param lastName   optional last name\n     * @param avatarUrl  optional avatar URL\n     * @return identifiers of provisioned user and owning account\n     */\n    @Transactional\n    public ProvisionedUser provision(final String provider,\n                                     final String externalId,\n                                     final String email,\n                                     final String firstName,\n                                     final String lastName,\n                                     final String avatarUrl) {\n        final var normalizedEmail = normalizeEmail(email);\n\n        // If user already exists by provider/externalId, allow sign-in even when current OAuth response\n        // does not include email (keep stored email). Update profile fields and return.\n        final var existing = userRepository.findByAuthProviderAndExternalId(provider, externalId);\n        if (existing.isPresent()) {\n            final var user = existing.get();\n            boolean changed = false;\n            if (normalizedEmail != null && !Objects.equals(user.getEmail(), normalizedEmail)) {\n                user.setEmail(normalizedEmail);\n                changed = true;\n            }\n            if (!Objects.equals(user.getFirstName(), firstName)) {\n                user.setFirstName(firstName);\n                changed = true;\n            }\n            if (!Objects.equals(user.getLastName(), lastName)) {\n                user.setLastName(lastName);\n                changed = true;\n            }\n            if (!Objects.equals(user.getAvatarUrl(), avatarUrl)) {\n                user.setAvatarUrl(avatarUrl);\n                changed = true;\n            }\n            if (changed) {\n                userRepository.save(user);\n            }\n            final var userAccount = userAccountRepository.findLatestUsedByUserId(user.getId())\n                .orElseThrow(() -> new IllegalStateException(\"User has no accounts\"));\n            final var account = userAccount.getAccount();\n\n            return new ProvisionedUser(user.getId(), account.getId(), account.getName(), userAccount.getRoles());\n        }\n\n        // For new identities we must have a non-null email to create/merge a user\n        if (normalizedEmail == null) {\n            throw new MissingEmailException(\"Email is required to provision a user\");\n        }\n\n        // No user for this provider/external identity. Try to locate an existing user by email\n        if (normalizedEmail != null) {\n            final var userByEmail = userRepository.findByEmailIgnoreCase(normalizedEmail);\n            if (userByEmail.isPresent()) {\n                final var user = userByEmail.get();\n                boolean changed = false;\n                // Ensure email normalized\n                if (!Objects.equals(user.getEmail(), normalizedEmail)) {\n                    user.setEmail(normalizedEmail);\n                    changed = true;\n                }\n                if (!Objects.equals(user.getFirstName(), firstName)) {\n                    user.setFirstName(firstName);\n                    changed = true;\n                }\n                if (!Objects.equals(user.getLastName(), lastName)) {\n                    user.setLastName(lastName);\n                    changed = true;\n                }\n                if (!Objects.equals(user.getAvatarUrl(), avatarUrl)) {\n                    user.setAvatarUrl(avatarUrl);\n                    changed = true;\n                }\n                // Re-link identity to this provider/external to allow future lookups by provider\n                if (!Objects.equals(user.getAuthProvider(), provider)) {\n                    user.setAuthProvider(provider);\n                    changed = true;\n                }\n                if (!Objects.equals(user.getExternalId(), externalId)) {\n                    user.setExternalId(externalId);\n                    changed = true;\n                }\n                if (changed) {\n                    userRepository.save(user);\n                }\n                final var userAccount = userAccountRepository.findLatestUsedByUserId(user.getId())\n                    .orElseThrow(() -> new IllegalStateException(\"User has no accounts\"));\n                final var account = userAccount.getAccount();\n\n                return new ProvisionedUser(user.getId(), account.getId(), account.getName(), userAccount.getRoles());\n            }\n        }\n\n        // Create new account and user\n        final var account = new AccountEntity();\n        account.setId(UUID.randomUUID());\n        account.setName(defaultAccountName(firstName, lastName, email));\n        account.setPlan(Plan.PRO);\n        accountRepository.save(account);\n\n        final var user = new UserEntity();\n        user.setId(UUID.randomUUID());\n        user.setEmail(normalizedEmail);\n        user.setFirstName(firstName);\n        user.setLastName(lastName);\n        user.setAuthProvider(provider);\n        user.setExternalId(externalId);\n        user.setAvatarUrl(avatarUrl);\n        userRepository.save(user);\n\n        final var userAccount = new UserAccountEntity(user, account, determineRoles(true));\n        userAccountRepository.save(userAccount);\n\n        // Try to create an initial port reservation and domain for the account by this user\n        try {\n            domainService.assignRandomDomain(account);\n            portReservationService.createReservation(account, user);\n        } catch (final Exception ignored) {\n            // per spec: ignore if no host/port available during account creation\n        }\n\n        // Publish event for brand new user\n        eventPublisher.publishEvent(new UserCreatedEvent(\n            user.getId(), account.getId(), user.getEmail(), user.getFirstName(), user.getLastName(), null\n        ));\n\n        return new ProvisionedUser(user.getId(), account.getId(), account.getName(), userAccount.getRoles());\n    }\n\n    private static String defaultAccountName(final String firstName, final String lastName, final String email) {\n        final var fullName = \"%s %s\".formatted(firstName, lastName).trim();\n        if (!fullName.isEmpty()) {\n            return fullName + \"’s account\";\n        }\n        if (email != null && !email.isBlank()) {\n            final var at = email.indexOf('@');\n            final var local = at > 0 ? email.substring(0, at) : email;\n            return local + \"’s account\";\n        }\n        return \"New account\";\n    }\n\n    private static String normalizeEmail(final String email) {\n        if (email == null) {\n            return null;\n        }\n        final var trimmed = email.trim();\n        return trimmed.isEmpty() ? null : trimmed.toLowerCase();\n    }\n\n    private Set<Role> determineRoles(final boolean isAccountOwner) {\n        final var roles = new HashSet<Role>();\n        if (userRepository.count() == 0) {\n            roles.add(Role.ADMIN);\n        }\n        if (isAccountOwner) {\n            roles.add(Role.ACCOUNT_ADMIN);\n        } else {\n            roles.add(Role.USER);\n        }\n        return roles;\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/tunnel/PermissiveSubprotocolHandshakeHandler.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.tunnel;\n\nimport java.util.List;\n\nimport org.springframework.web.socket.WebSocketHandler;\nimport org.springframework.web.socket.server.support.DefaultHandshakeHandler;\n\n/**\n * A permissive WebSocket HandshakeHandler that negotiates the subprotocol by simply\n * echoing back the first protocol requested by the client, if any. This is helpful\n * for clients (e.g., Vaadin) that expect the selected subprotocol to match their\n * request without the server advertising a fixed list.\n */\npublic class PermissiveSubprotocolHandshakeHandler extends DefaultHandshakeHandler {\n\n    @Override\n    protected String selectProtocol(final List<String> requestedProtocols, final WebSocketHandler webSocketHandler) {\n        return requestedProtocols.stream().findFirst().orElse(null);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/tunnel/PublicWebSocketProxyHandler.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.tunnel;\n\nimport java.util.Base64;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.UUID;\n\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.socket.BinaryMessage;\nimport org.springframework.web.socket.CloseStatus;\nimport org.springframework.web.socket.TextMessage;\nimport org.springframework.web.socket.WebSocketSession;\nimport org.springframework.web.socket.handler.AbstractWebSocketHandler;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.tunnel.WsTunnelMessage;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.DomainRepository;\n\n/**\n * Accepts public WebSocket connections from browsers for tunneled subdomains and bridges them\n * over the control WebSocket to the CLI client.\n */\n@Slf4j\n@Component\n@RequiredArgsConstructor\npublic class PublicWebSocketProxyHandler extends AbstractWebSocketHandler {\n\n    private final TunnelRegistry registry;\n    private final AppProperties properties;\n    private final DomainRepository domainRepository;\n    private final AccountRepository accountRepository;\n\n    @Override\n    public void afterConnectionEstablished(final WebSocketSession browserSession) throws Exception {\n        final var subdomain = extractSubdomain(browserSession);\n        if (subdomain == null) {\n            log.debug(\"WS: missing/invalid host header\");\n            browserSession.close(CloseStatus.POLICY_VIOLATION);\n            return;\n        }\n\n        var tunnel = registry.getBySubdomain(subdomain);\n        if (tunnel == null) {\n            // It might be a custom domain, try to resolve it to a subdomain\n            final var domainOpt = domainRepository.findByCustomDomain(subdomain);\n            if (domainOpt.isPresent()) {\n                final var resolvedSubdomain = domainOpt.get().getSubdomain();\n                tunnel = registry.getBySubdomain(resolvedSubdomain);\n            }\n        }\n\n        if (tunnel == null || !tunnel.isOpen()) {\n            browserSession.close(CloseStatus.SERVICE_RESTARTED);\n            return;\n        }\n\n        // Check subscription status\n        final var accountOpt = accountRepository.findById(tunnel.accountId());\n        if (accountOpt.isPresent()) {\n            final var status = accountOpt.get().getSubscriptionStatus();\n            if (status != null && !\"active\".equals(status)) {\n                log.warn(\"Blocked WS request to subdomain {} because subscription is not active (status: {})\",\n                    subdomain, status);\n                browserSession.close(CloseStatus.POLICY_VIOLATION.withReason(\"Subscription inactive\"));\n                return;\n            }\n        }\n\n        final var connectionId = UUID.randomUUID().toString();\n        registry.registerBrowserWs(tunnel.tunnelId(), connectionId, browserSession);\n\n        final var uri = browserSession.getUri();\n        final var message = new WsTunnelMessage();\n        message.setConnectionId(connectionId);\n        message.setWsType(WsTunnelMessage.Type.OPEN);\n        if (uri != null) {\n            final var normalized = normalizePublicPath(subdomain, uri.getPath());\n            message.setPath(normalized);\n            message.setQuery(uri.getQuery());\n        }\n\n        // Forward essential handshake headers (cookies, origin, vaadin-specific, etc.)\n        final var forwarded = collectForwardedHandshakeHeaders(browserSession);\n        if (!forwarded.isEmpty()) {\n            message.setHeaders(forwarded);\n        }\n\n        registry.sendWsToClient(tunnel.tunnelId(), message);\n    }\n\n    @Override\n    protected void handleTextMessage(final WebSocketSession session, final TextMessage message) {\n        final var ids = registry.findIdsByBrowserSession(session);\n        if (ids == null) {\n            return;\n        }\n        final var websocketMessage = new WsTunnelMessage();\n        websocketMessage.setConnectionId(ids.getConnectionId());\n        websocketMessage.setWsType(WsTunnelMessage.Type.TEXT);\n        websocketMessage.setText(message.getPayload());\n        registry.sendWsToClient(ids.getTunnelId(), websocketMessage);\n    }\n\n    @Override\n    protected void handleBinaryMessage(final WebSocketSession session, final BinaryMessage message) {\n        final var ids = registry.findIdsByBrowserSession(session);\n        if (ids == null) {\n            return;\n        }\n        final var websocketMessage = new WsTunnelMessage();\n        websocketMessage.setConnectionId(ids.getConnectionId());\n        websocketMessage.setWsType(WsTunnelMessage.Type.BINARY);\n        websocketMessage.setDataB64(Base64.getEncoder().encodeToString(message.getPayload().array()));\n        registry.sendWsToClient(ids.getTunnelId(), websocketMessage);\n    }\n\n    @Override\n    public void afterConnectionClosed(final WebSocketSession session, final CloseStatus status) {\n        final var ids = registry.unregisterBrowserWs(session);\n        if (ids == null) {\n            return;\n        }\n        final var websocketMessage = new WsTunnelMessage();\n        websocketMessage.setConnectionId(ids.getConnectionId());\n        websocketMessage.setWsType(WsTunnelMessage.Type.CLOSE);\n        websocketMessage.setCloseCode(status.getCode());\n        websocketMessage.setCloseReason(status.getReason());\n        registry.sendWsToClient(ids.getTunnelId(), websocketMessage);\n    }\n\n    private String extractSubdomain(final WebSocketSession session) {\n        // Prefer X-Forwarded-Host because requests are routed via Spring Cloud Gateway,\n        // which by default does not preserve the original Host header to the upstream.\n        var host = firstNonBlank(\n            session.getHandshakeHeaders().getFirst(\"X-Forwarded-Host\"),\n            session.getHandshakeHeaders().getFirst(HttpHeaders.HOST)\n        );\n\n        if (host != null) {\n            // X-Forwarded-Host may contain a comma-separated list — take the first\n            final var commaIdx = host.indexOf(',');\n            if (commaIdx > 0) {\n                host = host.substring(0, commaIdx).trim();\n            }\n\n            // Strip port if present\n            final var colonIdx = host.indexOf(':');\n            if (colonIdx > 0) {\n                host = host.substring(0, colonIdx);\n            }\n\n            if (host.endsWith(properties.gateway().subdomainHost())) {\n                final var idx = host.indexOf('.');\n                if (idx > 0) {\n                    return host.substring(0, idx);\n                }\n            } else {\n                // Check if it's a custom domain\n                final var domainOpt = domainRepository.findByCustomDomain(host.toLowerCase());\n                if (domainOpt.isPresent()) {\n                    return domainOpt.get().getSubdomain();\n                }\n            }\n        }\n\n        // Fallback: the gateway rewrites path to /_/{subdomain}/... for HTTP\n        // and to /_ws/{subdomain}/... for WebSocket handshakes.\n        // Try to extract subdomain from the request path if headers are unavailable/unexpected.\n        final var uri = session.getUri();\n        if (uri != null) {\n            final var path = uri.getPath();\n            if (path != null) {\n                String prefix = null;\n                if (path.startsWith(\"/_ws/\")) {\n                    prefix = \"/_ws/\";\n                } else if (path.startsWith(\"/_/\")) {\n                    prefix = \"/_/\";\n                }\n                if (prefix != null) {\n                    final var rest = path.substring(prefix.length());\n                    final var slash = rest.indexOf('/');\n                    if (slash > 0) {\n                        return rest.substring(0, slash);\n                    }\n                    if (!rest.isBlank()) {\n                        return rest; // path was exactly /_ws/{subdomain} or /_/{subdomain}\n                    }\n                }\n            }\n        }\n        return null;\n    }\n\n    private static String firstNonBlank(final String a, final String b) {\n        if (a != null && !a.isBlank()) {\n            return a;\n        }\n        return (b != null && !b.isBlank()) ? b : null;\n    }\n\n    /**\n     * Normalize the path coming from the gateway so the client connects to the local\n     * application using its original path. The gateway rewrites incoming public requests\n     * to \"/_ws/{subdomain}/...\" for WebSocket (and \"/_/{subdomain}/...\" for HTTP).\n     * We must strip that internal prefix before forwarding the OPEN to the CLI,\n     * otherwise the CLI will try to open a WS to a non-existent path like\n     * \"/_ws/{subdomain}/...\" on the user's local app causing HTTP 200 instead of 101.\n     */\n    private static String normalizePublicPath(final String subdomain, final String rawPath) {\n        if (rawPath == null || rawPath.isBlank()) {\n            return \"/\";\n        }\n        var path = rawPath;\n        final var wsPrefix = \"/_ws/\" + subdomain;\n        final var httpPrefix = \"/_/\" + subdomain;\n        if (path.startsWith(wsPrefix)) {\n            path = path.substring(wsPrefix.length());\n        } else if (path.startsWith(httpPrefix)) { // fallback safety\n            path = path.substring(httpPrefix.length());\n        }\n        if (path.isBlank()) {\n            return \"/\";\n        }\n        if (!path.startsWith(\"/\")) {\n            path = \"/\" + path;\n        }\n        return path;\n    }\n\n    /**\n     * Build a safe subset of headers from the browser's WS handshake that should be forwarded\n     * to the local application when establishing the tunneled WS connection. This helps\n     * frameworks like Vaadin associate the WS with the existing HTTP session (via cookies)\n     * and preserve security context.\n     */\n    private Map<String, String> collectForwardedHandshakeHeaders(final WebSocketSession browserSession) {\n        final var result = new HashMap<String, String>();\n\n        final var headers = browserSession.getHandshakeHeaders();\n\n        // Explicit allow-list (case-insensitive)\n        final var allowedExact = Set.of(\n            \"cookie\",\n            \"origin\",\n            \"authorization\",\n            \"referer\",\n            \"x-requested-with\",\n            \"x-csrf-token\",\n            // Keep subprotocol if requested by the browser (e.g., Vaadin)\n            \"sec-websocket-protocol\"\n        );\n\n        // Explicit deny-list of hop-by-hop and WS negotiation headers we must not forward\n        final var forbidden = Set.of(\n            HttpHeaders.HOST.toLowerCase(),\n            HttpHeaders.CONNECTION.toLowerCase(),\n            HttpHeaders.UPGRADE.toLowerCase(),\n            \"sec-websocket-key\",\n            \"sec-websocket-version\",\n            \"sec-websocket-extensions\",\n            \"sec-websocket-accept\"\n        );\n\n        for (final var name : headers.keySet()) {\n            if (name == null) {\n                continue;\n            }\n            final var nlc = name.toLowerCase();\n            if (forbidden.contains(nlc)) {\n                continue;\n            }\n            final var isAllowedExact = allowedExact.contains(nlc);\n            final var isVaadinSpecific = nlc.startsWith(\"x-vaadin-\") || nlc.startsWith(\"vaadin-\");\n            if (isAllowedExact || isVaadinSpecific) {\n                final var value = headers.getFirst(name);\n                if (value != null && !value.isBlank()) {\n                    result.put(name, value);\n                }\n            }\n        }\n\n        // Add/normalize forwarding context headers\n        // X-Forwarded-Host: take it from handshake if present, otherwise use Host\n        var xfHost = firstNonBlank(headers.getFirst(\"X-Forwarded-Host\"), headers.getFirst(HttpHeaders.HOST));\n        if (xfHost != null && !xfHost.isBlank()) {\n            final var commaIdx = xfHost.indexOf(',');\n            if (commaIdx > 0) {\n                xfHost = xfHost.substring(0, commaIdx).trim();\n            }\n            result.put(\"X-Forwarded-Host\", xfHost);\n        }\n\n        // X-Forwarded-Proto: trust existing if present, otherwise derive from ws/wss scheme\n        var xfProto = headers.getFirst(\"X-Forwarded-Proto\");\n        if (xfProto == null || xfProto.isBlank()) {\n            final var uri = browserSession.getUri();\n            if (uri != null && uri.getScheme() != null) {\n                final var sch = uri.getScheme();\n                if (\"wss\".equalsIgnoreCase(sch)) {\n                    xfProto = \"https\";\n                } else if (\"ws\".equalsIgnoreCase(sch)) {\n                    xfProto = \"http\";\n                }\n            }\n        }\n        if (xfProto != null && !xfProto.isBlank()) {\n            result.put(\"X-Forwarded-Proto\", xfProto);\n        }\n\n        if (!result.isEmpty()) {\n            try {\n                log.debug(\"WS: forwarding handshake headers: {}\", List.copyOf(result.keySet()));\n            } catch (final Exception ignored) {\n                // ignore logging issues\n            }\n        }\n\n        return result;\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/tunnel/TunnelRegistry.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.tunnel;\n\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.TimeUnit;\n\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.socket.TextMessage;\nimport org.springframework.web.socket.WebSocketSession;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.RequiredArgsConstructor;\nimport lombok.Setter;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.tunnel.ControlMessage;\nimport tech.amak.portbuddy.common.tunnel.HttpTunnelMessage;\nimport tech.amak.portbuddy.common.tunnel.WsTunnelMessage;\nimport tech.amak.portbuddy.server.db.entity.TunnelEntity;\n\n/**\n * Registry of active HTTP tunnels and WS connections.\n */\n@Slf4j\n@Component\n@RequiredArgsConstructor\npublic class TunnelRegistry {\n\n    private final Map<String, Tunnel> bySubdomain = new ConcurrentHashMap<>();\n    private final Map<UUID, Tunnel> byTunnelId = new ConcurrentHashMap<>();\n    public static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30);\n\n    private final ObjectMapper mapper;\n\n    /**\n     * Registers a WebSocket session for a given tunnel entity by associating it with a newly created\n     * tunnel instance based on the subdomain and tunnel ID.\n     *\n     * @param tunnelEntity the {@code TunnelEntity} containing information about the domain and tunnel identifiers\n     * @param session      the {@code WebSocketSession} to be associated with the created tunnel instance\n     * @return {@code true} to indicate successful registration\n     */\n    public boolean register(final TunnelEntity tunnelEntity, final WebSocketSession session) {\n        final var tunnel = register(tunnelEntity.getDomain().getSubdomain(), tunnelEntity.getId(),\n            tunnelEntity.getAccountId());\n        tunnel.setSession(session);\n        log.info(\"Registered tunnel {} with session {}\", tunnel.tunnelId(), session.getId());\n        return true;\n    }\n\n    /**\n     * Creates a new pending Tunnel instance with the specified subdomain and tunnel ID\n     * and registers it in the internal mappings.\n     *\n     * @param subdomain the subdomain associated with the tunnel\n     * @param tunnelId  the unique identifier for the tunnel\n     * @param accountId the account identifier for the tunnel\n     * @return the created Tunnel instance\n     */\n    private Tunnel register(final String subdomain, final UUID tunnelId, final UUID accountId) {\n        final var tunnel = new Tunnel(subdomain, tunnelId, accountId);\n        bySubdomain.put(subdomain, tunnel);\n        byTunnelId.put(tunnelId, tunnel);\n        return tunnel;\n    }\n\n\n    public Tunnel getBySubdomain(final String subdomain) {\n        return bySubdomain.get(subdomain);\n    }\n\n    public Tunnel getByTunnelId(final UUID tunnelId) {\n        return byTunnelId.get(tunnelId);\n    }\n\n    /**\n     * Forwards an HTTP tunnel request through a WebSocket session associated with a specified subdomain.\n     * If the tunnel is not connected or not open, the request will fail with an exception.\n     * A timeout can be specified to limit the operation’s duration.\n     *\n     * @param subdomain the subdomain associated with the destination tunnel\n     * @param request   the HTTP tunnel message to be forwarded\n     * @param timeout   the maximum duration to wait for a response; null indicates default timeout\n     * @return a CompletableFuture that will complete with the response message or fail with an exception\n     */\n    public CompletableFuture<HttpTunnelMessage> forwardRequest(final String subdomain,\n                                                               final HttpTunnelMessage request,\n                                                               final Duration timeout) {\n        final var tunnel = bySubdomain.get(subdomain);\n        if (tunnel == null || !tunnel.isOpen()) {\n            final var future = new CompletableFuture<HttpTunnelMessage>();\n            future.completeExceptionally(new IllegalStateException(\"Tunnel not connected\"));\n            return future;\n        }\n        // Assign id if missing\n        if (request.getId() == null) {\n            request.setId(UUID.randomUUID().toString());\n        }\n        request.setType(HttpTunnelMessage.Type.REQUEST);\n        final var future = new CompletableFuture<HttpTunnelMessage>();\n        tunnel.pending().put(request.getId(), future);\n        try {\n            final var json = mapper.writeValueAsString(request);\n            tunnel.session().sendMessage(new TextMessage(json));\n            log.trace(\"Forwarded request {} to tunnel {}\", json, tunnel.tunnelId());\n        } catch (final IOException e) {\n            tunnel.pending().remove(request.getId());\n            future.completeExceptionally(e);\n            return future;\n        }\n\n        final var futureTimeout = timeout == null ? DEFAULT_TIMEOUT : timeout;\n        // Apply timeout\n        final var timedFuture = future.orTimeout(futureTimeout.toMillis(), TimeUnit.MILLISECONDS);\n\n        timedFuture.whenComplete((res, err) ->\n            tunnel.pending().remove(request.getId()));\n\n        return timedFuture;\n    }\n\n    /**\n     * Processes an HTTP tunnel response message associated with the specified tunnel ID.\n     * If the tunnel with the given ID exists and the response matches an existing pending\n     * request in the tunnel, the request's future is completed with the response.\n     *\n     * @param tunnelId the unique identifier of the tunnel associated with the response\n     * @param response the HTTP tunnel message representing the response to be processed\n     */\n    public void onResponse(final UUID tunnelId, final HttpTunnelMessage response) {\n        final var tunnel = byTunnelId.get(tunnelId);\n        if (tunnel == null) {\n            return;\n        }\n        final var future = tunnel.pending()\n            .get(response.getId());\n        if (future != null) {\n            future.complete(response);\n        }\n    }\n\n    /**\n     * Sends a WebSocket message to the client associated with the specified tunnel.\n     * If the specified tunnel is not open or does not exist, the operation is aborted.\n     *\n     * @param tunnelId the unique identifier of the tunnel to send the message to\n     * @param message  the WebSocket message to be sent to the client\n     */\n    // ============ WebSocket tunneling support ============\n    public void sendWsToClient(final UUID tunnelId, final WsTunnelMessage message) {\n        final var tunnel = byTunnelId.get(tunnelId);\n        if (tunnel == null || !tunnel.isOpen()) {\n            return;\n        }\n        try {\n            final var json = mapper.writeValueAsString(message);\n            tunnel.session().sendMessage(new TextMessage(json));\n        } catch (final IOException e) {\n            log.warn(\"Failed to send WS message to client: {}\", e.toString());\n        }\n    }\n\n    /**\n     * Registers a browser WebSocket session associated with the specified tunnel ID and connection ID.\n     * If no tunnel with the provided tunnel ID exists, the operation is aborted.\n     * The session is mapped in both the forward and reverse lookup structures for later reference.\n     *\n     * @param tunnelId       the unique identifier of the tunnel to associate with the browser session\n     * @param connectionId   the unique identifier of the connection within the tunnel\n     * @param browserSession the WebSocket session representing the browser connection\n     */\n    public void registerBrowserWs(final UUID tunnelId,\n                                  final String connectionId,\n                                  final WebSocketSession browserSession) {\n        final var tunnel = byTunnelId.get(tunnelId);\n        if (tunnel == null) {\n            return;\n        }\n        tunnel.browserByConnection().put(connectionId, browserSession);\n        tunnel.browserReverse().put(browserSession, new Ids(tunnelId, connectionId));\n    }\n\n    /**\n     * Unregisters a browser WebSocket session from the tunnel registry. This method\n     * removes the browser session from the reverse mapping and connection ID mapping\n     * of the associated tunnel. If the session is successfully unregistered, the related\n     * IDs (tunnel ID and connection ID) are returned; otherwise, null is returned.\n     *\n     * @param browserSession the WebSocketSession representing the browser connection to be unregistered\n     * @return an {@code Ids} object containing the tunnel ID and connection ID associated with the\n     *     unregistered browser session, or {@code null} if the session was not found\n     */\n    public Ids unregisterBrowserWs(final WebSocketSession browserSession) {\n        for (final var tunnel : byTunnelId.values()) {\n            final var ids = tunnel.browserReverse().remove(browserSession);\n            if (ids != null) {\n                tunnel.browserByConnection().remove(ids.connectionId);\n                return ids;\n            }\n        }\n        return null;\n    }\n\n    /**\n     * Retrieves the tunnel and connection IDs associated with a given browser WebSocket session.\n     * Iterates through the registered tunnels to find a reverse mapping for the specified session.\n     *\n     * @param browserSession the WebSocketSession representing the browser connection to look up\n     * @return an {@code Ids} object containing the tunnel ID and connection ID associated with\n     *     the specified session, or {@code null} if no match is found\n     */\n    public Ids findIdsByBrowserSession(final WebSocketSession browserSession) {\n        for (final var tunnel : byTunnelId.values()) {\n            final var ids = tunnel.browserReverse().get(browserSession);\n            if (ids != null) {\n                return ids;\n            }\n        }\n        return null;\n    }\n\n    /**\n     * Retrieves a WebSocket session associated with the specified tunnel ID and connection ID.\n     * If no tunnel exists for the given tunnel ID or no session is associated with the\n     * provided connection ID, this method returns {@code null}.\n     *\n     * @param tunnelId     the unique identifier of the tunnel from which to retrieve the session\n     * @param connectionId the unique identifier of the connection within the tunnel\n     * @return the WebSocketSession associated with the specified tunnel ID and connection ID,\n     *     or {@code null} if no matching session is found\n     */\n    public WebSocketSession getBrowserSession(final UUID tunnelId, final String connectionId) {\n        final var tunnel = byTunnelId.get(tunnelId);\n        if (tunnel == null) {\n            return null;\n        }\n        return tunnel.browserByConnection().get(connectionId);\n    }\n\n    /**\n     * Closes the WebSocket session associated with the specified tunnel ID after sending an EXIT control message.\n     *\n     * @param tunnelId the unique identifier of the tunnel to close\n     */\n    public void closeTunnel(final UUID tunnelId) {\n        final var tunnel = byTunnelId.remove(tunnelId);\n        if (tunnel == null) {\n            return;\n        }\n        // Remove from bySubdomain if exists\n        if (tunnel.subdomain() != null) {\n            bySubdomain.remove(tunnel.subdomain());\n        }\n\n        // Close all browser sessions associated with this tunnel\n        tunnel.browserByConnection().values().forEach(session -> {\n            if (session.isOpen()) {\n                try {\n                    session.close();\n                } catch (final IOException e) {\n                    log.warn(\"Failed to close browser WS session: {}\", e.toString());\n                }\n            }\n        });\n        tunnel.browserByConnection().clear();\n        tunnel.browserReverse().clear();\n\n        // Fail all pending HTTP requests and clear\n        tunnel.pending().forEach((id, future) ->\n            future.completeExceptionally(new IllegalStateException(\"Tunnel closed\")));\n        tunnel.pending().clear();\n\n        if (tunnel.isOpen()) {\n            try {\n                final var exitMsg = new ControlMessage();\n                exitMsg.setType(ControlMessage.Type.EXIT);\n                exitMsg.setTs(System.currentTimeMillis());\n                tunnel.session().sendMessage(new TextMessage(mapper.writeValueAsString(exitMsg)));\n                tunnel.session().close();\n            } catch (final IOException e) {\n                log.warn(\"Failed to close tunnel WS session: {}\", e.toString());\n            }\n        }\n    }\n\n    @Data\n    @AllArgsConstructor\n    public static final class Ids {\n        private final UUID tunnelId;\n        private final String connectionId;\n    }\n\n    @RequiredArgsConstructor\n    public static class Tunnel {\n\n        private final String subdomain;\n        private final UUID tunnelId;\n        private final UUID accountId;\n\n        @Setter\n        private volatile WebSocketSession session;\n        private final Map<String, CompletableFuture<HttpTunnelMessage>> pending = new ConcurrentHashMap<>();\n        // Browser WS peers for this tunnel\n        private final Map<String, WebSocketSession> browserByConnection = new ConcurrentHashMap<>();\n        private final Map<WebSocketSession, Ids> browserReverse = new ConcurrentHashMap<>();\n\n\n        public String subdomain() {\n            return subdomain;\n        }\n\n        public UUID tunnelId() {\n            return tunnelId;\n        }\n\n        public UUID accountId() {\n            return accountId;\n        }\n\n        public WebSocketSession session() {\n            return session;\n        }\n\n        public Map<String, CompletableFuture<HttpTunnelMessage>> pending() {\n            return pending;\n        }\n\n        public boolean isOpen() {\n            return session != null && session.isOpen();\n        }\n\n        public Map<String, WebSocketSession> browserByConnection() {\n            return browserByConnection;\n        }\n\n        public Map<WebSocketSession, Ids> browserReverse() {\n            return browserReverse;\n        }\n\n        // No passcode kept in-memory; use DB via TunnelService when needed\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/tunnel/TunnelWebSocketHandler.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.tunnel;\n\nimport java.util.Base64;\nimport java.util.UUID;\n\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.socket.CloseStatus;\nimport org.springframework.web.socket.TextMessage;\nimport org.springframework.web.socket.WebSocketSession;\nimport org.springframework.web.socket.handler.TextWebSocketHandler;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.tunnel.ControlMessage;\nimport tech.amak.portbuddy.common.tunnel.HttpTunnelMessage;\nimport tech.amak.portbuddy.common.tunnel.MessageEnvelope;\nimport tech.amak.portbuddy.common.tunnel.WsTunnelMessage;\nimport tech.amak.portbuddy.common.utils.IdUtils;\nimport tech.amak.portbuddy.server.service.TunnelService;\n\n@Slf4j\n@Component\n@RequiredArgsConstructor\npublic class TunnelWebSocketHandler extends TextWebSocketHandler {\n\n    private final TunnelRegistry registry;\n    private final ObjectMapper mapper;\n    private final TunnelService tunnelService;\n\n    @Override\n    @Transactional\n    public void afterConnectionEstablished(final WebSocketSession session) {\n        final var tunnelId = extractTunnelId(session);\n\n        tunnelService.findByTunnelId(tunnelId).ifPresentOrElse(\n            tunnel -> {\n                registry.register(tunnel, session);\n                tunnelService.markConnected(tunnelId);\n                log.info(\"Tunnel session established: {}\", tunnelId);\n            },\n            () -> {\n                log.warn(\"Tunnel not found for id={}\", tunnelId);\n                closeWebsocket(session, CloseStatus.NORMAL);\n            });\n    }\n\n    private void closeWebsocket(final WebSocketSession session,\n                                final CloseStatus status) {\n        try {\n            session.close(status);\n            log.debug(\"Closed websocket. Status: {}, URI: {}\", status, session.getUri());\n        } catch (final Exception e) {\n            log.warn(\"Failed to close websocket: {}\", e.toString());\n        }\n    }\n\n    @Override\n    protected void handleTextMessage(final WebSocketSession session, final TextMessage message) {\n        try {\n            log.trace(\"Received message from client: {}\", message.getPayload());\n            final var tunnelId = extractTunnelId(session);\n\n            tunnelService.heartbeat(tunnelId);\n\n            final String payload = message.getPayload();\n            final var env = mapper.readValue(payload, MessageEnvelope.class);\n            // Control health checks\n            if (env.getKind() != null && env.getKind().equals(\"CTRL\")) {\n                final var ctrl = mapper.readValue(payload, ControlMessage.class);\n                if (ctrl.getType() == ControlMessage.Type.PING) {\n                    final var pong = new ControlMessage();\n                    pong.setType(ControlMessage.Type.PONG);\n                    pong.setTs(System.currentTimeMillis());\n                    session.sendMessage(new TextMessage(mapper.writeValueAsString(pong)));\n                }\n                return;\n            }\n            if (env.getKind() != null && env.getKind().equals(\"WS\")) {\n                final var wsMsg = mapper.readValue(payload, WsTunnelMessage.class);\n                handleWsFromClient(tunnelId, wsMsg);\n                return;\n            }\n            final var httpMsg = mapper.readValue(payload, HttpTunnelMessage.class);\n            if (httpMsg.getType() == HttpTunnelMessage.Type.RESPONSE) {\n                registry.onResponse(tunnelId, httpMsg);\n            } else {\n                log.debug(\"Ignoring unexpected message type from client: {}\", httpMsg.getType());\n            }\n        } catch (final Exception e) {\n            log.warn(\"Tunnel message handling error: {}\", e.toString());\n        }\n    }\n\n    private void handleWsFromClient(final UUID tunnelId, final WsTunnelMessage message) throws Exception {\n        final var browser = registry.getBrowserSession(tunnelId, message.getConnectionId());\n        if (browser == null) {\n            log.debug(\"No browser WS for connectionId={} tunnelId={}\", message.getConnectionId(), tunnelId);\n            return;\n        }\n        switch (message.getWsType()) {\n            case OPEN_OK -> { /* nothing extra for now */ }\n            case TEXT -> browser.sendMessage(new TextMessage(message.getText() != null ? message.getText() : \"\"));\n            case BINARY -> {\n                if (message.getDataB64() != null) {\n                    final var bytes = Base64.getDecoder().decode(message.getDataB64());\n                    browser.sendMessage(new org.springframework.web.socket.BinaryMessage(bytes));\n                }\n            }\n            case CLOSE -> {\n                final var code = message.getCloseCode() != null ? message.getCloseCode() : CloseStatus.NORMAL.getCode();\n                final var reason = message.getCloseReason();\n                browser.close(new CloseStatus(code, reason));\n            }\n            default -> {\n            }\n        }\n    }\n\n    @Override\n    public void afterConnectionClosed(final WebSocketSession session, final CloseStatus status) {\n        final var tunnelId = extractTunnelId(session);\n        log.info(\"Tunnel session closed: {} code={} reason={}\", tunnelId,\n            status != null ? status.getCode() : null,\n            status != null ? status.getReason() : null);\n\n        registry.closeTunnel(tunnelId);\n        tunnelService.markClosed(tunnelId);\n    }\n\n    private UUID extractTunnelId(final WebSocketSession session) {\n        return IdUtils.extractTunnelId(session.getUri());\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/tunnel/WebSocketConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.tunnel;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.web.socket.config.annotation.EnableWebSocket;\nimport org.springframework.web.socket.config.annotation.WebSocketConfigurer;\nimport org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;\nimport org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.config.AppProperties;\n\n@Configuration\n@EnableWebSocket\n@RequiredArgsConstructor\npublic class WebSocketConfig implements WebSocketConfigurer {\n\n    private final TunnelWebSocketHandler tunnelWebSocketHandler;\n    private final PublicWebSocketProxyHandler publicWebSocketProxyHandler;\n    private final AppProperties properties;\n\n    @Override\n    public void registerWebSocketHandlers(final WebSocketHandlerRegistry registry) {\n        registry.addHandler(tunnelWebSocketHandler, \"/api/http-tunnel/{tunnelId}\")\n            .setAllowedOrigins(\"*\")\n            // Echo back any requested subprotocol (some clients require it, e.g., Vaadin)\n            .setHandshakeHandler(new PermissiveSubprotocolHandshakeHandler());\n        // Public WS endpoint for tunneled hosts (dedicated base path to avoid MVC collisions)\n        registry.addHandler(publicWebSocketProxyHandler, \"/_ws/**\")\n            .setAllowedOrigins(\"*\")\n            // Echo back any requested subprotocol\n            .setHandshakeHandler(new PermissiveSubprotocolHandshakeHandler());\n    }\n\n    /**\n     * Configure the underlying servlet WebSocket container to allow larger text and\n     * binary messages. We increase limits to 2 MiB to support larger tunneled\n     * payloads between the CLI and the server.\n     */\n    @Bean\n    public ServletServerContainerFactoryBean websocketContainer() {\n        final var container = new ServletServerContainerFactoryBean();\n        final var webSocket = properties.webSocket();\n        container.setMaxTextMessageBufferSize((int) webSocket.maxTextMessageSize().toBytes());\n        container.setMaxBinaryMessageBufferSize((int) webSocket.maxBinaryMessageSize().toBytes());\n        // Prevent premature session termination by increasing idle timeout\n        if (webSocket.sessionIdleTimeout() != null) {\n            container.setMaxSessionIdleTimeout(webSocket.sessionIdleTimeout().toMillis());\n        }\n        return container;\n    }\n\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/AuthController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport java.util.HashMap;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.security.crypto.password.PasswordEncoder;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.ResponseStatus;\nimport org.springframework.web.bind.annotation.RestController;\nimport org.springframework.web.server.ResponseStatusException;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.dto.auth.RegisterRequest;\nimport tech.amak.portbuddy.common.dto.auth.RegisterResponse;\nimport tech.amak.portbuddy.common.dto.auth.TokenExchangeRequest;\nimport tech.amak.portbuddy.common.dto.auth.TokenExchangeResponse;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserAccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.security.JwtService;\nimport tech.amak.portbuddy.server.service.ApiTokenService;\nimport tech.amak.portbuddy.server.service.user.PasswordResetService;\nimport tech.amak.portbuddy.server.service.user.UserProvisioningService;\nimport tech.amak.portbuddy.server.web.dto.LoginRequest;\nimport tech.amak.portbuddy.server.web.dto.PasswordResetConfirm;\nimport tech.amak.portbuddy.server.web.dto.PasswordResetRequest;\n\n@RestController\n@RequestMapping(path = \"/api/auth\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\n@Slf4j\npublic class AuthController {\n\n    private final ApiTokenService apiTokenService;\n    private final JwtService jwtService;\n    private final UserRepository userRepository;\n    private final PasswordEncoder passwordEncoder;\n    private final UserProvisioningService userProvisioningService;\n    private final PasswordResetService passwordResetService;\n    private final AppProperties properties;\n    private final UserAccountRepository userAccountRepository;\n    private final AccountRepository accountRepository;\n\n    /**\n     * Exchanges a valid API token for a short-lived JWT suitable for authenticating API and WebSocket calls.\n     */\n    @PostMapping(\"/token-exchange\")\n    public TokenExchangeResponse tokenExchange(final @RequestBody TokenExchangeRequest payload) {\n        final var apiToken = payload == null ? \"\" : String.valueOf(payload.getApiToken()).trim();\n        final var cliClientVersion = payload == null ? null : payload.getCliClientVersion();\n        if (cliClientVersion == null || cliClientVersion.isBlank()) {\n            throw new ResponseStatusException(HttpStatus.UPGRADE_REQUIRED,\n                \"CLI client version is missing or not supported. Please upgrade your port-buddy CLI.\");\n        }\n        if (!isCliVersionSupported(cliClientVersion.trim(), properties.cli().minVersion())) {\n            throw new ResponseStatusException(HttpStatus.UPGRADE_REQUIRED,\n                \"Your port-buddy CLI is outdated. Please upgrade to the latest version.\");\n        }\n        final var validatedOpt = apiTokenService.validateAndGetApiKey(apiToken);\n        if (validatedOpt.isEmpty()) {\n            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"Invalid API token\");\n        }\n        final var validated = validatedOpt.get();\n        final var accountId = validated.accountId();\n        final var account = accountRepository.findById(accountId)\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"Account not found\"));\n        if (account.isBlocked()) {\n            throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"Account is blocked\");\n        }\n        final var userId = validated.userId();\n        final var claims = new HashMap<String, Object>();\n        claims.put(\"typ\", \"cli\");\n        claims.put(\"akid\", validated.apiKeyId().toString());\n        claims.put(\"aid\", validated.accountId().toString());\n        final var jwt = jwtService.createToken(claims, userId.toString());\n        return new TokenExchangeResponse(jwt, \"Bearer\");\n    }\n\n    private boolean isCliVersionSupported(final String clientVersion, final String minimalVersion) {\n        // allow dev builds\n        final var cv = clientVersion.toLowerCase();\n        if (cv.contains(\"dev\")) {\n            return true;\n        }\n        return compareVersions(clientVersion, minimalVersion) >= 0;\n    }\n\n    private int compareVersions(final String v1, final String v2) {\n        final var a = v1.split(\"[.\\\\-]\");\n        final var b = v2.split(\"[.\\\\-]\");\n        final var len = Math.max(a.length, b.length);\n        for (var i = 0; i < len; i++) {\n            final var ai = i < a.length ? parseIntSafe(a[i]) : 0;\n            final var bi = i < b.length ? parseIntSafe(b[i]) : 0;\n            if (ai != bi) {\n                return Integer.compare(ai, bi);\n            }\n        }\n        return 0;\n    }\n\n    private int parseIntSafe(final String part) {\n        try {\n            return Integer.parseInt(part.replaceAll(\"[^0-9]\", \"\"));\n        } catch (final Exception ignored) {\n            return 0;\n        }\n    }\n\n    /**\n     * Registers a new local user and returns an API key.\n     */\n    @PostMapping(\"/register\")\n    public RegisterResponse register(final @RequestBody RegisterRequest payload) {\n        if (payload == null || payload.getEmail() == null) {\n            return new RegisterResponse(null, false, \"Email is required\", 400);\n        }\n\n        try {\n            final var provisioned = userProvisioningService.createLocalUser(\n                payload.getEmail(),\n                payload.getName(),\n                payload.getPassword()\n            );\n            final var createdToken = apiTokenService.createToken(\n                provisioned.accountId(), provisioned.userId(), \"prtb-client\");\n            return new RegisterResponse(createdToken.token(), true, \"User registered successfully\", 200);\n        } catch (final IllegalArgumentException e) {\n            log.error(e.getMessage(), e);\n            return new RegisterResponse(null, false, e.getMessage(), 400);\n        } catch (final Exception e) {\n            log.error(e.getMessage(), e);\n            return new RegisterResponse(null, false, \"Internal Server Error: \" + e.getMessage(), 500);\n        }\n    }\n\n    /**\n     * Authenticates a user with email and password.\n     */\n    @PostMapping(\"/login\")\n    public TokenExchangeResponse login(final @RequestBody LoginRequest payload) {\n        if (payload == null || payload.email() == null || payload.password() == null) {\n            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, \"Email and password are required\");\n        }\n        final var user = userRepository.findByEmailIgnoreCase(payload.email())\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"Invalid credentials\"));\n\n        if (user.getPassword() == null || !passwordEncoder.matches(payload.password(), user.getPassword())) {\n            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"Invalid credentials\");\n        }\n\n        final var claims = new HashMap<String, Object>();\n        claims.put(\"email\", user.getEmail());\n\n        String name = user.getFirstName();\n        if (user.getLastName() != null) {\n            name = name + \" \" + user.getLastName();\n        }\n        claims.put(\"name\", name.trim());\n\n        if (user.getAvatarUrl() != null) {\n            claims.put(\"picture\", user.getAvatarUrl());\n        }\n\n        final var userAccount = userAccountRepository.findLatestUsedByUserId(user.getId())\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, \"User has no accounts\"));\n\n        if (userAccount.getAccount().isBlocked()) {\n            throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"Account is blocked\");\n        }\n\n        claims.put(\"aid\", userAccount.getAccount().getId().toString());\n        claims.put(\"uid\", user.getId().toString());\n\n        final var jwt = jwtService.createToken(claims, user.getId().toString(), userAccount.getRoles());\n        return new TokenExchangeResponse(jwt, \"Bearer\");\n    }\n\n    /**\n     * Requests a password reset email.\n     */\n    @PostMapping(\"/password-reset/request\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    public void requestPasswordReset(final @RequestBody PasswordResetRequest payload) {\n        if (payload == null || payload.email() == null || payload.email().isBlank()) {\n            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, \"Email is required\");\n        }\n        passwordResetService.requestReset(payload.email());\n    }\n\n    /**\n     * Validates a password reset token.\n     */\n    @GetMapping(\"/password-reset/validate\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    public void validateResetToken(final @RequestParam(\"token\") String token) {\n        if (token == null || token.isBlank()) {\n            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, \"Token is required\");\n        }\n        if (!passwordResetService.validateToken(token)) {\n            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, \"Invalid or expired token\");\n        }\n    }\n\n    /**\n     * Confirms password reset and sets a new password.\n     */\n    @PostMapping(\"/password-reset/confirm\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    public void confirmPasswordReset(final @RequestBody PasswordResetConfirm payload) {\n        if (payload == null || payload.token() == null || payload.newPassword() == null) {\n            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, \"Token and new password are required\");\n        }\n        try {\n            passwordResetService.resetPassword(payload.token(), payload.newPassword());\n        } catch (final IllegalArgumentException e) {\n            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());\n        }\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/DomainsController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport static tech.amak.portbuddy.server.security.JwtService.resolveAccountId;\nimport static tech.amak.portbuddy.server.security.JwtService.resolveUserId;\n\nimport java.util.List;\nimport java.util.UUID;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.jwt.Jwt;\nimport org.springframework.web.bind.annotation.DeleteMapping;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.PutMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.ResponseStatus;\nimport org.springframework.web.bind.annotation.RestController;\nimport org.springframework.web.server.ResponseStatusException;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.DomainEntity;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.service.DomainService;\nimport tech.amak.portbuddy.server.web.dto.DomainDto;\nimport tech.amak.portbuddy.server.web.dto.SetPasscodeRequest;\nimport tech.amak.portbuddy.server.web.dto.UpdateCustomDomainRequest;\nimport tech.amak.portbuddy.server.web.dto.UpdateDomainRequest;\n\n@RestController\n@RequestMapping(path = \"/api/domains\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\npublic class DomainsController {\n\n    private final DomainService domainService;\n    private final UserRepository userRepository;\n\n    /**\n     * Retrieves a list of domains associated with the account of the authenticated user.\n     *\n     * @param principal the JWT representing the authenticated user\n     * @return a list of domain DTOs representing the user's associated domains\n     */\n    @GetMapping\n    public List<DomainDto> list(final @AuthenticationPrincipal Jwt principal) {\n        final var account = getAccount(principal);\n        return domainService.getDomains(account).stream()\n            .map(DomainsController::toDto)\n            .toList();\n    }\n\n    /**\n     * Creates a new domain associated with the authenticated user's account.\n     *\n     * @param principal the JWT token representing the authenticated user\n     * @return the created domain as a DomainDto object\n     * @throws RuntimeException if no available domains can be created\n     */\n    @PostMapping\n    public DomainDto create(final @AuthenticationPrincipal Jwt principal) {\n        final var account = getAccount(principal);\n        return domainService.createDomain(account)\n            .map(DomainsController::toDto)\n            .orElseThrow(() -> new RuntimeException(\"No available domains\"));\n    }\n\n    @PutMapping(\"/{id}\")\n    public DomainDto update(final @AuthenticationPrincipal Jwt principal,\n                            @PathVariable(\"id\") final UUID id,\n                            @RequestBody final UpdateDomainRequest request) {\n        final var account = getAccount(principal);\n        return toDto(domainService.updateDomain(id, account, request.subdomain()));\n    }\n\n    @DeleteMapping(\"/{id}\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    public void delete(final @AuthenticationPrincipal Jwt principal,\n                       @PathVariable(\"id\") final UUID id) {\n        final var account = getAccount(principal);\n        domainService.deleteDomain(id, account);\n    }\n\n    /**\n     * Sets or updates a passcode for the given domain. The provided passcode will be hashed\n     * and stored server-side. Subsequent requests to the public domain will require the passcode.\n     *\n     * @param principal authenticated user token\n     * @param id domain id\n     * @param request passcode payload\n     * @return updated domain dto\n     */\n    @PutMapping(\"/{id}/passcode\")\n    public DomainDto setPasscode(final @AuthenticationPrincipal Jwt principal,\n                                 @PathVariable(\"id\") final UUID id,\n                                 @RequestBody final SetPasscodeRequest request) {\n        final var account = getAccount(principal);\n        return toDto(domainService.setPasscode(id, account, request.passcode()));\n    }\n\n    /**\n     * Deletes passcode protection for the given domain.\n     *\n     * @param principal authenticated user token\n     * @param id domain id\n     */\n    @DeleteMapping(\"/{id}/passcode\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    public void deletePasscode(final @AuthenticationPrincipal Jwt principal,\n                               @PathVariable(\"id\") final UUID id) {\n        final var account = getAccount(principal);\n        domainService.clearPasscode(id, account);\n    }\n\n    /**\n     * Updates the custom domain for the given domain.\n     *\n     * @param principal authenticated user token\n     * @param id domain id\n     * @param request update payload\n     * @return updated domain dto\n     */\n    @PutMapping(\"/{id}/custom-domain\")\n    public DomainDto updateCustomDomain(final @AuthenticationPrincipal Jwt principal,\n                                        @PathVariable(\"id\") final UUID id,\n                                        @RequestBody final UpdateCustomDomainRequest request) {\n        final var account = getAccount(principal);\n        return toDto(domainService.updateCustomDomain(id, account, request.customDomain()));\n    }\n\n    /**\n     * Deletes the custom domain from the given domain.\n     *\n     * @param principal authenticated user token\n     * @param id domain id\n     */\n    @DeleteMapping(\"/{id}/custom-domain\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    public void deleteCustomDomain(final @AuthenticationPrincipal Jwt principal,\n                                   @PathVariable(\"id\") final UUID id) {\n        final var account = getAccount(principal);\n        domainService.deleteCustomDomain(id, account);\n    }\n\n    /**\n     * Triggers CNAME verification and SSL issuance for the custom domain.\n     *\n     * @param principal authenticated user token\n     * @param id domain id\n     * @return updated domain dto\n     */\n    @PostMapping(\"/{id}/verify-cname\")\n    public DomainDto verifyCname(final @AuthenticationPrincipal Jwt principal,\n                                 @PathVariable(\"id\") final UUID id) {\n        final var account = getAccount(principal);\n        final var userId = resolveUserId(principal);\n        return toDto(domainService.verifyCname(id, account, userId));\n    }\n\n    private AccountEntity getAccount(final Jwt jwt) {\n        final var accountId = resolveAccountId(jwt);\n        final var userId = UUID.fromString(jwt.getSubject());\n        return userRepository.findById(userId)\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"User not found\"))\n            .getAccounts().stream()\n            .filter(ua -> ua.getAccount().getId().equals(accountId))\n            .findFirst()\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"Account not found\"))\n            .getAccount();\n    }\n\n    private static DomainDto toDto(final DomainEntity domain) {\n        return new DomainDto(\n            domain.getId(),\n            domain.getSubdomain(),\n            domain.getDomain(),\n            domain.getCustomDomain(),\n            domain.isCnameVerified(),\n            domain.isSslActive(),\n            domain.getPasscodeHash() != null && !domain.getPasscodeHash().isBlank(),\n            domain.getCreatedAt(),\n            domain.getUpdatedAt()\n        );\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/ExposeController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport static tech.amak.portbuddy.server.security.JwtService.resolveAccountId;\nimport static tech.amak.portbuddy.server.security.JwtService.resolveUserId;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.crypto.password.PasswordEncoder;\nimport org.springframework.security.oauth2.jwt.Jwt;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\nimport org.springframework.web.server.ResponseStatusException;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.dto.ExposeRequest;\nimport tech.amak.portbuddy.common.dto.ExposeResponse;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.service.DomainService;\nimport tech.amak.portbuddy.server.service.PortReservationService;\nimport tech.amak.portbuddy.server.service.TunnelService;\n\n@RestController\n@RequestMapping(path = \"/api/expose\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\n@Slf4j\npublic class ExposeController {\n\n    private final AppProperties properties;\n    private final TunnelService tunnelService;\n    private final UserRepository userRepository;\n    private final AccountRepository accountRepository;\n    private final DomainService domainService;\n    private final PortReservationService portReservationService;\n    private final PasswordEncoder passwordEncoder;\n\n    /**\n     * Creates a public HTTP endpoint to expose a local HTTP service by generating a unique\n     * subdomain and tunnel ID. The method constructs a public URL based on the application\n     * gateway settings and links it to the provided local service details (scheme, host, and port).\n     *\n     * @param request the details of the local HTTP service to expose, including the scheme,\n     *                host, and port\n     * @return an {@code ExposeResponse} containing the source (local service details),\n     *     the generated public URL, tunnel ID, and subdomain information for the exposed service\n     */\n    @PostMapping(\"/http\")\n    @Transactional\n    public ExposeResponse exposeHttp(final @AuthenticationPrincipal Jwt jwt,\n                                     final @RequestBody ExposeRequest request) {\n        final var validatedUser = validateUser(jwt);\n        final var account = validatedUser.account();\n        final var user = validatedUser.user();\n\n        final var domain = domainService.resolveDomain(\n            account, request.domain(), request.host(), request.port());\n        final var subdomain = domain.getSubdomain();\n        final var gateway = properties.gateway();\n        final var publicUrl = gateway.subdomainUrlTemplate().formatted(subdomain);\n        final var source = \"%s://%s:%s\".formatted(request.scheme(), request.host(), request.port());\n\n        final var apiKeyId = extractApiKeyId(jwt);\n        final var tunnel = tunnelService.createHttpTunnel(\n            account,\n            user.getId(),\n            apiKeyId,\n            request,\n            publicUrl,\n            domain);\n        final var tunnelId = tunnel.getId();\n\n        // If passcode provided, store temporary passcode hash on the tunnel entity\n        if (request.passcode() != null && !request.passcode().isBlank()) {\n            final var hash = passwordEncoder.encode(request.passcode());\n            tunnelService.setTempPasscodeHash(tunnelId, hash);\n        }\n        return new ExposeResponse(source, publicUrl, null, null, tunnelId, subdomain);\n    }\n\n    /**\n     * Allocates a public Net port to expose a local TCP or UDP service using the provided request\n     * details. This method interacts with a TCP proxy client to assign a unique tunnel ID\n     * and configure the TCP exposure.\n     *\n     * @param request the details of the local TCP or UDP service to expose, including the host,\n     *                scheme, and port\n     * @return an {@code ExposeResponse} containing the allocated public port, tunnel ID,\n     *     and other relevant exposure details\n     * @throws RuntimeException if the allocation of the public TCP or UDP port fails\n     */\n    @PostMapping(\"/net\")\n    @Transactional\n    public ExposeResponse exposeNet(final @AuthenticationPrincipal Jwt jwt,\n                                    final @RequestBody ExposeRequest request) {\n\n        final var validatedUser = validateUser(jwt);\n        final var account = validatedUser.account();\n        final var user = validatedUser.user();\n        final var apiKeyId = validatedUser.apiKeyId();\n\n        // Pre-create tunnel and use its DB id as tunnelId\n        final var tunnel = tunnelService.createNetTunnel(account, user.getId(), apiKeyId, request);\n        final var tunnelId = tunnel.getId();\n\n        // Resolve or validate reservation according to rules\n        final var reservation = portReservationService.resolveForNetExpose(\n            account,\n            user,\n            request.host(),\n            request.port(),\n            request.portReservation());\n\n        // Link reservation to tunnel and set public host/port from it\n        tunnelService.assignReservation(tunnelId, reservation);\n\n        // Do not call net-proxy here. Return allocated details to CLI.\n        return new ExposeResponse(\n            \"%s %s:%d\".formatted(request.tunnelType().name().toLowerCase(), request.host(), request.port()),\n            null,\n            reservation.getPublicHost(),\n            reservation.getPublicPort(),\n            tunnelId,\n            null);\n    }\n\n    private String extractApiKeyId(final Jwt jwt) {\n        final var claim = jwt.getClaimAsString(\"akid\");\n        return claim == null || claim.isBlank() ? null : claim;\n    }\n\n    private ValidatedUser validateUser(final Jwt jwt) {\n        final var userId = resolveUserId(jwt);\n        final var accountId = resolveAccountId(jwt);\n        final var user = userRepository.findById(userId)\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"User not found\"));\n        final var account = accountRepository.findById(accountId)\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"Account not found\"));\n        if (account.isBlocked()) {\n            throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"Account is blocked\");\n        }\n\n        final var apiKeyId = extractApiKeyId(jwt);\n\n        return new ValidatedUser(user, account, apiKeyId);\n    }\n\n    private record ValidatedUser(UserEntity user, AccountEntity account, String apiKeyId) {\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/IngressController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport static org.springframework.http.HttpStatus.TEMPORARY_REDIRECT;\n\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Base64;\nimport java.util.Enumeration;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.stream.Stream;\n\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.security.crypto.password.PasswordEncoder;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.util.AntPathMatcher;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\nimport org.springframework.web.servlet.HandlerMapping;\n\nimport jakarta.servlet.http.Cookie;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.tunnel.HttpTunnelMessage;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.DomainEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.DomainRepository;\nimport tech.amak.portbuddy.server.service.TunnelService;\nimport tech.amak.portbuddy.server.tunnel.TunnelRegistry;\n\n/**\n * HTTP ingress that forwards requests to a client tunnel by subdomain.\n */\n@Slf4j\n@RestController\n@RequiredArgsConstructor\npublic class IngressController {\n\n    private static final String PASSCODE_COOKIE_NAME = \"pbp\";\n\n    private final TunnelRegistry registry;\n    private final AppProperties properties;\n    private final DomainRepository domainRepository;\n    private final AccountRepository accountRepository;\n    private final TunnelService tunnelService;\n    private final PasswordEncoder passwordEncoder;\n\n    private static final Set<String> HOP_BY_HOP_RESPONSE_HEADERS = Set.of(\n        // RFC 7230 hop-by-hop headers + common variants we do not want to relay\n        HttpHeaders.CONNECTION.toLowerCase(),\n        \"keep-alive\",\n        \"proxy-authenticate\",\n        \"proxy-authorization\",\n        \"te\",\n        \"trailer\",\n        HttpHeaders.TRANSFER_ENCODING.toLowerCase(),\n        HttpHeaders.UPGRADE.toLowerCase(),\n        // Avoid conflicting length management across hops; let container decide\n        HttpHeaders.CONTENT_LENGTH.toLowerCase()\n    );\n\n    // HTTP route for subdomain ingress (non-WS traffic)\n    @RequestMapping(\"/_/{subdomain:.+}/**\")\n    @Transactional\n    public void ingressPathBased(final @PathVariable(\"subdomain\") String subdomain,\n                                 final HttpServletRequest request,\n                                 final HttpServletResponse response) throws IOException {\n        forwardViaTunnel(subdomain, request, response);\n    }\n\n    /**\n     * Handles path-based custom domain ingress, mapping requests to the appropriate subdomain.\n     * This endpoint processes requests that use the custom domain format in their URL path.\n     *\n     * @param customDomain The custom domain identifier extracted from the request path. Used to\n     *                     locate a matching subdomain in the database.\n     * @param request      The incoming HTTP request to be forwarded to the matching subdomain's endpoint.\n     * @param response     The HTTP response object used to return output or error codes to the client.\n     * @throws IOException If an input or output error occurs during the request forwarding process\n     *                     or while setting the HTTP response.\n     */\n    // Path-based custom domain ingress: http://server/_custom/{customDomain}/...\n    @RequestMapping(\"/_custom/{customDomain:.+}/**\")\n    @Transactional\n    public void ingressCustomDomainPathBased(final @PathVariable(\"customDomain\") String customDomain,\n                                             final HttpServletRequest request,\n                                             final HttpServletResponse response) throws IOException {\n        var lookupDomain = customDomain.toLowerCase();\n        final var colonIdx = lookupDomain.indexOf(':');\n        if (colonIdx > 0) {\n            lookupDomain = lookupDomain.substring(0, colonIdx);\n        }\n\n        final var domainOpt = domainRepository.findByCustomDomain(lookupDomain);\n        if (domainOpt.isPresent()) {\n            forwardViaTunnel(domainOpt.get().getSubdomain(), request, response);\n        } else {\n            response.sendError(HttpServletResponse.SC_NOT_FOUND, \"Custom domain not found: \" + lookupDomain);\n        }\n    }\n\n    private void forwardViaTunnel(final String subdomain,\n                                  final HttpServletRequest request,\n                                  final HttpServletResponse response) throws IOException {\n        // If there is no active tunnel for the requested subdomain — redirect users to SPA 404 page\n        final var tunnel = registry.getBySubdomain(subdomain);\n        if (tunnel == null || !tunnel.isOpen()) {\n            final var notFoundUrl = properties.gateway().notFoundPage();\n            response.setStatus(HttpServletResponse.SC_TEMPORARY_REDIRECT);\n            response.setHeader(HttpHeaders.LOCATION, notFoundUrl);\n            return;\n        }\n\n        // Check subscription status\n        final var accountOpt = accountRepository.findById(tunnel.accountId());\n        if (accountOpt.isPresent()) {\n            final var status = accountOpt.get().getSubscriptionStatus();\n            if (status != null && !\"active\".equals(status)) {\n                log.warn(\"Blocked request to subdomain {} because subscription is not active (status: {})\",\n                    subdomain, status);\n                response.sendError(HttpServletResponse.SC_PAYMENT_REQUIRED,\n                    \"Subscription is not active. Please check your billing information.\");\n                return;\n            }\n        }\n\n        // Passcode protection check (query param, header, or cookie)\n        if (!isAuthorized(subdomain, tunnel.tunnelId(), request, response)) {\n            final var gateway = properties.gateway();\n            final var originalDomain = \"%s.%s\".formatted(subdomain, gateway.domain());\n            final var redirect = \"%s?target_domain=%s\".formatted(gateway.passcodePage(), originalDomain);\n            response.setStatus(TEMPORARY_REDIRECT.value());\n            response.setHeader(HttpHeaders.LOCATION, redirect);\n            return;\n        }\n\n        final var pathWithin = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);\n        final var bestMatch = (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);\n        final var matcher = new AntPathMatcher();\n        var path = matcher.extractPathWithinPattern(bestMatch, pathWithin);\n        if (!path.startsWith(\"/\")) {\n            path = \"/\" + path;\n        }\n\n        final var method = request.getMethod();\n        final var query = request.getQueryString();\n\n        final Map<String, List<String>> headers = new HashMap<>();\n        for (Enumeration<String> en = request.getHeaderNames(); en.hasMoreElements(); ) {\n            final var name = en.nextElement();\n            // Skip hop-by-hop headers\n            if (name.equalsIgnoreCase(HttpHeaders.HOST) || name.equalsIgnoreCase(HttpHeaders.CONNECTION)) {\n                continue;\n            }\n            final List<String> values = new ArrayList<>();\n            for (Enumeration<String> headerValues = request.getHeaders(name); headerValues.hasMoreElements(); ) {\n                final var value = headerValues.nextElement();\n                if (value != null) {\n                    values.add(value);\n                }\n            }\n            if (!values.isEmpty()) {\n                headers.put(name, values);\n            }\n        }\n\n        headers.put(\"X-Forwarded-Host\", List.of(request.getServerName()));\n        headers.put(\"X-Forwarded-Proto\", List.of(request.isSecure() ? \"https\" : \"http\"));\n\n        final var maxRequestBodySize = properties.gateway().maxRequestBodySize();\n        var limit = maxRequestBodySize == null ? -1 : maxRequestBodySize.toBytes();\n        if (limit > Integer.MAX_VALUE - 8) {\n            limit = Integer.MAX_VALUE - 8;\n        }\n\n        final byte[] bodyBytes;\n        try (final var inputStream = request.getInputStream()) {\n            if (limit >= 0) {\n                // Read up to limit + 1 bytes to detect if the body is too large\n                final var result = inputStream.readNBytes((int) limit + 1);\n                if (result.length > limit) {\n                    log.warn(\"Payload Too Large: subdomain {} exceeded max body size of {} (actual length > {})\",\n                        subdomain, maxRequestBodySize, limit);\n                    response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE,\n                        \"Payload Too Large: max %s allowed\".formatted(maxRequestBodySize));\n                    return;\n                }\n                bodyBytes = result;\n            } else {\n                bodyBytes = inputStream.readAllBytes();\n            }\n        }\n        final var bodyB64 = bodyBytes.length == 0 ? null : Base64.getEncoder().encodeToString(bodyBytes);\n\n        final var msg = new HttpTunnelMessage();\n        msg.setMethod(method);\n        msg.setPath(path);\n        msg.setQuery(query);\n        msg.setHeaders(headers);\n        msg.setBodyB64(bodyB64);\n        msg.setBodyContentType(request.getContentType());\n\n        try {\n            final var resp = registry.forwardRequest(subdomain, msg, Duration.ofSeconds(30)).join();\n            final var status = resp.getStatus() == null ? 502 : resp.getStatus();\n            response.setStatus(status);\n            if (resp.getRespHeaders() != null) {\n                for (final var header : resp.getRespHeaders().entrySet()) {\n                    final var name = header.getKey();\n                    final var values = header.getValue();\n                    if (name == null || values == null) {\n                        continue;\n                    }\n                    final var nameLc = name.toLowerCase();\n                    if (HOP_BY_HOP_RESPONSE_HEADERS.contains(nameLc)) {\n                        // Skip hop-by-hop or conflicting headers\n                        continue;\n                    }\n                    values.stream()\n                        .filter(Objects::nonNull)\n                        .forEach(value ->\n                            response.addHeader(name, value));\n                }\n            }\n            if (resp.getRespBodyB64() != null) {\n                final var bytes = Base64.getDecoder().decode(resp.getRespBodyB64());\n                response.getOutputStream().write(bytes);\n            }\n        } catch (final Exception ex) {\n            log.warn(\"Tunnel forward failed for subdomain={}: {}\", subdomain, ex.toString());\n            response.setStatus(HttpServletResponse.SC_BAD_GATEWAY);\n            response.getWriter().write(\"Bad Gateway: tunnel unavailable\");\n        }\n    }\n\n    private boolean isAuthorized(final String subdomain,\n                                 final UUID tunnelId,\n                                 final HttpServletRequest request,\n                                 final HttpServletResponse response) {\n\n        final var passcodeHash = tunnelService.getTempPasscodeHash(tunnelId)\n            .or(() -> domainRepository.findBySubdomain(subdomain)\n                .map(DomainEntity::getPasscodeHash))\n            .orElse(null);\n\n        // If there is no passcode configured for either the domain or the tunnel — allow access\n        if (passcodeHash == null) {\n            return true;\n        }\n\n        final var passcode = StringUtils.firstNonBlank(\n            request.getHeader(\"X-API-Key\"),\n            request.getParameter(\"passcode\"));\n\n        // If passcode provided via header or query, validate and set cookie on success\n        if (passcode != null) {\n            if (matches(passcode, passcodeHash)) {\n                issueCookie(response, subdomain, passcode);\n                return true;\n            }\n            return false;\n        }\n\n        return findCookie(request, PASSCODE_COOKIE_NAME)\n            .map(Cookie::getValue)\n            .map(cookiePasscode -> matches(cookiePasscode, passcodeHash))\n            .orElse(false);\n    }\n\n    private boolean matches(final String raw, final String hash) {\n        if (hash == null || raw == null) {\n            return false;\n        }\n        try {\n            return passwordEncoder.matches(raw, hash);\n        } catch (final Exception e) {\n            return false;\n        }\n    }\n\n    private Optional<Cookie> findCookie(final HttpServletRequest request, final String name) {\n        return Stream.ofNullable(request.getCookies())\n            .flatMap(Arrays::stream)\n            .filter(cookie -> Objects.equals(name, cookie.getName()))\n            .findFirst();\n    }\n\n    private void issueCookie(final HttpServletResponse response, final String subdomain, final String value) {\n        final var gateway = properties.gateway();\n        final var cookie = new Cookie(PASSCODE_COOKIE_NAME, value);\n        cookie.setHttpOnly(true);\n        cookie.setSecure(gateway.url().startsWith(\"https\"));\n        cookie.setPath(\"/\");\n\n        // Build a safe cookie domain: strip port and avoid setting Domain for localhost/IP to satisfy RFC6265\n        final var configuredDomain = gateway.domain();\n        final var domainWithoutPort = configuredDomain.contains(\":\")\n            ? configuredDomain.substring(0, configuredDomain.indexOf(':'))\n            : configuredDomain;\n        final var fullDomain = subdomain + \".\" + domainWithoutPort;\n\n        final var isLocalhost = \"localhost\".equalsIgnoreCase(domainWithoutPort)\n                                || domainWithoutPort.endsWith('.' + \"localhost\");\n        final var isIpv4 = domainWithoutPort.matches(\"^\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+$\");\n\n        // Only set Domain attribute for real registrable domains (no port, not localhost, not IP)\n        final var shouldSetDomain = !(isLocalhost || isIpv4);\n        if (shouldSetDomain) {\n            cookie.setDomain(fullDomain);\n        }\n\n        cookie.setMaxAge(60 * 60 * 12); // 12 hours\n        response.addCookie(cookie);\n\n        // Compose manual Set-Cookie with SameSite=Lax; add Domain only when it is valid\n        final var sb = new StringBuilder();\n        sb.append(PASSCODE_COOKIE_NAME)\n            .append(\"=\")\n            .append(value)\n            .append(\"; Path=/; Max-Age=43200; HttpOnly; SameSite=Lax\");\n        if (shouldSetDomain) {\n            sb.append(\"; Domain=\").append(fullDomain);\n        }\n        if (cookie.getSecure()) {\n            sb.append(\"; Secure\");\n        }\n        response.addHeader(\"Set-Cookie\", sb.toString());\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/IngressResolveController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.DomainRepository;\nimport tech.amak.portbuddy.server.tunnel.TunnelRegistry;\n\n/**\n * Lightweight, instance-local endpoint for the API Gateway to resolve which server instance\n * currently owns an active tunnel for the given subdomain or custom domain. Returns 200 if\n * this instance has an open tunnel for the subdomain, otherwise 404.\n * This endpoint is intentionally placed under \"/ingress/**\" which is already permitted in\n * {@link tech.amak.portbuddy.server.security.SecurityConfig} so the gateway can probe it\n * without authentication.\n */\n@RestController\n@RequestMapping(\"/ingress\")\n@RequiredArgsConstructor\npublic class IngressResolveController {\n\n    private final TunnelRegistry registry;\n    private final DomainRepository domainRepository;\n    private final AccountRepository accountRepository;\n\n    /**\n     * Checks if the given subdomain is owned by an active tunnel.\n     *\n     * @param subdomain the subdomain to check\n     * @return 200 if the subdomain is owned by an active tunnel, 404 otherwise\n     */\n    @GetMapping(\"/resolve/{subdomain}\")\n    public ResponseEntity<Void> resolveOwner(final @PathVariable(\"subdomain\") String subdomain) {\n        final var tunnel = registry.getBySubdomain(subdomain);\n        if (tunnel != null && tunnel.isOpen() && isSubscriptionActive(tunnel)) {\n            return ResponseEntity.ok().build();\n        }\n        return ResponseEntity.notFound().build();\n    }\n\n    /**\n     * Checks if the given custom domain is owned by an active tunnel.\n     *\n     * @param domain the custom domain to check\n     * @return 200 if the custom domain is owned by an active tunnel, 404 otherwise\n     */\n    @GetMapping(\"/resolve-custom/{domain}\")\n    public ResponseEntity<Void> resolveCustomOwner(final @PathVariable(\"domain\") String domain) {\n        return domainRepository.findByCustomDomain(domain)\n            .map(domainEntity -> {\n                final var tunnel = registry.getBySubdomain(domainEntity.getSubdomain());\n                if (tunnel != null && tunnel.isOpen() && isSubscriptionActive(tunnel)) {\n                    return ResponseEntity.ok().<Void>build();\n                }\n                return ResponseEntity.notFound().<Void>build();\n            })\n            .orElseGet(() -> ResponseEntity.notFound().build());\n    }\n\n    private boolean isSubscriptionActive(final TunnelRegistry.Tunnel tunnel) {\n        return accountRepository.findById(tunnel.accountId())\n            .map(account -> {\n                final var status = account.getSubscriptionStatus();\n                return status == null || \"active\".equals(status);\n            })\n            .orElse(false);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/InternalDomainController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport org.springframework.http.MediaType;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.service.DomainService;\n\n/**\n * Controller for internal domain operations.\n */\n@RestController\n@RequestMapping(path = \"/api/internal/domains\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\npublic class InternalDomainController {\n\n    private final DomainService domainService;\n\n    /**\n     * Marks the domain as SSL active.\n     *\n     * @param domain the custom domain name\n     */\n    @PostMapping(\"/ssl-active\")\n    public void markSslActive(@RequestParam(\"domain\") final String domain) {\n        domainService.markSslActive(domain);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/InternalEmailController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport java.util.HashMap;\n\nimport org.springframework.http.MediaType;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.dto.DnsInstructionsEmailRequest;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.mail.EmailService;\n\n/**\n * Controller for internal email operations.\n */\n@RestController\n@RequestMapping(path = \"/api/internal/email\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\n@Slf4j\npublic class InternalEmailController {\n\n    private final EmailService emailService;\n    private final AppProperties properties;\n\n    /**\n     * Sends DNS instructions email.\n     *\n     * @param request the DNS instructions email request\n     */\n    @PostMapping(\"/dns-instructions\")\n    public void sendDnsInstructions(@RequestBody final DnsInstructionsEmailRequest request) {\n        log.info(\"Sending DNS instructions email for job {} and domain {}\", request.getJobId(), request.getDomain());\n        \n        final var model = new HashMap<String, Object>();\n        model.put(\"domain\", request.getDomain());\n        model.put(\"records\", request.getRecords());\n        model.put(\"expiresAt\", request.getExpiresAt());\n        model.put(\"webAppUrl\", properties.gateway().url());\n        model.put(\"subject\", \"Action Required: DNS Setup for \" + request.getDomain());\n\n        emailService.sendTemplate(\n            request.getContactEmail(),\n            \"Action Required: DNS Setup for \" + request.getDomain(),\n            \"email/dns-instructions\",\n            model\n        );\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/JwksController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.List;\n\nimport org.springframework.http.CacheControl;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport com.nimbusds.jose.jwk.JWK;\nimport com.nimbusds.jose.jwk.RSAKey;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.common.dto.jwks.JwkKey;\nimport tech.amak.portbuddy.common.dto.jwks.JwksResponse;\nimport tech.amak.portbuddy.server.security.RsaKeyProvider;\n\n@RestController\n@RequiredArgsConstructor\npublic class JwksController {\n\n    private final RsaKeyProvider rsaKeyProvider;\n\n    /**\n     * Provides the public JSON Web Key Set (JWKS) for the application.\n     * This endpoint exposes the keys that clients can use to validate\n     * the signatures of issued JSON Web Tokens (JWTs).\n     * The result is cacheable for up to 5 minutes for performance optimization\n     * and convenience of clients consuming this endpoint.\n     *\n     * @return a `ResponseEntity` containing a `JwksResponse` object which\n     *         holds a list of the public JSON Web Keys available for verification.\n     */\n    @GetMapping(value = \"/.well-known/jwks.json\", produces = MediaType.APPLICATION_JSON_VALUE)\n    public ResponseEntity<JwksResponse> jwks() {\n        final var set = rsaKeyProvider.getPublicJwkSet();\n        final List<JwkKey> keys = new ArrayList<>();\n        for (final JWK jwk : set.getKeys()) {\n            if (jwk instanceof RSAKey rsa) {\n                final var pub = rsa.toPublicJWK();\n                final var json = pub.toJSONObject();\n                final var dto = new JwkKey();\n                dto.setKty((String) json.get(\"kty\"));\n                dto.setKid((String) json.get(\"kid\"));\n                dto.setUse((String) json.get(\"use\"));\n                dto.setAlg((String) json.get(\"alg\"));\n                dto.setModulus((String) json.get(\"n\"));\n                dto.setExponent((String) json.get(\"e\"));\n                keys.add(dto);\n            }\n        }\n        final var body = new JwksResponse(keys);\n        return ResponseEntity.ok()\n            .cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())\n            .body(body);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/PaymentController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport static tech.amak.portbuddy.server.security.JwtService.resolveAccountId;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.security.access.prepost.PreAuthorize;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.jwt.Jwt;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.ResponseStatus;\nimport org.springframework.web.bind.annotation.RestController;\nimport org.springframework.web.server.ResponseStatusException;\n\nimport com.stripe.exception.StripeException;\n\nimport lombok.Data;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.Plan;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.service.StripeService;\n\n@Slf4j\n@RestController\n@RequestMapping(\"/api/payments\")\n@RequiredArgsConstructor\n@PreAuthorize(\"hasAnyRole('ADMIN', 'ACCOUNT_ADMIN')\")\npublic class PaymentController {\n\n    private final StripeService stripeService;\n    private final UserRepository userRepository;\n    private final AccountRepository accountRepository;\n\n    /**\n     * Creates a checkout session for the user's account and the requested plan.\n     *\n     * @param jwt     the JWT token\n     * @param request the checkout request containing the plan\n     * @return a response containing the checkout session URL\n     * @throws StripeException if Stripe API call fails\n     */\n    @Transactional\n    @PostMapping(\"/create-checkout-session\")\n    public SessionResponse createCheckoutSession(\n        @AuthenticationPrincipal final Jwt jwt,\n        @RequestBody final CheckoutRequest request) throws StripeException {\n        final var accountId = resolveAccountId(jwt);\n        final var account = accountRepository.findById(accountId)\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"Account not found\"));\n        final var url = stripeService.createCheckoutSession(account, request.getPlan());\n        return new SessionResponse(url);\n    }\n\n    /**\n     * Creates a billing portal session for the user's account.\n     *\n     * @param jwt the JWT token\n     * @return a response containing the billing portal session URL\n     * @throws StripeException if Stripe API call fails\n     */\n    @Transactional\n    @PostMapping(\"/create-portal-session\")\n    public SessionResponse createPortalSession(@AuthenticationPrincipal final Jwt jwt) throws StripeException {\n        final var accountId = resolveAccountId(jwt);\n        final var account = accountRepository.findById(accountId)\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"Account not found\"));\n        final var url = stripeService.createPortalSession(account);\n        return new SessionResponse(url);\n    }\n\n    /**\n     * Cancels the current subscription for the user's account and resets extra tunnels.\n     *\n     * @param jwt the JWT token\n     * @throws StripeException if Stripe API call fails\n     */\n    @Transactional\n    @PostMapping(\"/cancel-subscription\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    public void cancelSubscription(@AuthenticationPrincipal final Jwt jwt) throws StripeException {\n        final var accountId = resolveAccountId(jwt);\n        final var account = accountRepository.findById(accountId)\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"Account not found\"));\n        stripeService.cancelSubscription(account);\n        account.setExtraTunnels(0);\n        account.setPlan(Plan.PRO);\n        account.setSubscriptionStatus(\"active\");\n        account.setStripeSubscriptionId(null);\n        accountRepository.save(account);\n    }\n\n    @Data\n    public static class CheckoutRequest {\n        private Plan plan;\n    }\n\n    @Data\n    @RequiredArgsConstructor\n    public static class SessionResponse {\n        private final String url;\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/PortsController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport java.util.List;\nimport java.util.UUID;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.jwt.Jwt;\nimport org.springframework.web.bind.annotation.DeleteMapping;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.PutMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.ResponseStatus;\nimport org.springframework.web.bind.annotation.RestController;\nimport org.springframework.web.server.ResponseStatusException;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.PortReservationEntity;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.service.PortReservationService;\nimport tech.amak.portbuddy.server.service.ProxyDiscoveryService;\nimport tech.amak.portbuddy.server.web.dto.PortRangeDto;\nimport tech.amak.portbuddy.server.web.dto.PortReservationDto;\nimport tech.amak.portbuddy.server.web.dto.PortReservationUpdateRequest;\n\n@RestController\n@RequestMapping(path = \"/api/ports\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\npublic class PortsController {\n\n    private final PortReservationService reservationService;\n    private final UserRepository userRepository;\n    private final ProxyDiscoveryService proxyDiscoveryService;\n    private final AppProperties properties;\n\n    /**\n     * Retrieves a list of port reservations for the authenticated user's account.\n     *\n     * @param principal the authenticated user's JWT token, which holds the user information.\n     * @return a list of {@code PortReservationDto} objects representing the port reservations for the user's account.\n     */\n    @GetMapping\n    public List<PortReservationDto> list(final @AuthenticationPrincipal Jwt principal) {\n        final var account = getAccount(principal);\n        return reservationService.getReservations(account).stream()\n            .map(PortsController::toDto)\n            .toList();\n    }\n\n    /**\n     * Creates a new port reservation for the authenticated user's account.\n     *\n     * @param principal the authenticated user's JWT token containing user details.\n     * @return a {@code PortReservationDto} object representing the newly created port reservation.\n     * @throws ResponseStatusException if the user cannot be found or is not authorized.\n     */\n    @PostMapping\n    public PortReservationDto create(final @AuthenticationPrincipal Jwt principal) {\n        final var userId = UUID.fromString(principal.getSubject());\n        final var user = userRepository.findById(userId)\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"User not found\"));\n        final var account = getAccount(principal);\n        final var reservation = reservationService.createReservation(account, user)\n            .orElseThrow(() -> new RuntimeException(\"No available ports\"));\n        return toDto(reservation);\n    }\n\n    /**\n     * Deletes a port reservation for the specified ID if it belongs to the authenticated user's account.\n     * Responds with a 204 No Content status on success.\n     *\n     * @param principal the authenticated user's JWT token, used to determine the user's account.\n     * @param id        the unique identifier of the port reservation to be deleted.\n     * @throws ResponseStatusException with a 409 Conflict status if the reservation cannot be deleted\n     *                                 due to an illegal state.\n     */\n    @DeleteMapping(\"/{id}\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    public void delete(final @AuthenticationPrincipal Jwt principal,\n                       final @PathVariable(\"id\") UUID id) {\n        final var account = getAccount(principal);\n        try {\n            reservationService.deleteReservation(id, account);\n        } catch (final IllegalStateException e) {\n            throw new ResponseStatusException(HttpStatus.CONFLICT, e.getMessage());\n        }\n    }\n\n    /**\n     * Updates an existing port reservation. If there is only one available public host, UI may send only port.\n     */\n    @PutMapping(\"/{id}\")\n    public PortReservationDto update(final @AuthenticationPrincipal Jwt principal,\n                                     final @PathVariable(\"id\") UUID id,\n                                     final @RequestBody PortReservationUpdateRequest body) {\n        final var account = getAccount(principal);\n        try {\n            final var updated = reservationService\n                .updateReservation(account, id, body.publicHost(), body.publicPort(), body.name());\n            return toDto(updated);\n        } catch (final IllegalArgumentException e) {\n            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());\n        } catch (final IllegalStateException e) {\n            throw new ResponseStatusException(HttpStatus.CONFLICT, e.getMessage());\n        }\n    }\n\n    /**\n     * Lists available tcp-proxy public hosts for selection.\n     */\n    @GetMapping(\"/hosts\")\n    public List<String> hosts() {\n        return proxyDiscoveryService.listPublicHosts();\n    }\n\n    /**\n     * Returns allowed port range for a given host. Currently same for all hosts, derived from config.\n     */\n    @GetMapping(\"/hosts/{host}/range\")\n    public PortRangeDto hostRange(@PathVariable(\"host\") final String host) {\n        // Not validating host existence here; UI should have called /hosts first\n        final var range = properties.portReservations().range();\n        return new PortRangeDto(range.min(), range.max());\n    }\n\n    private AccountEntity getAccount(final Jwt jwt) {\n        final var accountId = tech.amak.portbuddy.server.security.JwtService.resolveAccountId(jwt);\n        return userRepository.findById(UUID.fromString(jwt.getSubject()))\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"User not found\"))\n            .getAccounts().stream()\n            .filter(ua -> ua.getAccount().getId().equals(accountId))\n            .findFirst()\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, \"Account not found\"))\n            .getAccount();\n    }\n\n    private static PortReservationDto toDto(final PortReservationEntity e) {\n        return new PortReservationDto(\n            e.getId(),\n            e.getPublicHost(),\n            e.getPublicPort(),\n            e.getName(),\n            e.getCreatedAt(),\n            e.getUpdatedAt()\n        );\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/StripeWebhookController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport java.time.OffsetDateTime;\nimport java.util.Map;\nimport java.util.UUID;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestHeader;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport com.stripe.exception.SignatureVerificationException;\nimport com.stripe.model.Event;\nimport com.stripe.model.Invoice;\nimport com.stripe.model.Subscription;\nimport com.stripe.model.checkout.Session;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.common.Plan;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.StripeEventEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.StripeEventRepository;\nimport tech.amak.portbuddy.server.mail.EmailService;\nimport tech.amak.portbuddy.server.service.StripeService;\nimport tech.amak.portbuddy.server.service.StripeWebhookService;\nimport tech.amak.portbuddy.server.service.TunnelService;\n\n@Slf4j\n@RestController\n@RequestMapping(\"/api/webhooks/stripe\")\n@RequiredArgsConstructor\npublic class StripeWebhookController {\n\n    private final AccountRepository accountRepository;\n    private final StripeEventRepository stripeEventRepository;\n    private final EmailService emailService;\n    private final TunnelService tunnelService;\n    private final StripeService stripeService;\n    private final StripeWebhookService stripeWebhookService;\n    private final AppProperties properties;\n\n    /**\n     * Handles Stripe webhooks.\n     *\n     * @param payload   webhook payload\n     * @param sigHeader Stripe-Signature header\n     * @return response entity\n     */\n    @Transactional\n    @PostMapping\n    public ResponseEntity<String> handleStripeWebhook(\n        @RequestBody final String payload,\n        @RequestHeader(\"Stripe-Signature\") final String sigHeader) {\n\n        if (sigHeader == null) {\n            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(\"Missing signature\");\n        }\n\n        final Event event;\n\n        try {\n            event = stripeWebhookService.constructEvent(payload, sigHeader, properties.stripe().webhookSecret());\n        } catch (final SignatureVerificationException e) {\n            log.error(\"Invalid Stripe signature\", e);\n            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(\"Invalid signature\");\n        }\n\n        log.info(\"Received Stripe event: id={}, type={}\", event.getId(), event.getType());\n\n        if (stripeEventRepository.existsById(event.getId())) {\n            log.info(\"Stripe event {} already processed, skipping\", event.getId());\n            return ResponseEntity.ok(\"\");\n        }\n\n        final var eventEntity = new StripeEventEntity();\n        eventEntity.setId(event.getId());\n        eventEntity.setType(event.getType());\n        eventEntity.setPayload(payload);\n        eventEntity.setStatus(\"PROCESSING\");\n        eventEntity.setCreatedAt(OffsetDateTime.now());\n        stripeEventRepository.save(eventEntity);\n\n        try {\n            switch (event.getType()) {\n                case \"checkout.session.completed\":\n                    handleCheckoutSessionCompleted(event);\n                    break;\n                case \"customer.subscription.updated\":\n                case \"customer.subscription.deleted\":\n                    handleSubscriptionEvent(event);\n                    break;\n                case \"invoice.payment_failed\":\n                    handleInvoicePaymentFailed(event);\n                    break;\n                default:\n                    log.debug(\"Unhandled event type: {}\", event.getType());\n            }\n\n            eventEntity.setStatus(\"PROCESSED\");\n            eventEntity.setProcessedAt(OffsetDateTime.now());\n            stripeEventRepository.save(eventEntity);\n            log.info(\"Successfully processed Stripe event: id={}, type={}\",\n                event.getId(), event.getType());\n        } catch (final Exception e) {\n            log.error(\"Error processing Stripe event: id={}, type={}\",\n                event.getId(), event.getType(), e);\n            eventEntity.setStatus(\"FAILED\");\n            eventEntity.setErrorMessage(e.getMessage());\n            stripeEventRepository.save(eventEntity);\n            // Return 200 to Stripe to avoid retries if we've successfully stored the failure\n            // or 500 if we want Stripe to retry. Usually for processed failures 200 is better to avoid infinite loops.\n            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)\n                .body(\"Error processing webhook\");\n        }\n\n        return ResponseEntity.ok(\"\");\n    }\n\n    /**\n     * Handles checkout session completed events.\n     *\n     * @param event the event\n     */\n    private void handleCheckoutSessionCompleted(final Event event) {\n        final var session = (Session) event.getDataObjectDeserializer().getObject().orElseThrow();\n        log.info(\"Processing checkout.session.completed: sessionId={}, customerId={}\",\n            session.getId(), session.getCustomer());\n\n        final var accountIdStr = session.getMetadata().get(\"accountId\");\n        if (accountIdStr == null) {\n            log.error(\"No accountId in session metadata for session {}\", session.getId());\n            throw new RuntimeException(\"No accountId in session metadata\");\n        }\n\n        final var accountId = UUID.fromString(accountIdStr);\n        final var planStr = session.getMetadata().get(\"plan\");\n        final var extraTunnelsStr = session.getMetadata().get(\"extraTunnels\");\n        final var oldSubscriptionId = session.getMetadata().get(\"oldSubscriptionId\");\n\n        final var account = accountRepository.findById(accountId)\n            .orElseThrow(() -> new RuntimeException(\"Account not found: \" + accountId));\n\n        if (oldSubscriptionId != null) {\n            log.info(\"Cancelling old subscription {} for account {}\", oldSubscriptionId, accountId);\n            try {\n                stripeService.cancelSubscription(oldSubscriptionId);\n            } catch (final Exception e) {\n                log.error(\"Failed to cancel old subscription {}: {}\", oldSubscriptionId, e.getMessage());\n                // We don't throw here to avoid failing the whole webhook if cancellation fails\n                // although it might lead to two active subscriptions if not handled.\n                // But normally this should work.\n            }\n        }\n\n        account.setStripeCustomerId(session.getCustomer());\n        account.setStripeSubscriptionId(session.getSubscription());\n        account.setSubscriptionStatus(\"active\");\n        if (planStr != null) {\n            account.setPlan(Plan.valueOf(planStr));\n        }\n        if (extraTunnelsStr != null) {\n            account.setExtraTunnels(Integer.parseInt(extraTunnelsStr));\n        }\n        accountRepository.save(account);\n        tunnelService.enforceTunnelLimit(account);\n        log.info(\"Updated account {} with Stripe customer {} and subscription {}\",\n            accountId, session.getCustomer(), session.getSubscription());\n\n        sendSubscriptionSuccessEmail(account);\n    }\n\n    /**\n     * Sends a subscription success email.\n     *\n     * @param account the account\n     */\n    private void sendSubscriptionSuccessEmail(final AccountEntity account) {\n        final var user = account.getUsers().stream().findFirst().orElse(null);\n        if (user != null) {\n            final var plan = account.getPlan();\n            final var baseLimit = properties.subscriptions().tunnels().base().get(plan);\n\n            emailService.sendTemplate(user.getEmail(), \"Welcome to \" + plan + \" - Port Buddy\",\n                \"email/subscription-success\", Map.of(\"name\",\n                    user.getFirstName() != null ? user.getFirstName() : \"there\", \"plan\",\n                    plan.name(), \"tunnelLimit\", baseLimit, \"extraTunnels\",\n                    account.getExtraTunnels(), \"portalUrl\",\n                    properties.gateway().url() + \"/app\"));\n        }\n    }\n\n    /**\n     * Handles subscription events.\n     *\n     * @param event the event\n     */\n    private void handleSubscriptionEvent(final Event event) {\n        final var subscription = (Subscription) event.getDataObjectDeserializer().getObject().orElseThrow();\n        final var customerId = subscription.getCustomer();\n        log.info(\"Processing {}: subscriptionId={}, customerId={}, status={}\",\n            event.getType(), subscription.getId(), customerId, subscription.getStatus());\n\n        accountRepository.findByStripeCustomerId(customerId).ifPresentOrElse(account -> {\n            // Only process events for the current subscription. \n            // If the event is for a different subscription, we ignore it to avoid overwriting \n            // active subscription with status from a cancelled old one (e.g. after upgrade).\n            if (account.getStripeSubscriptionId() != null\n                && !account.getStripeSubscriptionId().equals(subscription.getId())) {\n                log.info(\"Ignoring event for subscription {} as it is not the current subscription {} for account {}\",\n                    subscription.getId(), account.getStripeSubscriptionId(), account.getId());\n                return;\n            }\n\n            final var user = account.getUsers().stream().findFirst().orElse(null);\n            final var oldPlan = account.getPlan();\n            final var oldStatus = account.getSubscriptionStatus();\n\n            account.setSubscriptionStatus(subscription.getStatus());\n            account.setStripeSubscriptionId(subscription.getId());\n\n            // Try to extract plan from subscription items if possible\n            if (subscription.getItems() != null && !subscription.getItems().getData().isEmpty()) {\n                final var priceId = subscription.getItems().getData().get(0).getPrice().getId();\n                // We could use priceId mapping, but metadata is safer if it's there\n                final var planMeta = subscription.getMetadata().get(\"plan\");\n                if (planMeta != null) {\n                    account.setPlan(Plan.valueOf(planMeta));\n                }\n            }\n\n            final var isCanceled = \"canceled\".equals(account.getSubscriptionStatus());\n\n            if (isCanceled && !\"canceled\".equals(oldStatus)) {\n                log.info(\"Subscription canceled for account {}, resetting extra tunnels to 0\", account.getId());\n                account.setExtraTunnels(0);\n                account.setPlan(Plan.PRO);\n                account.setSubscriptionStatus(\"active\");\n                account.setStripeSubscriptionId(null);\n                \n                if (user != null) {\n                    emailService.sendTemplate(user.getEmail(), \"Subscription Canceled - Port Buddy\",\n                        \"email/subscription-canceled\", Map.of(\"name\",\n                            user.getFirstName() != null ? user.getFirstName() : \"there\",\n                            \"portalUrl\", properties.gateway().url() + \"/app/billing\"));\n                }\n            }\n\n            accountRepository.save(account);\n            tunnelService.enforceTunnelLimit(account);\n            log.info(\"Updated subscription status for account {} to {}\", account.getId(), subscription.getStatus());\n\n            final var isNowActive = \"active\".equals(account.getSubscriptionStatus());\n            final var wasActive = \"active\".equals(oldStatus);\n            final var planChanged = account.getPlan() != oldPlan;\n\n            if (user != null && !isCanceled) {\n                if (planChanged || (isNowActive && !wasActive)) {\n                    final var plan = account.getPlan();\n                    final var baseLimit = properties.subscriptions().tunnels().base().get(plan);\n                    emailService.sendTemplate(user.getEmail(), \"Plan Updated - Port Buddy\",\n                        \"email/plan-changed\", Map.of(\"name\",\n                            user.getFirstName() != null ? user.getFirstName() : \"there\",\n                            \"plan\", plan.name(), \"tunnelLimit\", baseLimit,\n                            \"extraTunnels\", account.getExtraTunnels(), \"portalUrl\",\n                            properties.gateway().url() + \"/app\"));\n                }\n            }\n        }, () -> log.warn(\"Account not found for Stripe customer {}\", customerId));\n    }\n\n    /**\n     * Handles payment failed events.\n     *\n     * @param event the event\n     */\n    private void handleInvoicePaymentFailed(final Event event) {\n        final var invoice = (Invoice) event.getDataObjectDeserializer().getObject().orElseThrow();\n        final var customerId = invoice.getCustomer();\n        log.warn(\"Processing invoice.payment_failed: invoiceId={}, customerId={}\",\n            invoice.getId(), customerId);\n\n        accountRepository.findByStripeCustomerId(customerId).ifPresentOrElse(account -> {\n            account.setSubscriptionStatus(\"past_due\");\n            accountRepository.save(account);\n\n            final var user = account.getUsers().stream().findFirst().orElse(null);\n            if (user != null) {\n                log.info(\"Sending payment failed email to user: {}\", user.getEmail());\n                emailService.sendTemplate(user.getEmail(), \"Payment Failed - Port Buddy\",\n                    \"email/payment-failed\", Map.of(\"name\",\n                        user.getFirstName() != null ? user.getFirstName() : \"there\",\n                        \"amount\", String.format(\"%.2f %s\", invoice.getAmountDue() / 100.0,\n                            invoice.getCurrency().toUpperCase()),\n                        \"portalUrl\", properties.gateway().url() + \"/app/billing\"));\n            }\n        }, () -> log.warn(\"Account not found for Stripe customer {}\", customerId));\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/TeamController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport java.time.OffsetDateTime;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.stream.Collectors;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.security.access.prepost.PreAuthorize;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.jwt.Jwt;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.bind.annotation.DeleteMapping;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.ResponseStatus;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.InvitationEntity;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserAccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.service.TeamService;\n\n@RestController\n@RequestMapping(\"/api/team\")\n@RequiredArgsConstructor\n@Transactional\npublic class TeamController {\n\n    private final TeamService teamService;\n    private final UserRepository userRepository;\n    private final AccountRepository accountRepository;\n    private final UserAccountRepository userAccountRepository;\n\n    /**\n     * Returns a list of team members for the current user's account.\n     *\n     * @param jwt the JWT token\n     * @return the list of team members\n     */\n    @GetMapping(\"/members\")\n    public List<MemberDto> getMembers(@AuthenticationPrincipal final Jwt jwt) {\n        final var account = getAccount(jwt);\n        return teamService.getMembers(account).stream()\n            .map(member -> toMemberDto(member, account))\n            .collect(Collectors.toList());\n    }\n\n    /**\n     * Returns a list of pending invitations for the current user's account.\n     *\n     * @param jwt the JWT token\n     * @return the list of pending invitations\n     */\n    @GetMapping(\"/invitations\")\n    @PreAuthorize(\"hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')\")\n    public List<InvitationDto> getInvitations(@AuthenticationPrincipal final Jwt jwt) {\n        final var account = getAccount(jwt);\n        return teamService.getPendingInvitations(account).stream()\n            .map(this::toInvitationDto)\n            .collect(Collectors.toList());\n    }\n\n    /**\n     * Invites a new member to the current user's account.\n     *\n     * @param jwt     the JWT token\n     * @param request the invite request\n     * @return the created invitation\n     */\n    @PostMapping(\"/invitations\")\n    @PreAuthorize(\"hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')\")\n    public InvitationDto inviteMember(@AuthenticationPrincipal final Jwt jwt,\n                                      @RequestBody final InviteRequest request) {\n        final var account = getAccount(jwt);\n        final var user = getUser(jwt);\n        final var invitation = teamService.inviteMember(account, user, request.getEmail());\n        return toInvitationDto(invitation);\n    }\n\n    @DeleteMapping(\"/invitations/{id}\")\n    @PreAuthorize(\"hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    public void cancelInvitation(@AuthenticationPrincipal final Jwt jwt,\n                                 @PathVariable(\"id\") final UUID id) {\n        final var account = getAccount(jwt);\n        teamService.cancelInvitation(account, id);\n    }\n\n    @PostMapping(\"/invitations/{id}/resend\")\n    @PreAuthorize(\"hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    public void resendInvitation(@AuthenticationPrincipal final Jwt jwt,\n                                 @PathVariable(\"id\") final UUID id) {\n        final var account = getAccount(jwt);\n        teamService.resendInvitation(account, id);\n    }\n\n    /**\n     * Removes a member from the team.\n     *\n     * @param jwt    the JWT token\n     * @param userId the user id to remove\n     */\n    @DeleteMapping(\"/members/{userId}\")\n    @PreAuthorize(\"hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    public void removeMember(@AuthenticationPrincipal final Jwt jwt,\n                             @PathVariable(\"userId\") final UUID userId) {\n        final var account = getAccount(jwt);\n        final var user = getUser(jwt);\n        teamService.removeMember(account, userId, user);\n    }\n\n    @PostMapping(\"/accept\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    public void acceptInvitation(@AuthenticationPrincipal final Jwt jwt,\n                                 @RequestParam(\"token\") final String token) {\n        final var user = getUser(jwt);\n        teamService.acceptInvitation(token, user);\n    }\n\n    private AccountEntity getAccount(final Jwt jwt) {\n        final var accountId = tech.amak.portbuddy.server.security.JwtService.resolveAccountId(jwt);\n        return accountRepository.findById(accountId)\n            .orElseThrow(() -> new IllegalArgumentException(\"Account not found.\"));\n    }\n\n    private UserEntity getUser(final Jwt jwt) {\n        final var userId = UUID.fromString(jwt.getSubject());\n        return userRepository.findById(userId)\n            .orElseThrow(() -> new IllegalArgumentException(\"User not found.\"));\n    }\n\n    private MemberDto toMemberDto(final UserEntity user, final AccountEntity account) {\n        final var userAccount = userAccountRepository.findByUserIdAndAccountId(user.getId(), account.getId())\n            .orElseThrow(() -> new IllegalArgumentException(\"User does not belong to this account.\"));\n        return MemberDto.builder()\n            .id(user.getId())\n            .email(user.getEmail())\n            .firstName(user.getFirstName())\n            .lastName(user.getLastName())\n            .avatarUrl(user.getAvatarUrl())\n            .roles(userAccount.getRoles().stream().map(Enum::name).collect(Collectors.toSet()))\n            .joinedAt(user.getCreatedAt())\n            .build();\n    }\n\n    private InvitationDto toInvitationDto(final InvitationEntity invitation) {\n        return InvitationDto.builder()\n            .id(invitation.getId())\n            .email(invitation.getEmail())\n            .invitedBy(invitation.getInvitedBy().getEmail())\n            .createdAt(invitation.getCreatedAt())\n            .expiresAt(invitation.getExpiresAt())\n            .build();\n    }\n\n    @Data\n    @Builder\n    public static class MemberDto {\n        private UUID id;\n        private String email;\n        private String firstName;\n        private String lastName;\n        private String avatarUrl;\n        private java.util.Set<String> roles;\n        private OffsetDateTime joinedAt;\n    }\n\n    @Data\n    @Builder\n    public static class InvitationDto {\n        private UUID id;\n        private String email;\n        private String invitedBy;\n        private OffsetDateTime createdAt;\n        private OffsetDateTime expiresAt;\n    }\n\n    @Data\n    public static class InviteRequest {\n        private String email;\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/TokensController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport static tech.amak.portbuddy.server.security.JwtService.resolveAccountId;\nimport static tech.amak.portbuddy.server.security.JwtService.resolveUserId;\n\nimport java.util.List;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.jwt.Jwt;\nimport org.springframework.web.bind.annotation.DeleteMapping;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.ResponseStatus;\nimport org.springframework.web.bind.annotation.RestController;\nimport org.springframework.web.server.ResponseStatusException;\n\nimport lombok.Data;\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.service.ApiTokenService;\n\n@RestController\n@RequestMapping(path = \"/api/tokens\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\npublic class TokensController {\n\n    private final ApiTokenService apiTokenService;\n    private final UserRepository userRepository;\n\n    /**\n     * Retrieves a list of API tokens belonging to the authenticated user.\n     *\n     * @param principal the authenticated user's principal object, used to extract the user ID\n     * @return a list of {@code ApiTokenService.TokenView} objects representing the user's API tokens\n     * @throws ResponseStatusException if the authenticated user cannot be found in the database\n     */\n    @GetMapping\n    public List<ApiTokenService.TokenView> list(@AuthenticationPrincipal final Jwt principal) {\n        final var accountId = resolveAccountId(principal);\n        return apiTokenService.listTokens(accountId);\n    }\n\n    /**\n     * Creates a new API token for the authenticated user.\n     *\n     * @param principal the authenticated user's principal object, used to extract the user ID\n     * @param req       the request payload containing the label for the API token; can be null\n     * @return a {@code CreateTokenResponse} object containing the generated token ID and the token itself\n     */\n    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)\n    public CreateTokenResponse create(@AuthenticationPrincipal final Jwt principal,\n                                      @RequestBody final CreateTokenRequest req) {\n        final var userId = resolveUserId(principal);\n        final var accountId = resolveAccountId(principal);\n        final var created = apiTokenService.createToken(\n            accountId,\n            userId,\n            req == null ? null : req.getLabel());\n        return new CreateTokenResponse(created.id(), created.token());\n    }\n\n    /**\n     * Revokes an API token associated with the authenticated user's account.\n     *\n     * @param principal the authenticated user's principal object, used to extract the user ID\n     * @param id        the ID of the API token to be revoked\n     * @throws ResponseStatusException if the authenticated user cannot be found in the database\n     */\n    @DeleteMapping(\"/{id}\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    public void revoke(@AuthenticationPrincipal final Jwt principal, @PathVariable(\"id\") final String id) {\n        final var accountId = resolveAccountId(principal);\n        apiTokenService.revoke(accountId, id);\n    }\n\n    @Data\n    public static class CreateTokenRequest {\n        private String label;\n    }\n\n    public record CreateTokenResponse(String id, String token) {\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/TunnelStatusController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport java.util.UUID;\n\nimport org.springframework.http.MediaType;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.server.service.TunnelService;\n\n/**\n * Endpoints to update tunnel connection status from external components (CLI/net-proxy).\n * In HTTP mode the status is tracked automatically via the control WebSocket connected to the server.\n * In TCP mode the control channel is handled by the TCP proxy service, so the CLI reports status here.\n */\n@RestController\n@RequestMapping(path = \"/api/tunnels\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\n@Slf4j\npublic class TunnelStatusController {\n\n    private final TunnelService tunnelService;\n\n    /**\n     * Marks a tunnel as connected and updates heartbeat timestamp.\n     */\n    @PostMapping(path = \"/{tunnelId}/connected\")\n    public void connected(final @PathVariable(\"tunnelId\") UUID tunnelId) {\n        log.debug(\"Status connected for tunnelId={}\", tunnelId);\n        tunnelService.markConnected(tunnelId);\n    }\n\n    /**\n     * Updates tunnel heartbeat timestamp.\n     */\n    @PostMapping(path = \"/{tunnelId}/heartbeat\")\n    public void heartbeat(final @PathVariable(\"tunnelId\") UUID tunnelId) {\n        tunnelService.heartbeat(tunnelId);\n    }\n\n    /**\n     * Marks a tunnel as closed.\n     */\n    @PostMapping(path = \"/{tunnelId}/closed\")\n    public void closed(final @PathVariable(\"tunnelId\") UUID tunnelId) {\n        log.debug(\"Status closed for tunnelId={}\", tunnelId);\n        tunnelService.markClosed(tunnelId);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/TunnelsController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport java.util.Optional;\n\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.http.MediaType;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.jwt.Jwt;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.common.TunnelType;\nimport tech.amak.portbuddy.server.db.entity.DomainEntity;\nimport tech.amak.portbuddy.server.db.entity.PortReservationEntity;\nimport tech.amak.portbuddy.server.db.entity.TunnelEntity;\nimport tech.amak.portbuddy.server.db.entity.TunnelStatus;\nimport tech.amak.portbuddy.server.db.repo.TunnelRepository;\nimport tech.amak.portbuddy.server.security.JwtService;\n\n@RestController\n@RequestMapping(path = \"/api/tunnels\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\npublic class TunnelsController {\n\n    private final TunnelRepository tunnelRepository;\n\n    /**\n     * Retrieves a paginated list of tunnels associated with the authenticated user's account, ordered by\n     * the most recent heartbeat timestamp (nulls last) and creation date.\n     *\n     * @param principal the authenticated user principle, used to extract the user's unique identifier\n     * @param pageable  the pagination and sorting parameters\n     * @return a paginated list of {@link TunnelView} objects representing the account's tunnels\n     */\n    @GetMapping\n    public Page<TunnelView> page(final @AuthenticationPrincipal Jwt principal,\n                                 final Pageable pageable) {\n        final var accountId = JwtService.resolveAccountId(principal);\n        final Page<TunnelEntity> page = tunnelRepository\n            .pageByAccountOrderByLastHeartbeatDescNullsLast(accountId, pageable);\n        return page.map(TunnelsController::toView);\n    }\n\n    private static TunnelView toView(final TunnelEntity tunnel) {\n        final var local = tunnel.getLocalScheme() == null\n                          || tunnel.getLocalHost() == null\n                          || tunnel.getLocalPort() == null\n            ? null\n            : \"%s://%s:%s\".formatted(tunnel.getLocalScheme(), tunnel.getLocalHost(), tunnel.getLocalPort());\n\n        final String publicEndpoint;\n        if (tunnel.getType() == TunnelType.HTTP) {\n            publicEndpoint = tunnel.getPublicUrl();\n        } else {\n            final var host = tunnel.getPublicHost();\n            final var port = tunnel.getPublicPort();\n            publicEndpoint = host == null || port == null ? null : host + \":\" + port;\n        }\n\n        final var subdomain = Optional.ofNullable(tunnel.getDomain())\n            .map(DomainEntity::getSubdomain)\n            .orElse(null);\n\n        final var portReservationName = Optional.ofNullable(tunnel.getPortReservation())\n            .map(PortReservationEntity::getName)\n            .orElse(null);\n\n        return new TunnelView(\n            tunnel.getId().toString(),\n            tunnel.getType(),\n            tunnel.getStatus(),\n            local,\n            publicEndpoint,\n            tunnel.getPublicUrl(), // keep original http URL if any\n            tunnel.getPublicHost(),\n            tunnel.getPublicPort(),\n            subdomain,\n            portReservationName,\n            tunnel.getLastHeartbeatAt() == null ? null : tunnel.getLastHeartbeatAt().toString(),\n            tunnel.getCreatedAt() == null ? null : tunnel.getCreatedAt().toString()\n        );\n    }\n\n    public record TunnelView(\n        String id,\n        TunnelType type,\n        TunnelStatus status,\n        String local,\n        String publicEndpoint,\n        String publicUrl,\n        String publicHost,\n        Integer publicPort,\n        String subdomain,\n        String portReservationName,\n        String lastHeartbeatAt,\n        String createdAt\n    ) {\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/UsersController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport static tech.amak.portbuddy.server.security.JwtService.resolveUserId;\n\nimport java.time.OffsetDateTime;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.stream.Collectors;\n\nimport org.springframework.http.MediaType;\nimport org.springframework.security.access.prepost.PreAuthorize;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.jwt.Jwt;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.util.StringUtils;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PatchMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport com.stripe.exception.StripeException;\n\nimport lombok.Data;\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.common.Plan;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.UserAccountEntity;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.TunnelRepository;\nimport tech.amak.portbuddy.server.db.repo.UserAccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.security.JwtService;\nimport tech.amak.portbuddy.server.security.Oauth2SuccessHandler;\nimport tech.amak.portbuddy.server.service.StripeService;\nimport tech.amak.portbuddy.server.service.TeamService;\nimport tech.amak.portbuddy.server.service.TunnelService;\n\n@RestController\n@RequestMapping(path = \"/api/users/me\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\npublic class UsersController {\n\n    private final UserRepository userRepository;\n    private final AccountRepository accountRepository;\n    private final TunnelRepository tunnelRepository;\n    private final StripeService stripeService;\n    private final TunnelService tunnelService;\n    private final UserAccountRepository userAccountRepository;\n    private final TeamService teamService;\n    private final JwtService jwtService;\n    private final AppProperties properties;\n\n    /**\n     * User details endpoint.\n     *\n     * @return user details\n     */\n    @GetMapping(\"/details\")\n    @Transactional\n    public UserDetailsResponse details(@AuthenticationPrincipal final Jwt jwt) {\n        final var user = resolveUser(jwt);\n        final var userAccount = resolveUserAccount(jwt);\n        final var account = userAccount.getAccount();\n\n        final var details = new UserDetailsResponse();\n        final var userDto = new UserDto();\n        userDto.setId(user.getId().toString());\n        userDto.setEmail(user.getEmail());\n        userDto.setFirstName(user.getFirstName());\n        userDto.setLastName(user.getLastName());\n        userDto.setAvatarUrl(user.getAvatarUrl());\n        userDto.setRoles(userAccount.getRoles().stream().map(Enum::name).collect(Collectors.toSet()));\n        details.setUser(userDto);\n\n        details.setAccount(toAccountDto(account));\n\n        return details;\n    }\n\n    /**\n     * Updates user profile.\n     *\n     * @param jwt     principal.\n     * @param request request body.\n     * @return updated user profile.\n     */\n    @PatchMapping(path = \"/profile\", consumes = MediaType.APPLICATION_JSON_VALUE)\n    @Transactional\n    public UserDto updateProfile(@AuthenticationPrincipal final Jwt jwt,\n                                 @RequestBody final UpdateProfileRequest request) {\n\n        final var user = resolveUser(jwt);\n        final var userAccount = resolveUserAccount(jwt);\n\n        final var firstName = normalizeNullable(request == null ? null : request.getFirstName());\n        final var lastName = normalizeNullable(request == null ? null : request.getLastName());\n\n        user.setFirstName(firstName);\n        user.setLastName(lastName);\n        userRepository.save(user);\n\n        final var userDto = new UserDto();\n        userDto.setId(user.getId().toString());\n        userDto.setEmail(user.getEmail());\n        userDto.setFirstName(user.getFirstName());\n        userDto.setLastName(user.getLastName());\n        userDto.setAvatarUrl(user.getAvatarUrl());\n        userDto.setRoles(userAccount.getRoles().stream().map(Enum::name).collect(Collectors.toSet()));\n        return userDto;\n    }\n\n    /**\n     * Updates account name.\n     *\n     * @param jwt     principal.\n     * @param request request body.\n     * @return updated account name.\n     */\n    @PatchMapping(path = \"/account\", consumes = MediaType.APPLICATION_JSON_VALUE)\n    @PreAuthorize(\"hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')\")\n    @Transactional\n    public AccountDto updateAccount(@AuthenticationPrincipal final Jwt jwt,\n                                    @RequestBody final UpdateAccountRequest request) {\n        final var userAccount = resolveUserAccount(jwt);\n        final var account = userAccount.getAccount();\n\n        final var name = normalizeNullable(request == null ? null : request.getName());\n        if (!StringUtils.hasText(name)) {\n            throw new IllegalArgumentException(\"Account name must not be empty\");\n        }\n        account.setName(name);\n        accountRepository.save(account);\n\n        return toAccountDto(account);\n    }\n\n    /**\n     * Updates the number of extra tunnels for the account.\n     *\n     * @param jwt     principal.\n     * @param request request body.\n     * @return updated account details.\n     */\n    @PatchMapping(path = \"/account/tunnels\", consumes = MediaType.APPLICATION_JSON_VALUE)\n    @PreAuthorize(\"hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')\")\n    @Transactional\n    public AccountDto updateExtraTunnels(@AuthenticationPrincipal final Jwt jwt,\n                                         @RequestBody final UpdateTunnelsRequest request) throws StripeException {\n        final var userAccount = resolveUserAccount(jwt);\n        final var account = userAccount.getAccount();\n\n        final int currentExtra = account.getExtraTunnels();\n        final int requestedExtra = request.getExtraTunnels();\n\n        if (requestedExtra < 0) {\n            throw new IllegalArgumentException(\"Extra tunnels count cannot be negative\");\n        }\n\n        final int diff = requestedExtra - currentExtra;\n        if (diff == 0) {\n            return toAccountDto(account);\n        }\n\n        final int increment = properties.subscriptions().tunnels().increment().get(account.getPlan());\n\n        if (Math.abs(diff) % increment != 0) {\n            throw new IllegalArgumentException(\n                \"For %s plan, tunnels must be changed by multiples of %d\"\n                    .formatted(account.getPlan(), increment));\n        }\n\n        if (requestedExtra > 0 && account.getStripeSubscriptionId() == null) {\n            // If they don't have a subscription yet, we must create a checkout session\n            // to actually create the subscription in Stripe.\n            // We do NOT update the account in the database yet. \n            // It will be updated by the webhook after successful payment.\n\n            final var url = stripeService.createCheckoutSession(account, account.getPlan(), requestedExtra);\n            final var dto = toAccountDto(account);\n            dto.setCheckoutUrl(url);\n            return dto;\n        }\n\n        if (requestedExtra == 0 && account.getPlan() == Plan.PRO && account.getStripeSubscriptionId() != null) {\n            stripeService.cancelSubscription(account);\n            account.setSubscriptionStatus(\"canceled\");\n            account.setStripeSubscriptionId(null);\n        } else {\n            stripeService.updateExtraTunnels(account, requestedExtra);\n        }\n\n        account.setExtraTunnels(requestedExtra);\n        accountRepository.save(account);\n        tunnelService.enforceTunnelLimit(account);\n\n        return toAccountDto(account);\n    }\n\n    /**\n     * Returns the list of accounts the user belongs to.\n     *\n     * @param jwt the JWT token.\n     * @return the list of accounts.\n     */\n    @Transactional\n    @GetMapping(\"/accounts\")\n    public List<UserAccountDto> getAccounts(@AuthenticationPrincipal final Jwt jwt) {\n        final var userId = resolveUserId(jwt);\n        return userAccountRepository.findAllByUserId(userId).stream()\n            .map(ua -> UserAccountDto.builder()\n                .accountId(ua.getAccount().getId())\n                .accountName(ua.getAccount().getName())\n                .plan(ua.getAccount().getPlan())\n                .roles(ua.getRoles().stream().map(Enum::name).collect(Collectors.toSet()))\n                .lastUsedAt(ua.getLastUsedAt())\n                .build())\n            .toList();\n    }\n\n    /**\n     * Switches the current account.\n     *\n     * @param jwt       the JWT token.\n     * @param accountId the account id to switch to.\n     * @return a new JWT token.\n     */\n    @Transactional\n    @PostMapping(\"/accounts/{id}/switch\")\n    public Map<String, String> switchAccount(@AuthenticationPrincipal final Jwt jwt,\n                                             @PathVariable(\"id\") final UUID accountId) {\n        final var userId = resolveUserId(jwt);\n        final var provisioned = teamService.switchAccount(userId, accountId);\n\n        final var claims = new java.util.HashMap<String, Object>();\n        final var email = jwt.getClaimAsString(Oauth2SuccessHandler.EMAIL_CLAIM);\n        if (email != null) {\n            claims.put(Oauth2SuccessHandler.EMAIL_CLAIM, email);\n        }\n        final var name = jwt.getClaimAsString(Oauth2SuccessHandler.NAME_CLAIM);\n        if (name != null) {\n            claims.put(Oauth2SuccessHandler.NAME_CLAIM, name);\n        }\n        final var picture = jwt.getClaimAsString(Oauth2SuccessHandler.PICTURE_CLAIM);\n        if (picture != null) {\n            claims.put(Oauth2SuccessHandler.PICTURE_CLAIM, picture);\n        }\n        claims.put(Oauth2SuccessHandler.ACCOUNT_ID_CLAIM, provisioned.accountId().toString());\n        claims.put(Oauth2SuccessHandler.ACCOUNT_NAME_CLAIM, provisioned.accountName());\n        claims.put(Oauth2SuccessHandler.USER_ID_CLAIM, provisioned.userId().toString());\n\n        final var token = jwtService.createToken(claims, provisioned.userId().toString(), provisioned.roles());\n        return Map.of(\"token\", token);\n    }\n\n    private AccountDto toAccountDto(final AccountEntity account) {\n        final var dto = new AccountDto();\n        dto.setId(account.getId().toString());\n        dto.setName(account.getName());\n        dto.setPlan(account.getPlan());\n        dto.setExtraTunnels(account.getExtraTunnels());\n        dto.setSubscriptionStatus(account.getSubscriptionStatus());\n        dto.setBaseTunnels(properties.subscriptions().tunnels().base().get(account.getPlan()));\n        dto.setActiveTunnels((int) tunnelRepository.countByAccountIdAndStatusIn(\n            account.getId(), TunnelService.ACTIVE_STATUSES));\n        dto.setStripeCustomerId(account.getStripeCustomerId());\n        dto.setBlocked(account.isBlocked());\n        return dto;\n    }\n\n    private UserEntity resolveUser(final Jwt jwt) {\n        final var userId = resolveUserId(jwt);\n        return userRepository.findById(userId)\n            .orElseThrow(() -> new RuntimeException(\"User not found. Id: \" + userId));\n    }\n\n    private UserAccountEntity resolveUserAccount(final Jwt jwt) {\n        final var userId = resolveUserId(jwt);\n        final var accountId = JwtService.resolveAccountId(jwt);\n        return userAccountRepository.findByUserIdAndAccountId(userId, accountId)\n            .orElseThrow(() -> new IllegalArgumentException(\"User does not belong to this account.\"));\n    }\n\n    private static String normalizeNullable(final String value) {\n        if (value == null) {\n            return null;\n        }\n        final var trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    @Data\n    public static class UserDetailsResponse {\n        private UserDto user;\n        private AccountDto account;\n    }\n\n    @Data\n    public static class UserDto {\n        private String id;\n        private String email;\n        private String firstName;\n        private String lastName;\n        private String avatarUrl;\n        private Set<String> roles;\n    }\n\n    @Data\n    public static class AccountDto {\n        private String id;\n        private String name;\n        private Plan plan;\n        private int extraTunnels;\n        private int baseTunnels;\n        private int activeTunnels;\n        private String subscriptionStatus;\n        private String stripeCustomerId;\n        private String checkoutUrl;\n        private boolean blocked;\n    }\n\n    @Data\n    public static class UpdateProfileRequest {\n        private String firstName;\n        private String lastName;\n    }\n\n    @Data\n    public static class UpdateAccountRequest {\n        private String name;\n    }\n\n    @Data\n    public static class UpdateTunnelsRequest {\n        private int extraTunnels;\n    }\n\n    @Data\n    @lombok.Builder\n    public static class UserAccountDto {\n        private UUID accountId;\n        private String accountName;\n        private Plan plan;\n        private Set<String> roles;\n        private OffsetDateTime lastUsedAt;\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/admin/AdminAccountController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.admin;\n\nimport java.util.List;\nimport java.util.UUID;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.security.access.prepost.PreAuthorize;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.ResponseStatus;\nimport org.springframework.web.bind.annotation.RestController;\nimport org.springframework.web.server.ResponseStatusException;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.service.TunnelService;\nimport tech.amak.portbuddy.server.web.admin.dto.AdminAccountRow;\n\n@RestController\n@RequestMapping(path = \"/api/admin/accounts\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\npublic class AdminAccountController {\n\n    private final AccountRepository accountRepository;\n    private final TunnelService tunnelService;\n\n    /**\n     * Returns a list of accounts for the admin page using a single native SQL query.\n     * The list is ordered by number of active tunnels (DESC) and creation time (DESC).\n     * Accessible only for users with ADMIN role.\n     *\n     * @return list of account rows for admin table\n     */\n    @GetMapping\n    @PreAuthorize(\"hasRole('ADMIN')\")\n    public List<AdminAccountRow> listAccounts(final @RequestParam(value = \"search\", required = false) String search) {\n        return accountRepository.findAdminAccounts(search);\n    }\n\n    /**\n     * Blocks the specified account. Only users with the ADMIN role can invoke this endpoint.\n     * When an account is blocked, its users will be prevented from logging in or exchanging\n     * API tokens for access tokens.\n     *\n     * @param accountId the unique identifier of the account to block\n     */\n    @PostMapping(\"/{accountId}/block\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    @PreAuthorize(\"hasRole('ADMIN')\")\n    public void blockAccount(final @PathVariable(\"accountId\") UUID accountId) {\n        final var account = accountRepository.findById(accountId)\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, \"Account not found\"));\n        if (!account.isBlocked()) {\n            account.setBlocked(true);\n            accountRepository.save(account);\n            tunnelService.closeAllTunnels(account);\n        }\n    }\n\n    /**\n     * Unblocks the specified account. Only users with the ADMIN role can invoke this endpoint.\n     *\n     * @param accountId the unique identifier of the account to unblock\n     */\n    @PostMapping(\"/{accountId}/unblock\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    @PreAuthorize(\"hasRole('ADMIN')\")\n    public void unblockAccount(final @PathVariable(\"accountId\") UUID accountId) {\n        final var account = accountRepository.findById(accountId)\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, \"Account not found\"));\n        if (account.isBlocked()) {\n            account.setBlocked(false);\n            accountRepository.save(account);\n        }\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/admin/AdminSystemController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.admin;\n\nimport java.util.List;\n\nimport org.springframework.http.MediaType;\nimport org.springframework.security.access.prepost.PreAuthorize;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.db.entity.TunnelStatus;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.TunnelRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.web.admin.dto.AdminStatsRow;\nimport tech.amak.portbuddy.server.web.admin.dto.SystemStatsResponse;\n\n/**\n * Administrative system endpoints.\n */\n@RestController\n@RequestMapping(path = \"/api/admin\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\npublic class AdminSystemController {\n\n    private final UserRepository userRepository;\n    private final TunnelRepository tunnelRepository;\n    private final AccountRepository accountRepository;\n\n    /**\n     * Returns system-wide statistics for the admin control center.\n     * Only users with the ADMIN role can invoke this endpoint.\n     *\n     * @return a {@link SystemStatsResponse} with total users and active tunnels\n     */\n    @GetMapping(\"/stats\")\n    @PreAuthorize(\"hasRole('ADMIN')\")\n    public SystemStatsResponse getSystemStats() {\n        final var totalUsers = userRepository.count();\n        final var activeTunnels = tunnelRepository.countByStatusIn(List.of(TunnelStatus.CONNECTED));\n        final var totalAccounts = accountRepository.count();\n        return new SystemStatsResponse(totalUsers, activeTunnels, totalAccounts);\n    }\n\n    /**\n     * Returns daily system statistics for the last 30 days.\n     * Only users with the ADMIN role can invoke this endpoint.\n     *\n     * @return list of {@link AdminStatsRow} sorted by date in descending order\n     */\n    @GetMapping(\"/stats/daily\")\n    @PreAuthorize(\"hasRole('ADMIN')\")\n    public List<AdminStatsRow> getDailyStats() {\n        return accountRepository.findDailyStats();\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/admin/AdminTunnelController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.admin;\n\nimport java.util.List;\nimport java.util.UUID;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\nimport org.springframework.security.access.prepost.PreAuthorize;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.ResponseStatus;\nimport org.springframework.web.bind.annotation.RestController;\nimport org.springframework.web.server.ResponseStatusException;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.db.entity.TunnelStatus;\nimport tech.amak.portbuddy.server.db.repo.TunnelRepository;\nimport tech.amak.portbuddy.server.web.admin.dto.AdminTunnelRow;\n\n@RestController\n@RequestMapping(path = \"/api/admin/tunnels\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\npublic class AdminTunnelController {\n\n    private final TunnelRepository tunnelRepository;\n\n    /**\n     * Returns a list of active tunnels for the admin page using a single native SQL query.\n     * The list is ordered by last activity (DESC).\n     * Accessible only for users with ADMIN role.\n     *\n     * @param search optional search string to filter tunnels by public address or user\n     * @return list of tunnel rows for admin table\n     */\n    @GetMapping\n    @PreAuthorize(\"hasRole('ADMIN')\")\n    public List<AdminTunnelRow> listActiveTunnels(\n        final @RequestParam(value = \"search\", required = false) String search) {\n        return tunnelRepository.findAdminActiveTunnels(search);\n    }\n\n    /**\n     * Closes the specified tunnel. Only users with the ADMIN role can invoke this endpoint.\n     *\n     * @param tunnelId the unique identifier of the tunnel to close\n     */\n    @PostMapping(\"/{tunnelId}/close\")\n    @ResponseStatus(HttpStatus.NO_CONTENT)\n    @PreAuthorize(\"hasRole('ADMIN')\")\n    public void closeTunnel(final @PathVariable(\"tunnelId\") UUID tunnelId) {\n        final var tunnel = tunnelRepository.findById(tunnelId)\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, \"Tunnel not found\"));\n        \n        tunnel.setStatus(TunnelStatus.CLOSED);\n        tunnelRepository.save(tunnel);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/admin/AdminUserController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.admin;\n\nimport java.util.List;\n\nimport org.springframework.http.MediaType;\nimport org.springframework.security.access.prepost.PreAuthorize;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.web.admin.dto.AdminUserRow;\n\n@RestController\n@RequestMapping(path = \"/api/admin/users\", produces = MediaType.APPLICATION_JSON_VALUE)\n@RequiredArgsConstructor\npublic class AdminUserController {\n\n    private final UserRepository userRepository;\n\n    /**\n     * Returns a list of users for the admin page using a single native SQL query.\n     * The list is ordered by number of active tunnels (DESC) and creation time (DESC).\n     * Accessible only for users with ADMIN role.\n     *\n     * @param search optional search string to filter users by email, name or ID\n     * @return list of user rows for admin table\n     */\n    @GetMapping\n    @PreAuthorize(\"hasRole('ADMIN')\")\n    public List<AdminUserRow> listUsers(final @RequestParam(value = \"search\", required = false) String search) {\n        return userRepository.findAdminUsers(search);\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/admin/dto/AdminAccountRow.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.admin.dto;\n\nimport java.time.Instant;\nimport java.util.UUID;\n\n/**\n * Projection row for admin accounts list.\n * Used with a native SQL query in {@code AccountRepository}.\n */\npublic record AdminAccountRow(\n    UUID accountId,\n    String name,\n    String plan,\n    int extraTunnels,\n    long activeTunnels,\n    boolean blocked,\n    Instant createdAt) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/admin/dto/AdminStatsRow.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.admin.dto;\n\nimport java.sql.Date;\n\n/**\n * Daily statistics for the admin control center.\n *\n * @param date           the date of the statistics\n * @param newUsersCount  number of users created on this date\n * @param tunnelsCount   number of tunnels created on this date\n * @param paymentEvents  number of stripe events created on this date\n */\npublic record AdminStatsRow(\n        Date date,\n        long newUsersCount,\n        long tunnelsCount,\n        long paymentEvents\n) { }\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/admin/dto/AdminTunnelRow.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.admin.dto;\n\nimport java.time.Instant;\nimport java.util.UUID;\n\n/**\n * Projection row for admin active tunnels list.\n * Used with a native SQL query in {@code TunnelRepository}.\n */\npublic record AdminTunnelRow(\n    UUID id,\n    String type,\n    String localAddress,\n    String publicAddress,\n    Instant lastActivity,\n    String userName,\n    UUID userId,\n    UUID accountId) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/admin/dto/AdminUserRow.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.admin.dto;\n\nimport java.time.Instant;\nimport java.util.UUID;\n\n/**\n * Projection row for admin users list.\n * Used with a native SQL query in {@code UserRepository}.\n */\npublic record AdminUserRow(\n    UUID id,\n    UUID accountId,\n    String name,\n    String email,\n    long activeTunnels,\n    boolean blocked,\n    Instant createdAt) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/admin/dto/SystemStatsResponse.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.admin.dto;\n\n/**\n * System-wide statistics for the admin control center.\n *\n * @param totalUsers     total number of registered users in the system\n * @param activeTunnels  total number of currently active (connected) tunnels in the system\n * @param totalAccounts  total number of accounts in the system\n */\npublic record SystemStatsResponse(long totalUsers, long activeTunnels, long totalAccounts) { }\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/advice/GlobalExceptionHandler.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.advice;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.ProblemDetail;\nimport org.springframework.web.bind.annotation.ExceptionHandler;\nimport org.springframework.web.bind.annotation.RestControllerAdvice;\nimport org.springframework.web.server.ResponseStatusException;\nimport org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;\n\nimport lombok.extern.slf4j.Slf4j;\n\n@Slf4j\n@RestControllerAdvice\npublic class GlobalExceptionHandler extends ResponseEntityExceptionHandler {\n\n    @ExceptionHandler(ResponseStatusException.class)\n    public ProblemDetail handleResponseStatusException(final ResponseStatusException ex) {\n        log.error(ex.getMessage(), ex);\n        return ProblemDetail.forStatusAndDetail(ex.getStatusCode(), ex.getReason());\n    }\n\n    @ExceptionHandler(IllegalArgumentException.class)\n    public ProblemDetail handleIllegalArgumentException(final IllegalArgumentException ex) {\n        log.error(ex.getMessage(), ex);\n        return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());\n    }\n\n    @ExceptionHandler(Exception.class)\n    public ProblemDetail handleGlobalException(final Exception ex) {\n        log.error(ex.getMessage(), ex);\n        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());\n    }\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/dto/DomainDto.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.dto;\n\nimport java.time.OffsetDateTime;\nimport java.util.UUID;\n\npublic record DomainDto(\n    UUID id,\n    String subdomain,\n    String domain,\n    String customDomain,\n    boolean cnameVerified,\n    boolean sslActive,\n    boolean passcodeProtected,\n    OffsetDateTime createdAt,\n    OffsetDateTime updatedAt\n) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/dto/LoginRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.dto;\n\npublic record LoginRequest(String email, String password) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/dto/PasswordResetConfirm.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.dto;\n\npublic record PasswordResetConfirm(String token, String newPassword) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/dto/PasswordResetRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.dto;\n\npublic record PasswordResetRequest(String email) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/dto/PortRangeDto.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.dto;\n\n/**\n * Port range for a given tcp-proxy host.\n */\npublic record PortRangeDto(\n    int min,\n    int max\n) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/dto/PortReservationDto.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.dto;\n\nimport java.time.OffsetDateTime;\nimport java.util.UUID;\n\npublic record PortReservationDto(\n    UUID id,\n    String publicHost,\n    Integer publicPort,\n    String name,\n    OffsetDateTime createdAt,\n    OffsetDateTime updatedAt\n) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/dto/PortReservationUpdateRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.dto;\n\n/**\n * Request body to update a port reservation.\n * Both fields are optional; if a field is null, it will not be changed.\n */\npublic record PortReservationUpdateRequest(\n    String publicHost,\n    Integer publicPort,\n    String name\n) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/dto/SetPasscodeRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.dto;\n\n/**\n * Request payload for setting or updating a passcode on a domain.\n *\n * @param passcode the raw passcode value to set for the domain\n */\npublic record SetPasscodeRequest(String passcode) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/dto/UpdateCustomDomainRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.dto;\n\npublic record UpdateCustomDomainRequest(\n    String customDomain\n) {\n}\n"
  },
  {
    "path": "server/src/main/java/tech/amak/portbuddy/server/web/dto/UpdateDomainRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.dto;\n\npublic record UpdateDomainRequest(\n    String subdomain\n) {\n}\n"
  },
  {
    "path": "server/src/main/resources/application.yml",
    "content": "server:\n  port: 8090\n  compression:\n    enabled: on\n\neureka:\n  client:\n    registryFetchIntervalSeconds: 2\n    eurekaServiceUrlPollIntervalSeconds: 60\n    service-url:\n      defaultZone: ${EUREKA_ZONE:http://portbuddy:portbuddy@localhost:8761/eureka}\n\napp:\n  gateway:\n    domain: localhost:8443\n    url: https://${app.gateway.domain}\n    subdomain-url-template: https://%s.${app.gateway.domain}\n    not-found-page: ${app.gateway.url}/404\n    passcode-page: ${app.gateway.url}/passcode\n    max-request-body-size: 10MB\n  mail:\n    fromAddress: ${MAIL_FROM:no-reply@portbuddy.dev}\n    fromName: ${MAIL_FROM_NAME:Port Buddy}\n\n  port-reservations:\n    range:\n      min: 40000\n      max: 60000\n\n  tunnels:\n    heartbeat-timeout: 20s\n    check-interval: 5s\n  subscriptions:\n    grace-period: 3d\n    check-interval: 1h\n    tunnels:\n      base:\n        pro: 1\n        team: 10\n      increment:\n        pro: 1\n        team: 5\n\n  jwt:\n    issuer: port-buddy\n    ttl: 168h # 1 week\n    rsa:\n      currentKeyId: dev-key\n      keys:\n        - id: dev-key\n          publicKeyPem: ${JWT_PUBLIC_KEY:classpath:keys/dev_jwt.pub}\n          privateKeyPem: ${JWT_PRIVATE_KEY:classpath:keys/dev_jwt.pem}\n\n  web-socket:\n    max-text-message-size: 1MB\n    max-binary-message-size: 1MB\n    session-idle-timeout: 10m\n\n  cli:\n    min-version: 1.0\n\n  stripe:\n    api-key: ${STRIPE_API_KEY:sk_test_51P...}\n    webhook-secret: ${STRIPE_WEBHOOK_SECRET:whsec_...}\n    price-ids:\n      pro: ${STRIPE_PRICE_PRO:price_...}\n      team: ${STRIPE_PRICE_TEAM:price_...}\n      extra-tunnel: ${STRIPE_PRICE_EXTRA_TUNNEL:price_...}\n\nspring:\n  mvc:\n    problem-details:\n      enabled: true\n  servlet:\n    multipart:\n      max-request-size: 1GB\n      max-file-size: 1GB\n\n  application:\n    name: port-buddy-server\n  datasource:\n    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:portbuddy}\n    username: ${DB_USER:portbuddy}\n    password: ${DB_PASSWORD:portbuddy}\n  jpa:\n    hibernate:\n      ddl-auto: none\n    open-in-view: false\n  security:\n    oauth2:\n      client:\n        registration:\n          google:\n            client-id: ${OAUTH_GOOGLE_CLIENT_ID:1111111111111-2222222222222.apps.googleusercontent.com}\n            client-secret: ${OAUTH_GOOGLE_CLIENT_SECRET:secret}\n            scope: openid,profile,email\n            redirect-uri: \"${app.gateway.url}/login/oauth2/code/{registrationId}\"\n          github:\n            client-id: ${OAUTH_GITHUB_CLIENT_ID:github-client-id}\n            client-secret: ${OAUTH_GITHUB_CLIENT_SECRET:github-client-secret}\n            scope: read:user,user:email\n            redirect-uri: \"${app.gateway.url}/login/oauth2/code/{registrationId}\"\n        provider:\n          google:\n            authorization-uri: https://accounts.google.com/o/oauth2/auth\n            token-uri: https://oauth2.googleapis.com/token\n            jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs\n            user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo\n            user-name-attribute: sub\n          github:\n            authorization-uri: https://github.com/login/oauth/authorize\n            token-uri: https://github.com/login/oauth/access_token\n            user-info-uri: https://api.github.com/user\n            user-name-attribute: id\n  mail:\n    host: ${SMTP_HOST:}\n    port: ${SMTP_PORT:}\n    username: ${SMTP_USERNAME:}\n    password: ${SMTP_PASSWORD:}\n    protocol: smtp\n    default-encoding: UTF-8\n    properties:\n      mail:\n        smtp:\n          auth: true\n          starttls:\n            enable: true\n            required: true\n        mime:\n          charset: UTF-8\nmanagement:\n  endpoints:\n    web:\n      exposure:\n        include: health,info\n  endpoint:\n    health:\n      probes:\n        enabled: true\n\nlogging:\n  level:\n    root: info\n    tech.amak.portbuddy: debug\n    org.springframework.web: info\n    com.netflix.discovery: warn\n    tech.amak.portbuddy.server.service.StaleTunnelsReaper: info\n  file:\n    name: log/app.log\n  logback:\n    rollingpolicy:\n      max-history: 14\n      total-size-cap: 1000MB\n      max-file-size: 100MB\n\nthreatfox:\n  enabled: ${THREATFOX_ENABLED:false}\n  url: https://threatfox-api.abuse.ch\n  auth-key: ${THREATFOX_AUTH_KEY:}\n  fetch-interval: 1h\n\n---\nspring:\n  config:\n    activate:\n      on-profile: prod\n\napp:\n  gateway:\n    domain: ${APP_DOMAIN:portbuddy.dev}\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V10__link_http_tunnels_to_domain.sql",
    "content": "ALTER TABLE tunnels ADD COLUMN domain_id UUID;\n\nALTER TABLE tunnels ADD CONSTRAINT fk_tunnels_domain_id FOREIGN KEY (domain_id) REFERENCES domains(id);\n\nCREATE INDEX idx_tunnels_domain_id ON tunnels(domain_id);\n\n-- Backfill domain_id for existing HTTP tunnels\nUPDATE tunnels t\nSET domain_id = d.id\nFROM domains d\nWHERE t.subdomain = d.subdomain AND t.account_id = d.account_id\n  AND t.type = 'HTTP';\n\n-- Insert missing domains for orphans (if any)\nINSERT INTO domains (id, subdomain, domain, account_id, created_at, updated_at)\nSELECT gen_random_uuid(), t.subdomain, 'portbuddy.dev', t.account_id, NOW(), NOW()\nFROM tunnels t\nWHERE t.type = 'HTTP'\n  AND t.domain_id IS NULL\n  AND t.subdomain IS NOT NULL\nGROUP BY t.subdomain, t.account_id;\n\n-- Run update again to link newly created domains\nUPDATE tunnels t\nSET domain_id = d.id\nFROM domains d\nWHERE t.subdomain = d.subdomain AND t.account_id = d.account_id\n  AND t.type = 'HTTP'\n  AND t.domain_id IS NULL;\n\nALTER TABLE tunnels DROP COLUMN subdomain;\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V11__add_deleted_column_to_domains.sql",
    "content": "ALTER TABLE domains ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE;\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V12__drop_tunnel_id_from_tunnels.sql",
    "content": "-- Drop obsolete tunnel_id column; entity id (UUID) is used as the tunnel identifier now\nALTER TABLE IF EXISTS tunnels\n    DROP COLUMN IF EXISTS tunnel_id;\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V13__password_reset_tokens.sql",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nCREATE TABLE password_reset_tokens (\n    id UUID PRIMARY KEY,\n    token VARCHAR(255) NOT NULL UNIQUE,\n    user_id UUID NOT NULL REFERENCES users(id),\n    expiry_date TIMESTAMP WITH TIME ZONE NOT NULL,\n    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()\n);\n\nCREATE INDEX idx_password_reset_tokens_token ON password_reset_tokens(token);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V14__port_reservations.sql",
    "content": "CREATE TABLE port_reservations (\n    id UUID PRIMARY KEY,\n    account_id UUID NOT NULL REFERENCES accounts(id),\n    public_host VARCHAR(255) NOT NULL,\n    public_port INT NOT NULL,\n    created_at TIMESTAMPTZ NOT NULL,\n    updated_at TIMESTAMPTZ NOT NULL,\n    CONSTRAINT uq_port_reservations_host_port UNIQUE (public_host, public_port)\n);\n\nCREATE INDEX idx_port_reservations_account_id ON port_reservations(account_id);\nCREATE INDEX idx_port_reservations_public_host ON port_reservations(public_host);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V15__add_user_to_port_reservations.sql",
    "content": "-- Add creator user reference to port_reservations\nALTER TABLE port_reservations\n    ADD COLUMN IF NOT EXISTS user_id UUID NULL REFERENCES users (id);\n\n-- Index for faster lookups by user\nCREATE INDEX IF NOT EXISTS idx_port_reservations_user_id ON port_reservations (user_id);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V16__add_port_reservation_to_tunnels.sql",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\n-- Link tunnels to port_reservations\nALTER TABLE IF EXISTS tunnels\n    ADD COLUMN IF NOT EXISTS port_reservation_id UUID NULL REFERENCES port_reservations (id);\n\nCREATE INDEX IF NOT EXISTS idx_tunnels_port_reservation_id ON tunnels (port_reservation_id);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V17__soft_delete_port_reservations.sql",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\n-- Add soft delete flag and change unique constraint to only apply to non-deleted records\nALTER TABLE port_reservations\n    ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE;\n\n-- Drop old unique constraint and replace with a partial unique index\nDO $$\nBEGIN\n    IF EXISTS (\n        SELECT 1 FROM pg_constraint c\n        JOIN pg_class t ON c.conrelid = t.oid\n        WHERE t.relname = 'port_reservations' AND c.conname = 'uq_port_reservations_host_port'\n    ) THEN\n        ALTER TABLE port_reservations DROP CONSTRAINT uq_port_reservations_host_port;\n    END IF;\nEND $$;\n\nCREATE UNIQUE INDEX IF NOT EXISTS uq_port_reservations_host_port_active\n    ON port_reservations(public_host, public_port)\n    WHERE deleted = FALSE;\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V18__add_passcode_to_domains.sql",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\n-- Add optional passcode hash column to domains for securing HTTP tunnels by domain\nALTER TABLE domains\n    ADD COLUMN IF NOT EXISTS passcode_hash TEXT;\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V19__add_temp_passcode_to_tunnels.sql",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\n-- Temporary passcode hash per tunnel (set via CLI during expose)\nALTER TABLE tunnels\n    ADD COLUMN IF NOT EXISTS temp_passcode_hash TEXT;\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V1__accounts_and_users.sql",
    "content": "-- Accounts and Users schema\nCREATE TABLE IF NOT EXISTS accounts (\n    id UUID PRIMARY KEY,\n    name VARCHAR(255) NOT NULL,\n    plan VARCHAR(50) NOT NULL,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE TABLE IF NOT EXISTS users (\n    id UUID PRIMARY KEY,\n    account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,\n    email VARCHAR(320),\n    first_name VARCHAR(255),\n    last_name VARCHAR(255),\n    auth_provider VARCHAR(100) NOT NULL,\n    external_id VARCHAR(255) NOT NULL,\n    avatar_url VARCHAR(1024),\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    CONSTRAINT uq_user_provider_ext UNIQUE (auth_provider, external_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_users_account_id ON users(account_id);\nCREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V20__add_custom_domain_to_domains.sql",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nALTER TABLE domains ADD COLUMN custom_domain VARCHAR(255);\nALTER TABLE domains ADD COLUMN cname_verified BOOLEAN NOT NULL DEFAULT FALSE;\nCREATE INDEX idx_domains_custom_domain ON domains(custom_domain);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V21__add_roles_to_users.sql",
    "content": "ALTER TABLE users ADD COLUMN roles VARCHAR(255)[] NOT NULL DEFAULT '{}';\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V22__update_plans.sql",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nALTER TABLE accounts ADD COLUMN extra_tunnels INTEGER NOT NULL DEFAULT 0;\n\nUPDATE accounts SET plan = 'PRO' WHERE plan IN ('BASIC', 'INDIVIDUAL');\nUPDATE accounts SET plan = 'TEAM' WHERE plan = 'PROFESSIONAL';\nUPDATE accounts SET plan = 'PRO' WHERE plan NOT IN ('PRO', 'TEAM');\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V23__add_stripe_fields.sql",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nALTER TABLE accounts ADD COLUMN stripe_customer_id VARCHAR(255);\nALTER TABLE accounts ADD COLUMN stripe_subscription_id VARCHAR(255);\nALTER TABLE accounts ADD COLUMN subscription_status VARCHAR(50);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V24__create_stripe_events_table.sql",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nCREATE TABLE stripe_events (\n    id VARCHAR(255) PRIMARY KEY,\n    type VARCHAR(255) NOT NULL,\n    payload TEXT NOT NULL,\n    status VARCHAR(50) NOT NULL,\n    error_message TEXT,\n    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    processed_at TIMESTAMP WITH TIME ZONE\n);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V25__create_invitations_table.sql",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nCREATE TABLE invitations (\n    id UUID PRIMARY KEY,\n    account_id UUID NOT NULL REFERENCES accounts(id),\n    email VARCHAR(320) NOT NULL,\n    token VARCHAR(255) NOT NULL UNIQUE,\n    invited_by_id UUID NOT NULL REFERENCES users(id),\n    accepted_at TIMESTAMP WITH TIME ZONE,\n    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,\n    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE INDEX idx_invitations_account_id ON invitations(account_id);\nCREATE INDEX idx_invitations_token ON invitations(token);\nCREATE INDEX idx_invitations_email ON invitations(email);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V26__many_to_many_users_accounts.sql",
    "content": "/*\n * Copyright (c) 2026 AMAK Inc. All rights reserved.\n */\n\n-- V26__many_to_many_users_accounts.sql\nCREATE TABLE IF NOT EXISTS user_accounts (\n    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n    account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,\n    roles VARCHAR(255)[] NOT NULL DEFAULT '{}',\n    last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    PRIMARY KEY (user_id, account_id)\n);\n\n-- Migrate data\nINSERT INTO user_accounts (user_id, account_id, roles, created_at, updated_at)\nSELECT id, account_id, roles, created_at, updated_at FROM users;\n\n-- Drop columns from users\nALTER TABLE users DROP COLUMN account_id;\nALTER TABLE users DROP COLUMN roles;\n\n-- Update indexes\nCREATE INDEX IF NOT EXISTS idx_user_accounts_user_id ON user_accounts(user_id);\nCREATE INDEX IF NOT EXISTS idx_user_accounts_account_id ON user_accounts(account_id);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V27__add_ssl_active_to_domains.sql",
    "content": "/*\n * Copyright (c) 2026 AMAK Inc. All rights reserved.\n */\n\nALTER TABLE domains ADD COLUMN ssl_active BOOLEAN NOT NULL DEFAULT FALSE;\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V28__add_blocked_to_accounts.sql",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\nALTER TABLE accounts ADD COLUMN blocked BOOLEAN NOT NULL DEFAULT FALSE;\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V29__add_name_to_port_reservations.sql",
    "content": "\n-- Add name column to port_reservations\nALTER TABLE port_reservations ADD COLUMN name VARCHAR(255);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V2__api_keys.sql",
    "content": "-- API Keys persistent storage\nCREATE TABLE IF NOT EXISTS api_keys (\n    id UUID PRIMARY KEY,\n    user_id UUID NOT NULL,\n    label VARCHAR(255) NOT NULL,\n    token_hash VARCHAR(255) NOT NULL UNIQUE,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    last_used_at TIMESTAMPTZ NULL,\n    revoked BOOLEAN NOT NULL DEFAULT FALSE,\n    revoked_at TIMESTAMPTZ NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);\nCREATE INDEX IF NOT EXISTS idx_api_keys_created_at ON api_keys(created_at);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V30__unique_port_reservation_name.sql",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\n-- Ensure port reservation name is unique within account\n-- We use a partial index to only enforce uniqueness for non-deleted reservations and non-null names\nCREATE UNIQUE INDEX idx_port_reservations_account_id_name_unique \nON port_reservations (account_id, name) \nWHERE deleted = false AND name IS NOT NULL;\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V31__lowercase_domains.sql",
    "content": "\nUPDATE domains\nSET subdomain = LOWER(subdomain),\n    domain = LOWER(domain),\n    custom_domain = LOWER(custom_domain);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V3__tunnels.sql",
    "content": "-- Tunnels storage\nCREATE TABLE IF NOT EXISTS tunnels (\n    id UUID PRIMARY KEY,\n    tunnel_id VARCHAR(255) NOT NULL UNIQUE,\n    type VARCHAR(16) NOT NULL,\n    status VARCHAR(16) NOT NULL,\n    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n    api_key_id UUID NULL REFERENCES api_keys(id) ON DELETE SET NULL,\n\n    local_scheme VARCHAR(16) NULL,\n    local_host VARCHAR(255) NULL,\n    local_port INTEGER NULL,\n\n    public_url VARCHAR(1024) NULL,\n    public_host VARCHAR(255) NULL,\n    public_port INTEGER NULL,\n    subdomain VARCHAR(255) NULL,\n\n    last_heartbeat_at TIMESTAMPTZ NULL,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX IF NOT EXISTS idx_tunnels_user_id ON tunnels(user_id);\nCREATE INDEX IF NOT EXISTS idx_tunnels_api_key_id ON tunnels(api_key_id);\nCREATE INDEX IF NOT EXISTS idx_tunnels_status ON tunnels(status);\nCREATE INDEX IF NOT EXISTS idx_tunnels_created_at ON tunnels(created_at);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V4__shedlock_and_heartbeat_indexes.sql",
    "content": "-- ShedLock table for cluster-safe scheduling\nCREATE TABLE shedlock\n(\n    name       VARCHAR(64)  NOT NULL,\n    lock_until TIMESTAMP    NOT NULL,\n    locked_at  TIMESTAMP    NOT NULL,\n    locked_by  VARCHAR(255) NOT NULL,\n    PRIMARY KEY (name)\n);\n\n-- Helpful index to speed up stale selection/updates\nCREATE INDEX IF NOT EXISTS idx_tunnels_status_last_heartbeat ON tunnels (status, last_heartbeat_at);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V5__add_password_and_admin.sql",
    "content": "-- Add password column\nALTER TABLE users\n    ADD COLUMN IF NOT EXISTS password VARCHAR(255);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V6__create_domains_table.sql",
    "content": "CREATE TABLE domains (\n    id UUID PRIMARY KEY,\n    subdomain VARCHAR(255) NOT NULL UNIQUE,\n    domain VARCHAR(255) NOT NULL,\n    account_id UUID NOT NULL REFERENCES accounts(id),\n    created_at TIMESTAMP WITH TIME ZONE NOT NULL,\n    updated_at TIMESTAMP WITH TIME ZONE NOT NULL\n);\n\nCREATE INDEX idx_domains_account_id ON domains(account_id);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V7__link_tunnels_to_account.sql",
    "content": "ALTER TABLE tunnels ADD COLUMN account_id UUID;\n\nUPDATE tunnels t\nSET account_id = u.account_id\nFROM users u\nWHERE t.user_id = u.id;\n\nALTER TABLE tunnels ALTER COLUMN account_id SET NOT NULL;\n\nALTER TABLE tunnels ADD CONSTRAINT fk_tunnels_account_id FOREIGN KEY (account_id) REFERENCES accounts(id);\n\nCREATE INDEX idx_tunnels_account_id ON tunnels(account_id);\n\nALTER TABLE tunnels DROP COLUMN user_id;\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V8__add_user_id_to_tunnels.sql",
    "content": "ALTER TABLE tunnels ADD COLUMN user_id UUID;\n\nUPDATE tunnels t\nSET user_id = ak.user_id\nFROM api_keys ak\nWHERE t.api_key_id = ak.id;\n\nALTER TABLE tunnels ADD CONSTRAINT fk_tunnels_user_id FOREIGN KEY (user_id) REFERENCES users(id);\n\nCREATE INDEX idx_tunnels_user_id ON tunnels(user_id);\n"
  },
  {
    "path": "server/src/main/resources/db/migration/V9__link_api_keys_to_account.sql",
    "content": "ALTER TABLE api_keys ADD COLUMN account_id UUID;\n\nUPDATE api_keys k\nSET account_id = u.account_id\nFROM users u\nWHERE k.user_id = u.id;\n\nALTER TABLE api_keys ALTER COLUMN account_id SET NOT NULL;\n\nALTER TABLE api_keys ADD CONSTRAINT fk_api_keys_account_id FOREIGN KEY (account_id) REFERENCES accounts(id);\n\nCREATE INDEX idx_api_keys_account_id ON api_keys(account_id);\n"
  },
  {
    "path": "server/src/main/resources/keys/dev_jwt.pem",
    "content": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDZAc8VhagLcZWX\nI1Aw5TnfV9zlMANKD3+uc8FnEuY1Cpnq2hlV5TN2nDsNEeBXjoe2o9oi0dB/Ipi5\n3oCr7smxklCaJE5/ApfIIMzx/jwsKNx9Qz/G3cg2KlOjamLPLhSy4aK2BWGXkyzv\nO70IlJ1kEaHps+vkh8jsR6CnkcKxboLIzl8enA7P/Pp4UsQ1cqBiGnPxi3m42d1G\nu1ZcgLv/uhgyTeAdTjomm8e1ysRy/L0oTQ2+zzHmekZxFX7s8xUsYCRS743M7yOd\nC7dodNMIdu4RHowcc/vpYbHaay+5vZaceZpCQfATsVCzZ7MlFg30u7SCuBNC5BA2\ndo66jshFAgMBAAECggEABiwt/+jc7BeNJnOM1fTs8A/VT2XgFFzJWolm8aIMao/S\nNLCnwB5v4QX2DAmNtMa49Tsietx/ieCFVZnX5/nKHgw10UpT8dZY5pzShUOgdImt\nk+H9YkcmslKVCQcRgwNrQLvmA+6R0xPFoG61213pGncmgJUiSQUddBw7wOh0WWfo\nVTruvdOHnDnZqXSOsW4ZDJdzypnzuqpzol9+tmLUzCUC68NzZEj8z7CYjABxXeO7\ndRBK1N83UCriJysIKoWZvL0gYgcEVHx2J2FhWBGoTaPSvRLVtZfE9vrGSofCisI+\n+SnTn88TE0PgQCGGdSuMd97vJcldEpefVSSWvAnFYQKBgQDsYhSeQEgk8SFh1iq6\n/2o6I/dAWfqGOlMBo3+RlZEhDEwv42vVbkDanXk0CEzKFyTRVEjwKDgWgyqFRlxM\n9YkiEirT57AZ9GebUqb3PQvTiB6Lz7PXp1MUsB/WmjWcDWmvTAfLfPYKO2m+9VCg\nxfENDbksB40X/+TtMgsVkDx5+QKBgQDrBBZWGWaRzEtgnsqIu21ZODPnK9FzPh1P\nJ63MZ8Vmjbb6EvTps86Ash7qOvYmD+MdaYXTs9q0wDd7W5E+26+ZNCSQHkT0l0Sk\n22XjG2a/6qjObP0qimNQMl4l7274YaZvge4PcZFDwCLU7znGNz1rUEkeEfuGPoa6\nxySR23HzrQKBgQCL9QqGJENS9B4ywk5sh5vKrs7PIDdP0Cqjdr2qYicarSBS3lFT\nfkMR7Vj88MkegpN/CWtiHj4PPjwnyuANhPdb3+vRqYU/6NCLS2WmT1O4PAjx+Nlf\nnyd2wU0okAebzOk9LEQVPHik2EalFLRXbLtrYiu4IQRuKEnQEugzLUJRaQKBgQDL\n7OEA1suUqYOilEbD/HZ223jWF8SHzhcajyCU5Fp6kW97cSWJAFeofmaq8nySLGjz\nJZRVTZPyEXRTGvJea7vkIUW0tD87SWLr9eBj/2vaDeFqNVI8LpbciMf+/NL6vajw\nyvpp9i6JblgLEoW8RESMML8xU4NASlMYESLfWV54hQKBgQDDGYAkxLpx1kxXrqF2\nwh2JSs/CzcA3E45MEDZ2w5mIXELtHs315a8p346Kx0lcaKvOOGO0lBBZnYY6x710\nSa2CaF5vBjbS8c3UQxJ3uziyAboREwWf660owqymBiedZwzRBLb8z8eJgxDws8sR\n82ZM1EWj/bvr8o1qIxbsaQbanA==\n-----END PRIVATE KEY-----\n"
  },
  {
    "path": "server/src/main/resources/keys/dev_jwt.pub",
    "content": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2QHPFYWoC3GVlyNQMOU5\n31fc5TADSg9/rnPBZxLmNQqZ6toZVeUzdpw7DRHgV46HtqPaItHQfyKYud6Aq+7J\nsZJQmiROfwKXyCDM8f48LCjcfUM/xt3INipTo2pizy4UsuGitgVhl5Ms7zu9CJSd\nZBGh6bPr5IfI7Eegp5HCsW6CyM5fHpwOz/z6eFLENXKgYhpz8Yt5uNndRrtWXIC7\n/7oYMk3gHU46JpvHtcrEcvy9KE0Nvs8x5npGcRV+7PMVLGAkUu+NzO8jnQu3aHTT\nCHbuER6MHHP76WGx2msvub2WnHmaQkHwE7FQs2ezJRYN9Lu0grgTQuQQNnaOuo7I\nRQIDAQAB\n-----END PUBLIC KEY-----\n"
  },
  {
    "path": "server/src/main/resources/templates/email/base.html",
    "content": "<!--\n  ~ Copyright (c) 2025 AMAK Inc. All rights reserved.\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\" xmlns:th=\"http://www.thymeleaf.org\" th:fragment=\"layout(content)\">\n<head>\n    <meta charset=\"UTF-8\"/>\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"/>\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"/>\n    <title th:text=\"${subject}\">Port Buddy</title>\n    <style>\n        /* Base reset */\n        body { margin: 0; padding: 0; background: #0b0f16; -webkit-text-size-adjust: 100%; }\n        img { border: 0; outline: none; text-decoration: none; max-width: 100%; }\n        table { border-collapse: collapse; }\n        /* Container */\n        .wrapper { width: 100%; background: #0b0f16; padding: 24px 0; }\n        .container { width: 600px; margin: 0 auto; background: #0f1522; border: 1px solid #1f2937; border-radius: 12px; overflow: hidden; }\n        /* Header */\n        .header { background: linear-gradient(90deg, #1a2333 0%, #0f1522 100%); padding: 24px; text-align: center; }\n        .brand { font-family: \"JetBrains Mono\", monospace; color: #e2e8f0; font-weight: 700; font-size: 18px; letter-spacing: 0.5px; }\n        /* Content */\n        .content { padding: 24px; font-family: \"JetBrains Mono\", monospace; color: #cbd5e1; line-height: 1.65; }\n        .title { color: #f8fafc; font-size: 22px; font-weight: 700; margin: 0 0 8px 0; }\n        .muted { color: #94a3b8; }\n        .btn { display: inline-block; padding: 12px 18px; background: #16a34a; color: #0b0f16 !important; border-radius: 8px; text-decoration: none; font-weight: 700; font-size: 14px; }\n        .btn:hover { filter: brightness(1.05); }\n        .card { background: #0b0f16; border: 1px solid #1f2937; border-radius: 10px; padding: 16px; margin: 16px 0; }\n        .feat-title { color: #e2e8f0; font-weight: 700; font-size: 16px; margin: 0 0 4px 0; }\n        .feat-desc { color: #94a3b8; margin: 0; font-size: 14px; }\n        .code { background: #0b0f16; border: 1px solid #1f2937; color: #e2e8f0; font-family: \"JetBrains Mono\", monospace; font-size: 12px; padding: 10px 12px; border-radius: 8px; display: block; }\n        /* Footer */\n        .footer { padding: 16px 24px 24px 24px; text-align: center; font-family: \"JetBrains Mono\", monospace; color: #64748b; font-size: 12px; }\n        .link { color: #38bdf8; text-decoration: none; }\n        /* Responsive */\n        @media (max-width: 640px) {\n            .container { width: 92% !important; }\n            .content { padding: 20px; }\n            .header { padding: 20px; }\n        }\n    </style>\n</head>\n<body>\n<div class=\"wrapper\">\n    <table role=\"presentation\" width=\"100%\">\n        <tr>\n            <td align=\"center\">\n                <table role=\"presentation\" class=\"container\">\n                    <tr>\n                        <td class=\"header\">\n                            <div class=\"brand\">PORT BUDDY</div>\n                        </td>\n                    </tr>\n                    <tr>\n                        <td class=\"content\">\n                            <!-- Content injection point -->\n                            <th:block th:replace=\"${content}\"></th:block>\n                        </td>\n                    </tr>\n                    <tr>\n                        <td class=\"footer\">\n                            You’re receiving this email because you created a Port Buddy account.\n                            · <a class=\"link\" th:href=\"${webAppUrl}\" target=\"_blank\" rel=\"noreferrer\">Open My Account</a>\n                        </td>\n                    </tr>\n                </table>\n            </td>\n        </tr>\n    </table>\n    <!-- Preheader text -->\n    <div style=\"display:none; max-height:0; overflow:hidden; opacity:0;\">Port Buddy — share local and private services securely.</div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "server/src/main/resources/templates/email/dns-instructions.html",
    "content": "<!--\n  ~ Copyright (c) 2025 AMAK Inc. All rights reserved.\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\" xmlns:th=\"http://www.thymeleaf.org\"\n      th:replace=\"~{email/base :: layout(~{::content})}\">\n<th:block th:fragment=\"content\">\n    <h1 class=\"title\">Action Required: DNS Setup</h1>\n    <p class=\"muted\">Please add the following DNS TXT records to proceed with SSL issuance for <strong th:text=\"${domain}\">example.com</strong>.</p>\n\n    <div th:each=\"record : ${records}\" class=\"card\">\n        <div class=\"feat-title\">DNS TXT Record</div>\n        <table role=\"presentation\" width=\"100%\" style=\"margin-top:8px;\">\n            <tr>\n                <td style=\"padding-bottom: 8px;\">\n                    <div class=\"muted\" style=\"font-size:12px;\">Type</div>\n                    <div class=\"code\">TXT</div>\n                </td>\n            </tr>\n            <tr>\n                <td style=\"padding-bottom: 8px;\">\n                    <div class=\"muted\" style=\"font-size:12px;\">Name</div>\n                    <div class=\"code\" th:text=\"${record.name}\">_acme-challenge.example.com</div>\n                </td>\n            </tr>\n            <tr>\n                <td>\n                    <div class=\"muted\" style=\"font-size:12px;\">Value</div>\n                    <div class=\"code\" th:text=\"${record.value}\">challenge-value</div>\n                </td>\n            </tr>\n        </table>\n    </div>\n\n    <div th:if=\"${expiresAt}\" class=\"card\" style=\"border-color: #f59e0b;\">\n        <div class=\"feat-title\" style=\"color: #f59e0b;\">Challenge Expiration</div>\n        <p class=\"feat-desc\">This challenge will expire at: <strong th:text=\"${#temporals.format(expiresAt, 'yyyy-MM-dd HH:mm:ss Z')}\">2025-12-21</strong></p>\n    </div>\n\n    <p class=\"muted\" style=\"margin-top: 18px;\">\n        After creating the records and waiting for propagation, please confirm the setup in your dashboard or via CLI.\n    </p>\n\n    <div class=\"card\">\n        <div class=\"feat-title\">CLI Confirmation</div>\n        <code class=\"code\" th:text=\"|port-buddy ssl confirm ${domain}|\">port-buddy ssl confirm example.com</code>\n    </div>\n\n    <p style=\"margin-top: 18px;\" class=\"muted\">\n        Need help? Visit the <a class=\"link\" th:href=\"|${webAppUrl}/install|\" target=\"_blank\" rel=\"noreferrer\">Documentation</a>\n        for docs and examples.\n    </p>\n</th:block>\n</html>\n"
  },
  {
    "path": "server/src/main/resources/templates/email/password-reset-success.html",
    "content": "<!--\n  ~ Copyright (c) 2025 AMAK Inc. All rights reserved.\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\" xmlns:th=\"http://www.thymeleaf.org\"\n      th:replace=\"~{email/base :: layout(~{::content})}\">\n<th:block th:fragment=\"content\">\n    <h1 class=\"title\" th:text=\"${greeting}\">Hello!</h1>\n    <p class=\"muted\">Your Port Buddy password has been successfully reset.</p>\n\n    <p style=\"margin: 18px 0;\">\n        <a class=\"btn\" th:href=\"${webAppUrl}\" target=\"_blank\" rel=\"noreferrer\">Go to Login</a>\n    </p>\n\n    <p class=\"muted\">\n        If you did not perform this action, please contact support immediately.\n    </p>\n\n    <p class=\"muted\" style=\"margin-top: 18px;\">\n        Thanks,<br/>\n        The Port Buddy Team\n    </p>\n</th:block>\n</html>\n"
  },
  {
    "path": "server/src/main/resources/templates/email/password-reset.html",
    "content": "<!--\n  ~ Copyright (c) 2025 AMAK Inc. All rights reserved.\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\" xmlns:th=\"http://www.thymeleaf.org\"\n      th:replace=\"~{email/base :: layout(~{::content})}\">\n<th:block th:fragment=\"content\">\n    <h1 class=\"title\" th:text=\"${greeting}\">Hello!</h1>\n    <p class=\"muted\">You recently requested to reset your password for your Port Buddy account. Use the button below to reset it. This password reset is only valid for the next 1 hour.</p>\n\n    <p style=\"margin: 18px 0;\">\n        <a class=\"btn\" th:href=\"${resetLink}\" target=\"_blank\" rel=\"noreferrer\">Reset your password</a>\n    </p>\n\n    <p class=\"muted\">\n        For security, this request was received from a Port Buddy service. If you did not request a password reset, please ignore this email or contact support if you have questions.\n    </p>\n\n    <p class=\"muted\" style=\"margin-top: 18px;\">\n        Thanks,<br/>\n        The Port Buddy Team\n    </p>\n</th:block>\n</html>\n"
  },
  {
    "path": "server/src/main/resources/templates/email/payment-failed.html",
    "content": "<!--\n  ~ Copyright (c) 2025 AMAK Inc. All rights reserved.\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\" xmlns:th=\"http://www.thymeleaf.org\"\n      th:replace=\"~{email/base :: layout(~{::content})}\">\n<th:block th:fragment=\"content\">\n    <h1 class=\"title\">Action Required: Payment Failed</h1>\n    <p class=\"muted\">Hi <span th:text=\"${name}\">there</span>,</p>\n\n    <p>\n        We were unable to process your recent payment of <strong th:text=\"${amount}\">$10.00 USD</strong> for your Port Buddy subscription.\n    </p>\n\n    <p>\n        To keep your tunnels active and prevent any service interruptions, please update your payment information as soon as possible.\n    </p>\n\n    <p style=\"margin: 24px 0;\">\n        <a class=\"btn\" th:href=\"${portalUrl}\" target=\"_blank\" rel=\"noreferrer\">Update Payment Method</a>\n    </p>\n\n    <p class=\"muted\">\n        If you've already updated your payment details, you can ignore this email. We will automatically attempt to charge your card again in a few days.\n    </p>\n\n    <p class=\"muted\" style=\"margin-top: 24px;\">\n        Need help? Reply to this email or visit our <a class=\"link\" th:href=\"${portalUrl}\">Billing page</a>.\n    </p>\n</th:block>\n</html>\n"
  },
  {
    "path": "server/src/main/resources/templates/email/plan-changed.html",
    "content": "<!--\n  ~ Copyright (c) 2025 AMAK Inc. All rights reserved.\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\" xmlns:th=\"http://www.thymeleaf.org\"\n      th:replace=\"~{email/base :: layout(~{::content})}\">\n<th:block th:fragment=\"content\">\n    <h1 class=\"title\">Your Plan has Been Updated</h1>\n    <p class=\"muted\">Hi <span th:text=\"${name}\">there</span>,</p>\n\n    <p>\n        This is a confirmation that your Port Buddy plan has been updated to <strong><span th:text=\"${plan}\">Pro</span></strong>.\n    </p>\n\n    <div class=\"card\">\n        <div class=\"feat-title\">Your updated limits:</div>\n        <ul>\n            <li><strong th:text=\"${tunnelLimit}\">1</strong> active tunnels</li>\n            <li th:if=\"${extraTunnels > 0}\"><strong th:text=\"${extraTunnels}\">0</strong> extra tunnels</li>\n        </ul>\n    </div>\n\n    <p style=\"margin: 24px 0;\">\n        <a class=\"btn\" th:href=\"${portalUrl}\" target=\"_blank\" rel=\"noreferrer\">Go to Dashboard</a>\n    </p>\n\n    <p class=\"muted\">\n        If you didn't authorize this change, please contact our support team immediately.\n    </p>\n\n    <p class=\"muted\" style=\"margin-top: 24px;\">\n        Happy tunneling!<br/>\n        The Port Buddy Team\n    </p>\n</th:block>\n</html>\n"
  },
  {
    "path": "server/src/main/resources/templates/email/subscription-canceled.html",
    "content": "<!--\n  ~ Copyright (c) 2025 AMAK Inc. All rights reserved.\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\" xmlns:th=\"http://www.thymeleaf.org\"\n      th:replace=\"~{email/base :: layout(~{::content})}\">\n<th:block th:fragment=\"content\">\n    <h1 class=\"title\">Subscription Canceled</h1>\n    <p class=\"muted\">Hi <span th:text=\"${name}\">there</span>,</p>\n\n    <p>\n        Your Port Buddy subscription has been canceled. We're sorry to see you go!\n    </p>\n\n    <p>\n        Your account has been reverted to the <strong>Free</strong> plan. Any tunnels exceeding the free limit have been stopped.\n    </p>\n\n    <p>\n        If this was a mistake, or if you change your mind, you can re-subscribe at any time from your billing portal.\n    </p>\n\n    <p style=\"margin: 24px 0;\">\n        <a class=\"btn\" th:href=\"${portalUrl}\" target=\"_blank\" rel=\"noreferrer\">Go to Billing Portal</a>\n    </p>\n\n    <p class=\"muted\" style=\"margin-top: 24px;\">\n        Thank you for using Port Buddy. If you have any feedback on how we can improve, please let us know!\n    </p>\n</th:block>\n</html>\n"
  },
  {
    "path": "server/src/main/resources/templates/email/subscription-success.html",
    "content": "<!--\n  ~ Copyright (c) 2025 AMAK Inc. All rights reserved.\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\" xmlns:th=\"http://www.thymeleaf.org\"\n      th:replace=\"~{email/base :: layout(~{::content})}\">\n<th:block th:fragment=\"content\">\n    <h1 class=\"title\">Subscription Successful!</h1>\n    <p class=\"muted\">Hi <span th:text=\"${name}\">there</span>,</p>\n\n    <p>\n        Thank you for subscribing to the <strong><span th:text=\"${plan}\">Pro</span></strong> plan! Your account has been upgraded, and you now have access to all the features included in your new plan.\n    </p>\n\n    <div class=\"card\">\n        <div class=\"feat-title\">Your new limits:</div>\n        <ul>\n            <li><strong th:text=\"${tunnelLimit}\">1</strong> active tunnels</li>\n            <li th:if=\"${extraTunnels > 0}\"><strong th:text=\"${extraTunnels}\">0</strong> extra tunnels</li>\n        </ul>\n    </div>\n\n    <p style=\"margin: 24px 0;\">\n        <a class=\"btn\" th:href=\"${portalUrl}\" target=\"_blank\" rel=\"noreferrer\">Go to Dashboard</a>\n    </p>\n\n    <p class=\"muted\">\n        If you have any questions about your subscription or need help getting started, just reply to this email.\n    </p>\n\n    <p class=\"muted\" style=\"margin-top: 24px;\">\n        Happy tunneling!<br/>\n        The Port Buddy Team\n    </p>\n</th:block>\n</html>\n"
  },
  {
    "path": "server/src/main/resources/templates/email/team-invite.html",
    "content": "<!--\n  ~ Copyright (c) 2025 AMAK Inc. All rights reserved.\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\" xmlns:th=\"http://www.thymeleaf.org\"\n      th:replace=\"~{email/base :: layout(~{::content})}\">\n<th:block th:fragment=\"content\">\n    <h1 class=\"title\">You've been invited!</h1>\n    <p class=\"muted\">\n        <span th:text=\"${inviterName}\">Some User</span> has invited you to join their team\n        <strong th:text=\"${accountName}\">Account Name</strong> on Port Buddy.\n    </p>\n\n    <p>\n        By joining this team, you'll be able to share the team's tunnel limits and collaborate with other members.\n    </p>\n\n    <p style=\"margin: 24px 0;\">\n        <a class=\"btn\" th:href=\"${inviteUrl}\" target=\"_blank\" rel=\"noreferrer\">Accept Invitation</a>\n    </p>\n\n    <p class=\"muted\" style=\"font-size: 12px;\">\n        If you weren't expecting this invitation, you can safely ignore this email.\n        This invitation will expire in 7 days.\n    </p>\n</th:block>\n</html>\n"
  },
  {
    "path": "server/src/main/resources/templates/email/welcome.html",
    "content": "<!--\n  ~ Copyright (c) 2025 AMAK Inc. All rights reserved.\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\" xmlns:th=\"http://www.thymeleaf.org\"\n      th:replace=\"~{email/base :: layout(~{::content})}\">\n<th:block th:fragment=\"content\">\n    <h1 class=\"title\" th:text=\"${greeting}\">Welcome to Port Buddy!</h1>\n    <p class=\"muted\" th:text=\"${intro}\">Let’s get your local and private services online in seconds.</p>\n\n    <p>\n        Port Buddy securely exposes local or private network ports over the public internet. Share your work,\n        demo features, or collaborate with your team — without deploying.\n    </p>\n\n    <p style=\"margin: 18px 0;\">\n        <a class=\"btn\" th:href=\"${ctaUrl}\" target=\"_blank\" rel=\"noreferrer\" th:text=\"${ctaText}\">Open My Account</a>\n    </p>\n\n    <div class=\"card\">\n        <div class=\"feat-title\" th:text=\"${featuresTitle}\">What you can do with Port Buddy</div>\n        <table role=\"presentation\" width=\"100%\" style=\"margin-top:8px;\">\n            <tr>\n                <td width=\"33%\" style=\"vertical-align: top; padding-right: 8px;\">\n                    <div class=\"feat-title\" style=\"font-size:14px;\" th:text=\"${feature1Title}\">Expose HTTP apps</div>\n                    <p class=\"feat-desc\" th:text=\"${feature1Desc}\">Share your local web app with a secure public URL (HTTP & WebSocket).</p>\n                </td>\n                <td width=\"33%\" style=\"vertical-align: top; padding-right: 8px;\">\n                    <div class=\"feat-title\" style=\"font-size:14px;\" th:text=\"${feature2Title}\">Share TCP services</div>\n                    <p class=\"feat-desc\" th:text=\"${feature2Desc}\">Give teammates access to databases or any TCP service with a temporary public port.</p>\n                </td>\n                <td width=\"33%\" style=\"vertical-align: top;\">\n                    <div class=\"feat-title\" style=\"font-size:14px;\" th:text=\"${feature3Title}\">Simple CLI</div>\n                    <p class=\"feat-desc\" th:text=\"${feature3Desc}\">One command to expose a port. Auth with API token. Pro plan is free.</p>\n                </td>\n            </tr>\n        </table>\n    </div>\n\n    <div class=\"card\">\n        <div class=\"feat-title\">Quick start</div>\n        <p class=\"muted\" style=\"margin: 0 0 6px 0;\">Expose an HTTP service running on localhost:3000:</p>\n        <code class=\"code\">portbuddy 3000</code>\n        <p class=\"muted\" style=\"margin: 12px 0 6px 0;\">Expose a TCP service, e.g. PostgreSQL on 5432:</p>\n        <code class=\"code\">port-buddy tcp 5432</code>\n    </div>\n\n    <p class=\"muted\" style=\"margin-top: 18px;\">\n        Tip: generate an API token in your account and initialize the CLI once:\n    </p>\n    <code class=\"code\">port-buddy init &lt;YOUR_API_TOKEN&gt;</code>\n\n    <p style=\"margin-top: 18px;\" class=\"muted\">\n        Need help? Visit the <a class=\"link\" th:href=\"|${webAppUrl}/install|\" target=\"_blank\" rel=\"noreferrer\">Documentation</a>\n        for docs and examples.\n    </p>\n</th:block>\n</html>\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/security/Oauth2SuccessHandlerTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.security;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.io.IOException;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.function.Consumer;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.springframework.core.ParameterizedTypeReference;\nimport org.springframework.security.oauth2.client.OAuth2AuthorizedClient;\nimport org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;\nimport org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;\nimport org.springframework.security.oauth2.core.OAuth2AccessToken;\nimport org.springframework.security.oauth2.core.user.DefaultOAuth2User;\nimport org.springframework.web.client.RestClient;\n\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.service.user.MissingEmailException;\nimport tech.amak.portbuddy.server.service.user.UserProvisioningService;\n\n@ExtendWith(MockitoExtension.class)\n@org.mockito.junit.jupiter.MockitoSettings(strictness = org.mockito.quality.Strictness.LENIENT)\nclass Oauth2SuccessHandlerTest {\n\n    @Mock\n    private JwtService jwtService;\n    @Mock\n    private AppProperties properties;\n    @Mock\n    private UserProvisioningService userProvisioningService;\n    @Mock\n    private OAuth2AuthorizedClientService authorizedClientService;\n    @Mock\n    private RestClient.Builder restClientBuilder;\n    @Mock\n    private RestClient restClient;\n\n    private Oauth2SuccessHandler handler;\n\n    @Mock\n    private HttpServletRequest request;\n    @Mock\n    private HttpServletResponse response;\n\n    @BeforeEach\n    void setUp() {\n        when(restClientBuilder.build()).thenReturn(restClient);\n        handler = new Oauth2SuccessHandler(\n            jwtService,\n            properties,\n            userProvisioningService,\n            authorizedClientService,\n            restClientBuilder);\n\n        // AppProperties config\n        final var gateway = mock(AppProperties.Gateway.class);\n        when(properties.gateway()).thenReturn(gateway);\n        when(gateway.url()).thenReturn(\"http://localhost:8443\");\n    }\n\n    @Test\n    void onAuthenticationSuccess_WithEmail_ShouldProvisionAndRedirect() throws IOException {\n        final var attributes = new HashMap<String, Object>();\n        attributes.put(\"id\", \"123\");\n        attributes.put(\"email\", \"test@example.com\");\n        attributes.put(\"name\", \"Test User\");\n\n        final var principal = new DefaultOAuth2User(List.of(), attributes, \"id\");\n        final var authentication = new OAuth2AuthenticationToken(principal, List.of(), \"google\");\n\n        final var userId = UUID.randomUUID();\n        final var accountId = UUID.randomUUID();\n        when(userProvisioningService.provision(anyString(), anyString(), anyString(), anyString(), anyString(), any()))\n            .thenReturn(new UserProvisioningService.ProvisionedUser(\n                userId, accountId, \"Test Account\", Collections.emptySet()));\n        when(jwtService.createToken(any(), anyString(), any())).thenReturn(\"mock-token\");\n\n        handler.onAuthenticationSuccess(request, response, authentication);\n\n        verify(response).sendRedirect(\"http://localhost:8443/auth/callback?token=mock-token\");\n    }\n\n    @Test\n    @SuppressWarnings(\"unchecked\")\n    void onAuthenticationSuccess_MissingEmailGithub_ShouldFetchEmail() throws IOException {\n        final var attributes = new HashMap<String, Object>();\n        attributes.put(\"id\", 123);\n        attributes.put(\"name\", \"Github User\");\n\n        final var principal = new DefaultOAuth2User(List.of(), attributes, \"id\");\n        final var authentication = new OAuth2AuthenticationToken(principal, List.of(), \"github\");\n\n        final var client = mock(OAuth2AuthorizedClient.class);\n        final var accessToken = mock(OAuth2AccessToken.class);\n        when(accessToken.getTokenValue()).thenReturn(\"gh-token\");\n        when(client.getAccessToken()).thenReturn(accessToken);\n        when(authorizedClientService.loadAuthorizedClient(eq(\"github\"), anyString())).thenReturn(client);\n\n        // Mock RestClient calls\n        final var getSpec = mock(RestClient.RequestHeadersUriSpec.class);\n        final var headersSpec = mock(RestClient.RequestHeadersSpec.class);\n        final var responseSpec = mock(RestClient.ResponseSpec.class);\n\n        when(restClient.get()).thenReturn(getSpec);\n        when(getSpec.uri(\"https://api.github.com/user/emails\")).thenReturn(headersSpec);\n        when(headersSpec.headers(any(Consumer.class))).thenReturn(headersSpec);\n        when(headersSpec.retrieve()).thenReturn(responseSpec);\n\n        final var emails = List.of(\n            Map.of(\"email\", \"primary@example.com\", \"primary\", true, \"verified\", true)\n        );\n        when(responseSpec.body(any(ParameterizedTypeReference.class))).thenReturn(emails);\n\n        final var userId = UUID.randomUUID();\n        final var accountId = UUID.randomUUID();\n        when(userProvisioningService.provision(\n            eq(\"github\"), eq(\"123\"), eq(\"primary@example.com\"), anyString(), anyString(), any()))\n            .thenReturn(new UserProvisioningService.ProvisionedUser(\n                userId, accountId, \"Test Account\", Collections.emptySet()));\n        when(jwtService.createToken(any(), anyString(), any())).thenReturn(\"mock-token\");\n\n        handler.onAuthenticationSuccess(request, response, authentication);\n\n        verify(response).sendRedirect(\"http://localhost:8443/auth/callback?token=mock-token\");\n    }\n\n    @Test\n    void onAuthenticationSuccess_MissingEmailNotGithub_ShouldRedirectWithError() throws IOException {\n        final var attributes = new HashMap<String, Object>();\n        attributes.put(\"id\", \"123\");\n        attributes.put(\"name\", \"Other User\");\n\n        final var principal = new DefaultOAuth2User(List.of(), attributes, \"id\");\n        final var authentication = new OAuth2AuthenticationToken(principal, List.of(), \"other-provider\");\n\n        when(userProvisioningService.provision(anyString(), anyString(), eq(null), anyString(), anyString(), any()))\n            .thenThrow(new MissingEmailException(\"Email is required\"));\n\n        handler.onAuthenticationSuccess(request, response, authentication);\n\n        verify(response).sendRedirect(org.mockito.ArgumentMatchers.contains(\"error=missing_email\"));\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/service/DomainServiceTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.springframework.security.crypto.password.PasswordEncoder;\nimport org.springframework.util.unit.DataSize;\n\nimport tech.amak.portbuddy.server.client.SslServiceClient;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.DomainEntity;\nimport tech.amak.portbuddy.server.db.entity.TunnelStatus;\nimport tech.amak.portbuddy.server.db.repo.DomainRepository;\nimport tech.amak.portbuddy.server.db.repo.TunnelRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\n\n@ExtendWith(MockitoExtension.class)\nclass DomainServiceTest {\n\n    @Mock\n    private DomainRepository domainRepository;\n\n    @Mock\n    private UserRepository userRepository;\n\n    @Mock\n    private TunnelRepository tunnelRepository;\n    @Mock\n    private PasswordEncoder passwordEncoder;\n    @Mock\n    private SslServiceClient sslServiceClient;\n\n    private DomainService domainService;\n    private AccountEntity account;\n\n    @BeforeEach\n    void setUp() {\n        final var gateway = new AppProperties.Gateway(\n            \"url\",\n            \"portbuddy.dev\",\n            \"https\",\n            \"https://portbuddy.dev/404\",\n            \"https://portbuddy.dev/passcode\",\n            DataSize.ofMegabytes(10));\n        final var mail = new AppProperties.Mail(\"no-reply@localhost\", \"Port Buddy\");\n        final var portReservations =\n            new AppProperties.PortReservations(new AppProperties.PortReservations.Range(40000, 60000));\n        final var appProps = new AppProperties(\n            gateway,\n            null,\n            null,\n            mail,\n            new AppProperties.Cli(\"1.0\"),\n            portReservations,\n            null,\n            null);\n\n        domainService = new DomainService(\n            domainRepository,\n            tunnelRepository,\n            appProps,\n            passwordEncoder,\n            sslServiceClient,\n            userRepository);\n        account = new AccountEntity();\n        account.setId(UUID.randomUUID());\n    }\n\n    @Test\n    void createDomain_Success() {\n        when(domainRepository.existsBySubdomainGlobal(anyString())).thenReturn(false);\n        when(domainRepository.save(any(DomainEntity.class))).thenAnswer(i -> i.getArguments()[0]);\n\n        final var domainOpt = domainService.createDomain(account);\n\n        assertTrue(domainOpt.isPresent());\n        final var domain = domainOpt.get();\n        assertEquals(\"portbuddy.dev\", domain.getDomain());\n        assertNotNull(domain.getSubdomain());\n        verify(domainRepository).save(any(DomainEntity.class));\n    }\n\n    @Test\n    void createDomain_RetryOnCollision() {\n        // First attempt collision, second success\n        when(domainRepository.existsBySubdomainGlobal(anyString()))\n            .thenReturn(true)\n            .thenReturn(false);\n        when(domainRepository.save(any(DomainEntity.class))).thenAnswer(i -> i.getArguments()[0]);\n\n        final var domain = domainService.createDomain(account);\n\n        assertTrue(domain.isPresent());\n        // Called 3 times: 1st loop (collision), 2nd loop (success), post-loop check (success)\n        verify(domainRepository, times(3)).existsBySubdomainGlobal(anyString());\n    }\n\n    @Test\n    void updateDomain_Success() {\n        final var id = UUID.randomUUID();\n        final var domain = new DomainEntity();\n        domain.setId(id);\n        domain.setSubdomain(\"old\");\n        domain.setAccount(account);\n\n        when(domainRepository.findByIdAndAccount(id, account)).thenReturn(Optional.of(domain));\n        when(tunnelRepository.existsByDomainAndStatusNot(domain, TunnelStatus.CLOSED)).thenReturn(false);\n        when(domainRepository.existsBySubdomainGlobal(\"new\")).thenReturn(false);\n        when(domainRepository.save(any(DomainEntity.class))).thenAnswer(i -> i.getArguments()[0]);\n\n        final var updated = domainService.updateDomain(id, account, \"new\");\n\n        assertEquals(\"new\", updated.getSubdomain());\n        verify(domainRepository).save(domain);\n    }\n\n    @Test\n    void updateDomain_ActiveTunnel() {\n        final var id = UUID.randomUUID();\n        final var domain = new DomainEntity();\n        domain.setId(id);\n        domain.setSubdomain(\"old\");\n\n        when(domainRepository.findByIdAndAccount(id, account)).thenReturn(Optional.of(domain));\n        when(tunnelRepository.existsByDomainAndStatusNot(domain, TunnelStatus.CLOSED)).thenReturn(true);\n\n        assertThrows(RuntimeException.class, () -> domainService.updateDomain(id, account, \"new\"));\n    }\n\n    @Test\n    void deleteDomain_Success() {\n        final var id = UUID.randomUUID();\n        final var domain = new DomainEntity();\n        domain.setId(id);\n        domain.setSubdomain(\"foo\");\n\n        when(domainRepository.findByIdAndAccount(id, account)).thenReturn(Optional.of(domain));\n        when(tunnelRepository.existsByDomainAndStatusNot(domain, TunnelStatus.CLOSED)).thenReturn(false);\n\n        domainService.deleteDomain(id, account);\n\n        verify(domainRepository).delete(domain);\n    }\n\n    @Test\n    void deleteCustomDomain_Success() {\n        final var id = UUID.randomUUID();\n        final var domain = new DomainEntity();\n        domain.setId(id);\n        domain.setCustomDomain(\"custom.com\");\n        domain.setCnameVerified(true);\n        domain.setAccount(account);\n\n        when(domainRepository.findByIdAndAccount(id, account)).thenReturn(Optional.of(domain));\n\n        domainService.deleteCustomDomain(id, account);\n\n        assertNull(domain.getCustomDomain());\n        assertFalse(domain.isCnameVerified());\n        verify(domainRepository).save(domain);\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/service/PaymentCleanupServiceTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Duration;\nimport java.time.OffsetDateTime;\nimport java.util.List;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\n\n@ExtendWith(MockitoExtension.class)\nclass PaymentCleanupServiceTest {\n\n    @Mock\n    private AccountRepository accountRepository;\n    @Mock\n    private TunnelService tunnelService;\n    @Mock\n    private AppProperties appProperties;\n    @Mock\n    private AppProperties.Subscriptions subscriptions;\n\n    private PaymentCleanupService paymentCleanupService;\n\n    @BeforeEach\n    void setUp() {\n        when(appProperties.subscriptions()).thenReturn(subscriptions);\n        paymentCleanupService = new PaymentCleanupService(accountRepository, tunnelService, appProperties);\n    }\n\n    @Test\n    void cleanupFailedPayments_FindsAccounts_FreezesTunnels() {\n        final var gracePeriod = Duration.ofDays(3);\n        when(subscriptions.gracePeriod()).thenReturn(gracePeriod);\n\n        final var account = new AccountEntity();\n        account.setId(UUID.randomUUID());\n        account.setSubscriptionStatus(\"past_due\");\n\n        when(accountRepository.findBySubscriptionStatusNotActiveAndUpdatedAtBefore(any(OffsetDateTime.class)))\n            .thenReturn(List.of(account));\n\n        paymentCleanupService.cleanupFailedPayments();\n\n        verify(tunnelService).closeAllTunnels(account);\n    }\n\n    @Test\n    void cleanupFailedPayments_NoAccounts_DoesNothing() {\n        final var gracePeriod = Duration.ofDays(3);\n        when(subscriptions.gracePeriod()).thenReturn(gracePeriod);\n\n        when(accountRepository.findBySubscriptionStatusNotActiveAndUpdatedAtBefore(any(OffsetDateTime.class)))\n            .thenReturn(List.of());\n\n        paymentCleanupService.cleanupFailedPayments();\n\n        verify(tunnelService, never()).closeAllTunnels(any());\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/service/PortReservationServiceTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.Mockito.when;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.PortReservationEntity;\nimport tech.amak.portbuddy.server.db.entity.TunnelStatus;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.db.repo.PortReservationRepository;\nimport tech.amak.portbuddy.server.db.repo.TunnelRepository;\n\n@ExtendWith(MockitoExtension.class)\nclass PortReservationServiceTest {\n\n    @Mock\n    private PortReservationRepository repository;\n    @Mock\n    private ProxyDiscoveryService proxyDiscoveryService;\n    @Mock\n    private TunnelRepository tunnelRepository;\n    @Mock\n    private AppProperties properties;\n\n    private PortReservationService service;\n    private AccountEntity account;\n    private UserEntity user;\n\n    @BeforeEach\n    void setUp() {\n        service = new PortReservationService(repository, proxyDiscoveryService, tunnelRepository, properties);\n        account = new AccountEntity();\n        account.setId(UUID.randomUUID());\n        user = new UserEntity();\n        user.setId(UUID.randomUUID());\n    }\n\n    @Test\n    void resolveForNetExpose_ExplicitHostPort_Success() {\n        final String host = \"proxy-1.portbuddy.dev\";\n        final int port = 12345;\n        final String explicit = host + \":\" + port;\n        final var reservation = new PortReservationEntity();\n        reservation.setPublicHost(host);\n        reservation.setPublicPort(port);\n\n        when(repository.findByAccountAndPublicHostAndPublicPort(account, host, port))\n            .thenReturn(Optional.of(reservation));\n        when(tunnelRepository.existsByPortReservationAndStatusNot(reservation, TunnelStatus.CLOSED))\n            .thenReturn(false);\n\n        final var result = service.resolveForNetExpose(account, user, \"localhost\", 8080, explicit);\n\n        assertEquals(reservation, result);\n    }\n\n    @Test\n    void resolveForNetExpose_ExplicitName_Success() {\n        final String name = \"my-reservation\";\n        final var reservation = new PortReservationEntity();\n        reservation.setName(name);\n\n        when(repository.findByAccountAndNameIgnoreCase(account, name))\n            .thenReturn(Optional.of(reservation));\n        when(tunnelRepository.existsByPortReservationAndStatusNot(reservation, TunnelStatus.CLOSED))\n            .thenReturn(false);\n\n        final var result = service.resolveForNetExpose(account, user, \"localhost\", 8080, name);\n\n        assertEquals(reservation, result);\n    }\n\n    @Test\n    void resolveForNetExpose_ExplicitPortOnly_MultipleFound_TakesFirst() {\n        final int port = 12345;\n        final String explicit = String.valueOf(port);\n\n        final var reservation1 = new PortReservationEntity();\n        reservation1.setPublicHost(\"proxy-1.portbuddy.dev\");\n        reservation1.setPublicPort(port);\n\n        final var reservation2 = new PortReservationEntity();\n        reservation2.setPublicHost(\"proxy-2.portbuddy.dev\");\n        reservation2.setPublicPort(port);\n\n        when(repository.findAllByAccountAndPublicPort(account, port))\n            .thenReturn(List.of(reservation1, reservation2));\n        when(tunnelRepository.existsByPortReservationAndStatusNot(reservation1, TunnelStatus.CLOSED))\n            .thenReturn(false);\n\n        final var result = service.resolveForNetExpose(account, user, \"localhost\", 8080, explicit);\n\n        assertEquals(reservation1, result);\n    }\n\n    @Test\n    void resolveForNetExpose_ExplicitPortOnly_NotFound_ThrowsException() {\n        final int port = 12345;\n        final String explicit = String.valueOf(port);\n\n        when(repository.findAllByAccountAndPublicPort(account, port))\n            .thenReturn(List.of());\n\n        assertThrows(IllegalArgumentException.class, () ->\n            service.resolveForNetExpose(account, user, \"localhost\", 8080, explicit));\n    }\n\n    @Test\n    void updateReservation_DuplicateName_ThrowsException() {\n        final UUID id = UUID.randomUUID();\n        final String name = \"duplicate-name\";\n        final var existing = new PortReservationEntity();\n        existing.setName(\"old-name\");\n\n        when(repository.findByIdAndAccount(id, account)).thenReturn(Optional.of(existing));\n        when(repository.existsByAccountAndName(account, name)).thenReturn(true);\n\n        assertThrows(IllegalArgumentException.class, () ->\n            service.updateReservation(account, id, null, null, name));\n    }\n\n    @Test\n    void updateReservation_SameName_Success() {\n        final UUID id = UUID.randomUUID();\n        final String name = \"same-name\";\n        final var existing = new PortReservationEntity();\n        existing.setName(name);\n\n        when(repository.findByIdAndAccount(id, account)).thenReturn(Optional.of(existing));\n        when(repository.saveAndFlush(existing)).thenReturn(existing);\n\n        final var result = service.updateReservation(account, id, null, null, name);\n        assertEquals(name, result.getName());\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/service/StaleTunnelsReaperTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Duration;\nimport java.time.OffsetDateTime;\nimport java.util.List;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.Test;\n\nimport tech.amak.portbuddy.server.config.TunnelsProperties;\nimport tech.amak.portbuddy.server.db.repo.TunnelRepository;\nimport tech.amak.portbuddy.server.tunnel.TunnelRegistry;\n\nclass StaleTunnelsReaperTest {\n\n    @Test\n    void shouldCloseTunnelsInRegistryWhenReaped() {\n        // Given\n        final var tunnelRepository = mock(TunnelRepository.class);\n        final var tunnelsProperties = new TunnelsProperties();\n        tunnelsProperties.setHeartbeatTimeout(Duration.ofMinutes(1));\n        final var tunnelRegistry = mock(TunnelRegistry.class);\n        final var reaper = new StaleTunnelsReaper(tunnelRepository, tunnelsProperties, tunnelRegistry);\n\n        final var tunnelId = UUID.randomUUID();\n        when(tunnelRepository.closeStaleConnected(any(OffsetDateTime.class)))\n            .thenReturn(List.of(tunnelId));\n\n        // When\n        reaper.closeStaleTunnels();\n\n        // Then\n        verify(tunnelRegistry).closeTunnel(tunnelId);\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/service/TunnelServiceTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.service;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport tech.amak.portbuddy.common.Plan;\nimport tech.amak.portbuddy.common.TunnelType;\nimport tech.amak.portbuddy.common.dto.ExposeRequest;\nimport tech.amak.portbuddy.server.client.NetProxyClient;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.DomainEntity;\nimport tech.amak.portbuddy.server.db.entity.TunnelEntity;\nimport tech.amak.portbuddy.server.db.entity.TunnelStatus;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.TunnelRepository;\nimport tech.amak.portbuddy.server.tunnel.TunnelRegistry;\n\n@ExtendWith(MockitoExtension.class)\nclass TunnelServiceTest {\n\n    @Mock\n    private TunnelRepository tunnelRepository;\n    @Mock\n    private AccountRepository accountRepository;\n    @Mock\n    private TunnelRegistry tunnelRegistry;\n    @Mock\n    private NetProxyClient netProxyClient;\n\n    private TunnelService tunnelService;\n    private AccountEntity account;\n\n    @BeforeEach\n    void setUp() {\n        final var properties = new AppProperties(\n            null, null, null, null, null, null,\n            new AppProperties.Subscriptions(\n                Duration.ofDays(3),\n                Duration.ofHours(1),\n                new AppProperties.Subscriptions.Tunnels(\n                    Map.of(Plan.PRO, 1, Plan.TEAM, 10), Map.of(Plan.PRO, 1, Plan.TEAM, 5))),\n            null\n        );\n        tunnelService = new TunnelService(\n            tunnelRepository, accountRepository, properties, Optional.empty(), tunnelRegistry, netProxyClient);\n        account = new AccountEntity();\n        account.setId(UUID.randomUUID());\n        account.setPlan(Plan.PRO);\n        account.setSubscriptionStatus(\"active\");\n    }\n\n    @Test\n    void checkTunnelLimit_ActiveSubscription_Success() {\n        when(tunnelRepository.countByAccountIdAndStatusIn(any(), any())).thenReturn(0L);\n        assertDoesNotThrow(() -> tunnelService.createHttpTunnel(\n            account, UUID.randomUUID(), null, createRequest(), \"http://abc.pb.dev\", new DomainEntity()));\n    }\n\n    @Test\n    void checkTunnelLimit_InactiveSubscription_ThrowsException() {\n        account.setSubscriptionStatus(\"past_due\");\n        assertThrows(IllegalStateException.class, () -> tunnelService.createHttpTunnel(\n            account, UUID.randomUUID(), null, createRequest(), \"http://abc.pb.dev\", new DomainEntity()));\n    }\n\n    @Test\n    void markConnected_InactiveSubscription_ThrowsException() {\n        final var tunnelId = UUID.randomUUID();\n        final var tunnel = new TunnelEntity();\n        tunnel.setId(tunnelId);\n        tunnel.setAccountId(account.getId());\n\n        account.setSubscriptionStatus(\"canceled\");\n\n        when(tunnelRepository.findById(tunnelId)).thenReturn(Optional.of(tunnel));\n        when(accountRepository.findById(account.getId())).thenReturn(Optional.of(account));\n\n        assertThrows(IllegalStateException.class, () -> tunnelService.markConnected(tunnelId));\n    }\n\n    @Test\n    void heartbeat_InactiveSubscription_ThrowsException() {\n        final var tunnelId = UUID.randomUUID();\n        final var tunnel = new TunnelEntity();\n        tunnel.setId(tunnelId);\n        tunnel.setAccountId(account.getId());\n\n        account.setSubscriptionStatus(\"past_due\");\n\n        when(tunnelRepository.findById(tunnelId)).thenReturn(Optional.of(tunnel));\n        when(accountRepository.findById(account.getId())).thenReturn(Optional.of(account));\n\n        assertThrows(IllegalStateException.class, () -> tunnelService.heartbeat(tunnelId));\n    }\n\n    @Test\n    void checkTunnelLimit_LimitReached_ThrowsException() {\n        when(tunnelRepository.countByAccountIdAndStatusIn(any(), any())).thenReturn(1L);\n        account.setExtraTunnels(0);\n\n        final var exception = assertThrows(IllegalStateException.class, () -> tunnelService.createHttpTunnel(\n            account, UUID.randomUUID(), null, createRequest(), \"http://abc.pb.dev\", new DomainEntity()));\n\n        assertEquals(\"Tunnel limit reached for your plan (1). Please upgrade or add more tunnels.\",\n            exception.getMessage());\n    }\n\n    @Test\n    void enforceTunnelLimit_ExceedsLimit_ClosesExcessTunnels() {\n        account.setPlan(Plan.PRO);\n        account.setExtraTunnels(0); // Limit is 1\n\n        final var t1 = new TunnelEntity();\n        t1.setId(UUID.randomUUID());\n        t1.setStatus(TunnelStatus.CONNECTED);\n\n        final var t2 = new TunnelEntity();\n        t2.setId(UUID.randomUUID());\n        t2.setStatus(TunnelStatus.CONNECTED);\n\n        when(tunnelRepository.findByAccountIdAndStatusInOrderByLastHeartbeatAtAscCreatedAtAsc(\n            eq(account.getId()), any()))\n            .thenReturn(List.of(t1, t2));\n\n        tunnelService.enforceTunnelLimit(account);\n\n        assertEquals(TunnelStatus.CLOSED, t1.getStatus());\n        // t2 is the second one, so it remains connected if limit is 1\n        assertEquals(TunnelStatus.CONNECTED, t2.getStatus());\n        verify(tunnelRepository, times(1)).save(t1);\n        verify(tunnelRepository, times(0)).save(t2);\n    }\n\n    @Test\n    void checkTunnelLimit_ProPlanNoSubscription_Success() {\n        account.setSubscriptionStatus(null);\n        account.setPlan(Plan.PRO);\n        account.setExtraTunnels(0);\n\n        when(tunnelRepository.countByAccountIdAndStatusIn(any(), any())).thenReturn(0L);\n        assertDoesNotThrow(() -> tunnelService.createHttpTunnel(\n            account, UUID.randomUUID(), null, createRequest(), \"http://abc.pb.dev\", new DomainEntity()));\n    }\n\n    @Test\n    void checkTunnelLimit_ProPlanWithExtraNoSubscription_ThrowsException() {\n        account.setSubscriptionStatus(null);\n        account.setPlan(Plan.PRO);\n        account.setExtraTunnels(1);\n\n        assertThrows(IllegalStateException.class, () -> tunnelService.createHttpTunnel(\n            account, UUID.randomUUID(), null, createRequest(), \"http://abc.pb.dev\", new DomainEntity()));\n    }\n\n    @Test\n    void checkTunnelLimit_TeamPlanNoSubscription_ThrowsException() {\n        account.setSubscriptionStatus(null);\n        account.setPlan(Plan.TEAM);\n\n        assertThrows(IllegalStateException.class, () -> tunnelService.createHttpTunnel(\n            account, UUID.randomUUID(), null, createRequest(), \"http://abc.pb.dev\", new DomainEntity()));\n    }\n\n    private ExposeRequest createRequest() {\n        return new ExposeRequest(TunnelType.HTTP, \"http\", \"localhost\", 8080, null, null, null);\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/tunnel/TunnelRegistryLeakTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.tunnel;\n\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.Test;\nimport org.springframework.web.socket.WebSocketSession;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport tech.amak.portbuddy.server.db.entity.DomainEntity;\nimport tech.amak.portbuddy.server.db.entity.TunnelEntity;\n\nclass TunnelRegistryLeakTest {\n\n    @Test\n    void shouldCloseBrowserSessionsWhenTunnelIsClosed() throws Exception {\n        // Given\n        final var mapper = new ObjectMapper();\n        final var registry = new TunnelRegistry(mapper);\n\n        final var tunnelId = UUID.randomUUID();\n        final var accountId = UUID.randomUUID();\n        final var domain = new DomainEntity();\n        domain.setSubdomain(\"test\");\n        final var tunnelEntity = new TunnelEntity();\n        tunnelEntity.setId(tunnelId);\n        tunnelEntity.setAccountId(accountId);\n        tunnelEntity.setDomain(domain);\n\n        final var tunnelSession = mock(WebSocketSession.class);\n        when(tunnelSession.isOpen()).thenReturn(true);\n        registry.register(tunnelEntity, tunnelSession);\n\n        final var browserSession = mock(WebSocketSession.class);\n        when(browserSession.isOpen()).thenReturn(true);\n        final var connectionId = \"conn-1\";\n        registry.registerBrowserWs(tunnelId, connectionId, browserSession);\n\n        // Verify it is registered\n        assert registry.getByTunnelId(tunnelId) != null;\n        assert registry.getBySubdomain(\"test\") != null;\n\n        // When\n        registry.closeTunnel(tunnelId);\n\n        // Then - the browser session should be closed\n        verify(browserSession).close();\n\n        // And it should be removed from maps\n        assert registry.getByTunnelId(tunnelId) == null;\n        assert registry.getBySubdomain(\"test\") == null;\n    }\n\n    @Test\n    void shouldCleanupPendingRequestsOnTimeout() throws Exception {\n        // Given\n        final var mapper = new ObjectMapper();\n        final var registry = new TunnelRegistry(mapper);\n\n        final var tunnelId = UUID.randomUUID();\n        final var accountId = UUID.randomUUID();\n        final var domain = new DomainEntity();\n        domain.setSubdomain(\"test\");\n        final var tunnelEntity = new TunnelEntity();\n        tunnelEntity.setId(tunnelId);\n        tunnelEntity.setAccountId(accountId);\n        tunnelEntity.setDomain(domain);\n\n        final var tunnelSession = mock(WebSocketSession.class);\n        when(tunnelSession.isOpen()).thenReturn(true);\n        registry.register(tunnelEntity, tunnelSession);\n\n        final var request = new tech.amak.portbuddy.common.tunnel.HttpTunnelMessage();\n        request.setId(\"req-1\");\n\n        // When\n        final var future = registry.forwardRequest(\"test\", request, java.time.Duration.ofMillis(100));\n\n        final var tunnel = registry.getByTunnelId(tunnelId);\n        assert tunnel.pending().containsKey(\"req-1\");\n\n        // Wait for timeout\n        Thread.sleep(200);\n\n        // Then\n        assert future.isCompletedExceptionally();\n        assert !tunnel.pending().containsKey(\"req-1\");\n    }\n\n    @Test\n    void shouldCleanupBrowserReverseMapWhenUnregistered() {\n        // Given\n        final var mapper = new ObjectMapper();\n        final var registry = new TunnelRegistry(mapper);\n\n        final var tunnelId = UUID.randomUUID();\n        final var accountId = UUID.randomUUID();\n        final var domain = new DomainEntity();\n        domain.setSubdomain(\"test\");\n        final var tunnelEntity = new TunnelEntity();\n        tunnelEntity.setId(tunnelId);\n        tunnelEntity.setAccountId(accountId);\n        tunnelEntity.setDomain(domain);\n\n        final var tunnelSession = mock(WebSocketSession.class);\n        when(tunnelSession.isOpen()).thenReturn(true);\n        registry.register(tunnelEntity, tunnelSession);\n\n        final var browserSession = mock(WebSocketSession.class);\n        final var connectionId = \"conn-1\";\n        registry.registerBrowserWs(tunnelId, connectionId, browserSession);\n\n        final var tunnel = registry.getByTunnelId(tunnelId);\n        assert tunnel.browserByConnection().containsKey(connectionId);\n        assert tunnel.browserReverse().containsKey(browserSession);\n\n        // When\n        registry.unregisterBrowserWs(browserSession);\n\n        // Then\n        assert !tunnel.browserByConnection().containsKey(connectionId);\n        assert !tunnel.browserReverse().containsKey(browserSession);\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/user/PasswordResetServiceTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.user;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyMap;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.time.OffsetDateTime;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.springframework.security.crypto.password.PasswordEncoder;\n\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.PasswordResetTokenEntity;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.db.repo.PasswordResetTokenRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.mail.EmailService;\nimport tech.amak.portbuddy.server.service.user.PasswordResetService;\n\n@ExtendWith(MockitoExtension.class)\nclass PasswordResetServiceTest {\n\n    @Mock private UserRepository userRepository;\n    @Mock private PasswordResetTokenRepository tokenRepository;\n    @Mock private EmailService emailService;\n    @Mock private PasswordEncoder passwordEncoder;\n    @Mock private AppProperties properties;\n    @Mock private AppProperties.Gateway gateway;\n\n    @InjectMocks private PasswordResetService service;\n\n    @Test\n    void requestReset_shouldGenerateTokenAndSendEmail_whenUserExists() {\n        final var email = \"test@example.com\";\n        final var user = new UserEntity();\n        user.setEmail(email);\n        user.setId(UUID.randomUUID());\n\n        when(userRepository.findByEmailIgnoreCase(email)).thenReturn(Optional.of(user));\n        when(properties.gateway()).thenReturn(gateway);\n        when(gateway.url()).thenReturn(\"http://localhost\");\n\n        service.requestReset(email);\n\n        verify(tokenRepository).deleteByUser(user);\n        verify(tokenRepository).save(any(PasswordResetTokenEntity.class));\n        verify(emailService).sendTemplate(eq(email), anyString(), eq(\"email/password-reset\"), anyMap());\n    }\n\n    @Test\n    void requestReset_shouldDoNothing_whenUserDoesNotExist() {\n        final var email = \"unknown@example.com\";\n        when(userRepository.findByEmailIgnoreCase(email)).thenReturn(Optional.empty());\n\n        service.requestReset(email);\n\n        verify(tokenRepository, org.mockito.Mockito.never()).save(any());\n        verify(emailService, org.mockito.Mockito.never()).sendTemplate(anyString(), anyString(), anyString(), anyMap());\n    }\n\n    @Test\n    void validateToken_shouldReturnTrue_whenTokenIsValid() {\n        final var token = \"valid-token\";\n        final var entity = new PasswordResetTokenEntity();\n        entity.setToken(token);\n        entity.setExpiryDate(OffsetDateTime.now().plusHours(1));\n\n        when(tokenRepository.findByToken(token)).thenReturn(Optional.of(entity));\n\n        assertTrue(service.validateToken(token));\n    }\n\n    @Test\n    void validateToken_shouldReturnFalse_whenTokenIsExpired() {\n        final var token = \"expired-token\";\n        final var entity = new PasswordResetTokenEntity();\n        entity.setToken(token);\n        entity.setExpiryDate(OffsetDateTime.now().minusHours(1));\n\n        when(tokenRepository.findByToken(token)).thenReturn(Optional.of(entity));\n\n        assertFalse(service.validateToken(token));\n    }\n\n    @Test\n    void resetPassword_shouldUpdatePassword_whenTokenIsValid() {\n        final var token = \"valid-token\";\n        final var newPass = \"new-password\";\n        final var entity = new PasswordResetTokenEntity();\n        entity.setToken(token);\n        entity.setExpiryDate(OffsetDateTime.now().plusHours(1));\n        final var user = new UserEntity();\n        user.setEmail(\"user@example.com\");\n        entity.setUser(user);\n\n        when(tokenRepository.findByToken(token)).thenReturn(Optional.of(entity));\n        when(passwordEncoder.encode(newPass)).thenReturn(\"encoded-password\");\n        when(properties.gateway()).thenReturn(gateway);\n        when(gateway.url()).thenReturn(\"http://localhost\");\n\n        service.resetPassword(token, newPass);\n\n        verify(userRepository).save(user);\n        verify(tokenRepository).delete(entity);\n        verify(emailService)\n            .sendTemplate(eq(user.getEmail()), anyString(), eq(\"email/password-reset-success\"), anyMap());\n    }\n\n    @Test\n    void resetPassword_shouldThrow_whenTokenIsExpired() {\n        final var token = \"expired-token\";\n        final var entity = new PasswordResetTokenEntity();\n        entity.setToken(token);\n        entity.setExpiryDate(OffsetDateTime.now().minusHours(1));\n\n        when(tokenRepository.findByToken(token)).thenReturn(Optional.of(entity));\n\n        assertThrows(IllegalArgumentException.class, () -> service.resetPassword(token, \"new-pass\"));\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/web/AuthControllerTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;\nimport org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;\nimport org.springframework.http.MediaType;\nimport org.springframework.security.crypto.password.PasswordEncoder;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\nimport org.springframework.test.web.servlet.MockMvc;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport tech.amak.portbuddy.common.dto.auth.RegisterRequest;\nimport tech.amak.portbuddy.common.dto.auth.TokenExchangeRequest;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.Role;\nimport tech.amak.portbuddy.server.db.entity.UserAccountEntity;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserAccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.security.JwtService;\nimport tech.amak.portbuddy.server.service.ApiTokenService;\nimport tech.amak.portbuddy.server.service.user.PasswordResetService;\nimport tech.amak.portbuddy.server.service.user.UserProvisioningService;\nimport tech.amak.portbuddy.server.service.user.UserProvisioningService.ProvisionedUser;\nimport tech.amak.portbuddy.server.web.dto.LoginRequest;\nimport tech.amak.portbuddy.server.web.dto.PasswordResetConfirm;\nimport tech.amak.portbuddy.server.web.dto.PasswordResetRequest;\n\n@WebMvcTest(AuthController.class)\n@AutoConfigureMockMvc(addFilters = false) // Disable security filters to focus on controller logic\nclass AuthControllerTest {\n\n    @Autowired\n    private MockMvc mockMvc;\n\n    @Autowired\n    private ObjectMapper objectMapper;\n\n    @MockitoBean\n    private UserProvisioningService userProvisioningService;\n\n    @MockitoBean\n    private ApiTokenService apiTokenService;\n\n    @MockitoBean\n    private JwtService jwtService;\n\n    @MockitoBean\n    private UserRepository userRepository;\n\n    @MockitoBean\n    private PasswordEncoder passwordEncoder;\n\n    @MockitoBean\n    private PasswordResetService passwordResetService;\n\n    @MockitoBean\n    private AppProperties appProperties;\n\n    @MockitoBean\n    private UserAccountRepository userAccountRepository;\n\n    @MockitoBean\n    private AccountRepository accountRepository;\n\n    @Test\n    void register_shouldReturnApiKey() throws Exception {\n        final var request = new RegisterRequest(\"test@example.com\", \"Test User\", \"password\");\n        final var userId = UUID.randomUUID();\n        final var accountId = UUID.randomUUID();\n        final var apiKey = \"test-api-key\";\n\n        when(userProvisioningService.createLocalUser(any(), any(), any()))\n            .thenReturn(new ProvisionedUser(userId, accountId, \"Test Account\", Set.of(Role.ACCOUNT_ADMIN)));\n\n        when(apiTokenService.createToken(accountId, userId, \"prtb-client\"))\n            .thenReturn(new ApiTokenService.CreatedToken(UUID.randomUUID().toString(), apiKey));\n\n        mockMvc.perform(post(\"/api/auth/register\")\n                .contentType(MediaType.APPLICATION_JSON)\n                .content(objectMapper.writeValueAsString(request)))\n            .andExpect(status().isOk())\n            .andExpect(jsonPath(\"$.apiKey\").value(apiKey))\n            .andExpect(jsonPath(\"$.success\").value(true));\n    }\n\n    @Test\n    void register_shouldReturnError_whenMissingFields() throws Exception {\n        final var request = new RegisterRequest(null, \"Test User\", \"password\");\n\n        mockMvc.perform(post(\"/api/auth/register\")\n                .contentType(MediaType.APPLICATION_JSON)\n                .content(objectMapper.writeValueAsString(request)))\n            .andExpect(status().isOk()) // We now return 200 with error details\n            .andExpect(jsonPath(\"$.success\").value(false))\n            .andExpect(jsonPath(\"$.message\").value(\"Email is required\"))\n            .andExpect(jsonPath(\"$.statusCode\").value(400));\n    }\n\n    @Test\n    void requestPasswordReset_shouldReturnNoContent() throws Exception {\n        final var request = new PasswordResetRequest(\"test@example.com\");\n\n        mockMvc.perform(post(\"/api/auth/password-reset/request\")\n                .contentType(MediaType.APPLICATION_JSON)\n                .content(objectMapper.writeValueAsString(request)))\n            .andExpect(status().isNoContent());\n    }\n\n    @Test\n    void tokenExchange_shouldReturnJwt() throws Exception {\n        final var apiToken = \"valid-token\";\n        final var clientVersion = \"1.0.0\";\n        final var request = new TokenExchangeRequest(apiToken, clientVersion);\n        final var accountId = UUID.randomUUID();\n        final var userId = UUID.randomUUID();\n        final var apiKeyId = UUID.randomUUID();\n\n        when(appProperties.cli()).thenReturn(new AppProperties.Cli(\"1.0.0\"));\n        when(apiTokenService.validateAndGetApiKey(apiToken))\n            .thenReturn(Optional.of(new ApiTokenService.ValidatedApiKey(userId, accountId, apiKeyId)));\n\n        final var account = new AccountEntity();\n        account.setId(accountId);\n        account.setBlocked(false);\n        when(accountRepository.findById(accountId)).thenReturn(Optional.of(account));\n\n        when(jwtService.createToken(any(), any())).thenReturn(\"test-jwt\");\n\n        mockMvc.perform(post(\"/api/auth/token-exchange\")\n                .contentType(MediaType.APPLICATION_JSON)\n                .content(objectMapper.writeValueAsString(request)))\n            .andExpect(status().isOk())\n            .andExpect(jsonPath(\"$.accessToken\").value(\"test-jwt\"));\n    }\n\n    @Test\n    void login_shouldReturnJwt() throws Exception {\n        final var email = \"test@example.com\";\n        final var password = \"password\";\n        final var request = new LoginRequest(email, password);\n        final var userId = UUID.randomUUID();\n\n        final var user = new UserEntity();\n        user.setId(userId);\n        user.setEmail(email);\n        user.setFirstName(\"Test\");\n        user.setPassword(\"hashed-password\");\n\n        when(userRepository.findByEmailIgnoreCase(email)).thenReturn(Optional.of(user));\n        when(passwordEncoder.matches(password, \"hashed-password\")).thenReturn(true);\n\n        final var account = new AccountEntity();\n        account.setId(UUID.randomUUID());\n        account.setBlocked(false);\n\n        final var userAccount = new UserAccountEntity(user, account, Set.of(Role.ACCOUNT_ADMIN));\n        when(userAccountRepository.findLatestUsedByUserId(userId)).thenReturn(Optional.of(userAccount));\n        when(jwtService.createToken(any(), any(), any())).thenReturn(\"login-jwt\");\n\n        mockMvc.perform(post(\"/api/auth/login\")\n                .contentType(MediaType.APPLICATION_JSON)\n                .content(objectMapper.writeValueAsString(request)))\n            .andExpect(status().isOk())\n            .andExpect(jsonPath(\"$.accessToken\").value(\"login-jwt\"));\n    }\n\n    @Test\n    void confirmPasswordReset_shouldReturnNoContent() throws Exception {\n        final var request = new PasswordResetConfirm(\"valid-token\", \"new-password\");\n\n        mockMvc.perform(post(\"/api/auth/password-reset/confirm\")\n                .contentType(MediaType.APPLICATION_JSON)\n                .content(objectMapper.writeValueAsString(request)))\n            .andExpect(status().isNoContent());\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/web/IngressControllerTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;\nimport org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;\nimport org.springframework.http.MediaType;\nimport org.springframework.security.crypto.password.PasswordEncoder;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\nimport org.springframework.test.web.servlet.MockMvc;\nimport org.springframework.util.unit.DataSize;\n\nimport tech.amak.portbuddy.common.tunnel.HttpTunnelMessage;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.DomainRepository;\nimport tech.amak.portbuddy.server.service.ApiTokenService;\nimport tech.amak.portbuddy.server.service.TunnelService;\nimport tech.amak.portbuddy.server.tunnel.TunnelRegistry;\nimport tech.amak.portbuddy.server.tunnel.TunnelRegistry.Tunnel;\n\n@WebMvcTest(IngressController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass IngressControllerTest {\n\n    @Autowired\n    private MockMvc mockMvc;\n\n    @MockitoBean\n    private TunnelRegistry registry;\n\n    @MockitoBean\n    private AppProperties properties;\n\n    @MockitoBean\n    private DomainRepository domainRepository;\n\n    @MockitoBean\n    private AccountRepository accountRepository;\n\n    @MockitoBean\n    private TunnelService tunnelService;\n\n    @MockitoBean\n    private ApiTokenService apiTokenService;\n\n    @MockitoBean\n    private PasswordEncoder passwordEncoder;\n\n    @BeforeEach\n    void setUp() {\n        final var gateway = new AppProperties.Gateway(\n            \"http://localhost\", \"portbuddy.dev\", \"https://%s.portbuddy.dev\",\n            \"http://localhost/404\", \"http://localhost/passcode\", DataSize.ofKilobytes(1)\n        );\n        when(properties.gateway()).thenReturn(gateway);\n    }\n\n    @Test\n    void forwardViaTunnel_shouldRejectLargeRequest() throws Exception {\n        final var subdomain = \"test\";\n        final var tunnelId = UUID.randomUUID();\n        final var accountId = UUID.randomUUID();\n\n        final var mockTunnel = mock(Tunnel.class);\n        when(mockTunnel.isOpen()).thenReturn(true);\n        when(mockTunnel.tunnelId()).thenReturn(tunnelId);\n        when(mockTunnel.accountId()).thenReturn(accountId);\n        when(registry.getBySubdomain(subdomain)).thenReturn(mockTunnel);\n\n        final var account = new AccountEntity();\n        account.setId(accountId);\n        account.setSubscriptionStatus(\"active\");\n        when(accountRepository.findById(accountId)).thenReturn(Optional.of(account));\n\n        // Content-Length = 2KB (exceeds 1KB limit)\n        final var largeBody = new byte[2048];\n\n        mockMvc.perform(post(\"/_/\" + subdomain + \"/some-path\")\n                .content(largeBody)\n                .contentType(MediaType.APPLICATION_OCTET_STREAM))\n            .andExpect(status().is(413));\n    }\n\n    @Test\n    void forwardViaTunnel_shouldAcceptSmallRequest() throws Exception {\n        final var subdomain = \"test\";\n        final var tunnelId = UUID.randomUUID();\n        final var accountId = UUID.randomUUID();\n\n        final var mockTunnel = mock(Tunnel.class);\n        when(mockTunnel.isOpen()).thenReturn(true);\n        when(mockTunnel.tunnelId()).thenReturn(tunnelId);\n        when(mockTunnel.accountId()).thenReturn(accountId);\n        when(registry.getBySubdomain(subdomain)).thenReturn(mockTunnel);\n\n        final var account = new AccountEntity();\n        account.setId(accountId);\n        account.setSubscriptionStatus(\"active\");\n        when(accountRepository.findById(accountId)).thenReturn(Optional.of(account));\n\n        final var responseMsg = new HttpTunnelMessage();\n        responseMsg.setStatus(200);\n        when(registry.forwardRequest(anyString(), any(), any()))\n            .thenReturn(CompletableFuture.completedFuture(responseMsg));\n        \n        // Content-Length = 512B (within 1KB limit)\n        final var smallBody = new byte[512];\n\n        mockMvc.perform(post(\"/_/\" + subdomain + \"/some-path\")\n                .content(smallBody)\n                .contentType(MediaType.APPLICATION_OCTET_STREAM))\n            .andExpect(status().isOk());\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/web/PaymentControllerTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;\nimport org.springframework.core.MethodParameter;\nimport org.springframework.http.MediaType;\nimport org.springframework.security.oauth2.jwt.Jwt;\nimport org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;\nimport org.springframework.test.context.ActiveProfiles;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\nimport org.springframework.test.web.servlet.MockMvc;\nimport org.springframework.test.web.servlet.setup.MockMvcBuilders;\nimport org.springframework.web.bind.support.WebDataBinderFactory;\nimport org.springframework.web.context.request.NativeWebRequest;\nimport org.springframework.web.method.support.HandlerMethodArgumentResolver;\nimport org.springframework.web.method.support.ModelAndViewContainer;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport tech.amak.portbuddy.common.Plan;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.security.ApiTokenAuthFilter;\nimport tech.amak.portbuddy.server.security.JwtService;\nimport tech.amak.portbuddy.server.security.Oauth2SuccessHandler;\nimport tech.amak.portbuddy.server.service.StripeService;\n\n@WebMvcTest(PaymentController.class)\n@ActiveProfiles(\"test\")\npublic class PaymentControllerTest {\n\n    private MockMvc mockMvc;\n\n    @Autowired\n    private PaymentController paymentController;\n\n    @Autowired\n    private ObjectMapper objectMapper;\n\n    @MockitoBean\n    private StripeService stripeService;\n\n    @MockitoBean\n    private UserRepository userRepository;\n\n    @MockitoBean\n    private AccountRepository accountRepository;\n\n    @MockitoBean\n    private JwtService jwtService;\n\n    @MockitoBean\n    private ApiTokenAuthFilter apiTokenAuthFilter;\n\n    @MockitoBean\n    private Oauth2SuccessHandler oauth2SuccessHandler;\n\n    private UUID accountId;\n    private UUID userId;\n    private AccountEntity account;\n\n    @BeforeEach\n    void setUp() {\n        mockMvc = MockMvcBuilders.standaloneSetup(paymentController)\n            .setControllerAdvice(new tech.amak.portbuddy.server.web.advice.GlobalExceptionHandler())\n            .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {\n                @Override\n                public boolean supportsParameter(final MethodParameter parameter) {\n                    return parameter.getParameterType().equals(Jwt.class);\n                }\n\n                @Override\n                public Object resolveArgument(final MethodParameter parameter,\n                                              final ModelAndViewContainer mavContainer,\n                                              final NativeWebRequest webRequest,\n                                              final WebDataBinderFactory binderFactory) {\n                    final var principal = webRequest.getUserPrincipal();\n                    if (principal instanceof JwtAuthenticationToken jwtToken) {\n                        return jwtToken.getToken();\n                    }\n                    return null;\n                }\n            })\n            .build();\n\n        accountId = UUID.randomUUID();\n        userId = UUID.randomUUID();\n\n        account = new AccountEntity();\n        account.setId(accountId);\n        account.setName(\"Test Account\");\n\n        when(accountRepository.findById(accountId)).thenReturn(Optional.of(account));\n    }\n\n    @Test\n    void createCheckoutSession_WithAccountAdmin_ShouldSucceed() throws Exception {\n        when(stripeService.createCheckoutSession(any(), any())).thenReturn(\"https://checkout.stripe.com/test\");\n\n        final var request = new PaymentController.CheckoutRequest();\n        request.setPlan(Plan.PRO);\n\n        mockMvc.perform(post(\"/api/payments/create-checkout-session\")\n                .contentType(MediaType.APPLICATION_JSON)\n                .content(objectMapper.writeValueAsString(request))\n                .principal(new JwtAuthenticationToken(createJwt(List.of(\"ACCOUNT_ADMIN\")))))\n            .andExpect(status().isOk());\n    }\n\n    @Test\n    void createPortalSession_WithAdmin_ShouldSucceed() throws Exception {\n        when(stripeService.createPortalSession(any())).thenReturn(\"https://billing.stripe.com/test\");\n\n        mockMvc.perform(post(\"/api/payments/create-portal-session\")\n                .principal(new JwtAuthenticationToken(createJwt(List.of(\"ADMIN\")))))\n            .andExpect(status().isOk());\n    }\n\n    @Test\n    void cancelSubscription_shouldUpdateAccount() throws Exception {\n        account.setExtraTunnels(5);\n        account.setSubscriptionStatus(\"active\");\n        account.setStripeSubscriptionId(\"sub_123\");\n\n        mockMvc.perform(post(\"/api/payments/cancel-subscription\")\n                .principal(new JwtAuthenticationToken(createJwt(List.of(\"ACCOUNT_ADMIN\")))))\n            .andExpect(status().isNoContent());\n\n        org.mockito.Mockito.verify(stripeService).cancelSubscription(account);\n        org.mockito.Mockito.verify(accountRepository).save(account);\n        \n        org.junit.jupiter.api.Assertions.assertEquals(0, account.getExtraTunnels());\n        org.junit.jupiter.api.Assertions.assertEquals(\"active\", account.getSubscriptionStatus());\n        org.junit.jupiter.api.Assertions.assertEquals(Plan.PRO, account.getPlan());\n        org.junit.jupiter.api.Assertions.assertNull(account.getStripeSubscriptionId());\n    }\n\n    // Note: standaloneSetup doesn't enforce @PreAuthorize. \n    // To test @PreAuthorize we would need a full integration test or a different setup.\n    // However, since I'm just verifying the controller logic works when authorized, \n    // and I've manually added @PreAuthorize, this is a good start.\n\n    private Jwt createJwt(final List<String> roles) {\n        return Jwt.withTokenValue(\"token\")\n            .header(\"alg\", \"none\")\n            .subject(userId.toString())\n            .claim(\"aid\", accountId.toString())\n            .claim(\"roles\", roles)\n            .build();\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/web/StripeWebhookControllerTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyMap;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;\nimport org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;\nimport org.springframework.http.MediaType;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\nimport org.springframework.test.web.servlet.MockMvc;\nimport org.springframework.util.unit.DataSize;\n\nimport com.stripe.model.Event;\nimport com.stripe.model.EventDataObjectDeserializer;\nimport com.stripe.model.Invoice;\nimport com.stripe.model.Subscription;\nimport com.stripe.model.checkout.Session;\n\nimport tech.amak.portbuddy.common.Plan;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.UserAccountEntity;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.StripeEventRepository;\nimport tech.amak.portbuddy.server.mail.EmailService;\nimport tech.amak.portbuddy.server.security.ApiTokenAuthFilter;\nimport tech.amak.portbuddy.server.security.Oauth2SuccessHandler;\nimport tech.amak.portbuddy.server.service.StripeService;\nimport tech.amak.portbuddy.server.service.StripeWebhookService;\nimport tech.amak.portbuddy.server.service.TunnelService;\n\n@WebMvcTest(StripeWebhookController.class)\n@AutoConfigureMockMvc(addFilters = false)\nclass StripeWebhookControllerTest {\n\n    @Autowired\n    private MockMvc mockMvc;\n\n    @MockitoBean\n    private AccountRepository accountRepository;\n\n    @MockitoBean\n    private StripeEventRepository stripeEventRepository;\n\n    @MockitoBean\n    private EmailService emailService;\n\n    @MockitoBean\n    private TunnelService tunnelService;\n\n    @MockitoBean\n    private StripeWebhookService stripeWebhookService;\n\n    @MockitoBean\n    private StripeService stripeService;\n\n    @MockitoBean\n    private ApiTokenAuthFilter apiTokenAuthFilter;\n\n    @MockitoBean\n    private Oauth2SuccessHandler oauth2SuccessHandler;\n\n    @MockitoBean\n    private AppProperties appProperties;\n\n    @BeforeEach\n    void setUp() {\n        when(appProperties.gateway()).thenReturn(new AppProperties.Gateway(\n            \"http://localhost:8080\", \"localhost\", \"http\", \"/404\", \"/passcode\", DataSize.ofMegabytes(10)\n        ));\n        when(appProperties.stripe()).thenReturn(new AppProperties.Stripe(\n            \"whsec_test\", \"sk_test\", new AppProperties.Stripe.PriceIds(\"pro\", \"team\", \"extra\")\n        ));\n    }\n\n    @Test\n    void handleInvoicePaymentFailed_shouldSendEmail() throws Exception {\n        final var customerId = \"cus_123\";\n        final var account = new AccountEntity();\n        account.setId(UUID.randomUUID());\n        account.setStripeCustomerId(customerId);\n        account.setPlan(Plan.PRO);\n\n        final var user = new UserEntity();\n        user.setId(UUID.randomUUID());\n        user.setEmail(\"test@example.com\");\n        user.setFirstName(\"Test\");\n        final var userAccount = new UserAccountEntity(user, account, java.util.Set.of());\n        account.setUsers(List.of(userAccount));\n\n        when(accountRepository.findByStripeCustomerId(customerId)).thenReturn(Optional.of(account));\n        when(stripeEventRepository.existsById(anyString())).thenReturn(false);\n\n        final var invoice = new Invoice();\n        invoice.setCustomer(customerId);\n        invoice.setAmountDue(1000L);\n        invoice.setCurrency(\"usd\");\n\n        final var event = mock(Event.class);\n        when(event.getId()).thenReturn(\"evt_123\");\n        when(event.getType()).thenReturn(\"invoice.payment_failed\");\n\n        final var deserializer = mock(EventDataObjectDeserializer.class);\n        when(deserializer.getObject()).thenReturn(Optional.of(invoice));\n        when(event.getDataObjectDeserializer()).thenReturn(deserializer);\n\n        when(stripeWebhookService.constructEvent(anyString(), anyString(), any())).thenReturn(event);\n\n        mockMvc.perform(post(\"/api/webhooks/stripe\")\n                .header(\"Stripe-Signature\", \"sig\")\n                .content(\"{}\")\n                .contentType(MediaType.APPLICATION_JSON))\n            .andExpect(status().isOk());\n\n        verify(emailService).sendTemplate(\n            eq(\"test@example.com\"),\n            eq(\"Payment Failed - Port Buddy\"),\n            eq(\"email/payment-failed\"),\n            anyMap()\n        );\n    }\n\n    @Test\n    void handleSubscriptionDeleted_shouldSendEmail() throws Exception {\n        final var customerId = \"cus_123\";\n        final var account = new AccountEntity();\n        account.setId(UUID.randomUUID());\n        account.setStripeCustomerId(customerId);\n        account.setPlan(Plan.PRO);\n        account.setSubscriptionStatus(\"active\");\n\n        final var user = new UserEntity();\n        user.setId(UUID.randomUUID());\n        user.setEmail(\"test@example.com\");\n        user.setFirstName(\"Test\");\n        final var userAccount = new UserAccountEntity(user, account, java.util.Set.of());\n        account.setUsers(List.of(userAccount));\n\n        when(accountRepository.findByStripeCustomerId(customerId)).thenReturn(Optional.of(account));\n        when(stripeEventRepository.existsById(anyString())).thenReturn(false);\n\n        final var subscription = new Subscription();\n        subscription.setCustomer(customerId);\n        subscription.setStatus(\"canceled\");\n\n        final var event = mock(Event.class);\n        when(event.getId()).thenReturn(\"evt_456\");\n        when(event.getType()).thenReturn(\"customer.subscription.deleted\");\n\n        final var deserializer = mock(EventDataObjectDeserializer.class);\n        when(deserializer.getObject()).thenReturn(Optional.of(subscription));\n        when(event.getDataObjectDeserializer()).thenReturn(deserializer);\n\n        when(stripeWebhookService.constructEvent(anyString(), anyString(), any())).thenReturn(event);\n\n        mockMvc.perform(post(\"/api/webhooks/stripe\")\n                .header(\"Stripe-Signature\", \"sig\")\n                .content(\"{}\")\n                .contentType(MediaType.APPLICATION_JSON))\n            .andExpect(status().isOk());\n\n        verify(emailService).sendTemplate(\n            eq(\"test@example.com\"),\n            eq(\"Subscription Canceled - Port Buddy\"),\n            eq(\"email/subscription-canceled\"),\n            anyMap()\n        );\n\n        org.junit.jupiter.api.Assertions.assertEquals(\"active\", account.getSubscriptionStatus());\n        org.junit.jupiter.api.Assertions.assertEquals(Plan.PRO, account.getPlan());\n        org.junit.jupiter.api.Assertions.assertNull(account.getStripeSubscriptionId());\n    }\n\n    @Test\n    void handleCheckoutSessionCompleted_withOldSubscription_shouldCancelOldSubscription() throws Exception {\n        final var accountId = UUID.randomUUID();\n        final var oldSubId = \"sub_old\";\n        final var newSubId = \"sub_new\";\n        final var customerId = \"cus_123\";\n\n        final var account = new AccountEntity();\n        account.setId(accountId);\n        account.setStripeCustomerId(customerId);\n        account.setStripeSubscriptionId(oldSubId);\n        account.setPlan(Plan.PRO);\n\n        when(accountRepository.findById(accountId)).thenReturn(Optional.of(account));\n        when(stripeEventRepository.existsById(anyString())).thenReturn(false);\n\n        final var session = mock(Session.class);\n        when(session.getId()).thenReturn(\"cs_123\");\n        when(session.getCustomer()).thenReturn(customerId);\n        when(session.getSubscription()).thenReturn(newSubId);\n        when(session.getMetadata()).thenReturn(java.util.Map.of(\n            \"accountId\", accountId.toString(),\n            \"plan\", \"TEAM\",\n            \"extraTunnels\", \"5\",\n            \"oldSubscriptionId\", oldSubId\n        ));\n\n        final var event = mock(Event.class);\n        when(event.getId()).thenReturn(\"evt_789\");\n        when(event.getType()).thenReturn(\"checkout.session.completed\");\n\n        final var deserializer = mock(EventDataObjectDeserializer.class);\n        when(deserializer.getObject()).thenReturn(Optional.of(session));\n        when(event.getDataObjectDeserializer()).thenReturn(deserializer);\n\n        when(stripeWebhookService.constructEvent(anyString(), anyString(), any())).thenReturn(event);\n\n        mockMvc.perform(post(\"/api/webhooks/stripe\")\n                .header(\"Stripe-Signature\", \"sig\")\n                .content(\"{}\")\n                .contentType(MediaType.APPLICATION_JSON))\n            .andExpect(status().isOk());\n\n        verify(stripeService).cancelSubscription(\"sub_old\");\n        verify(accountRepository).save(account);\n        assert account.getStripeSubscriptionId().equals(newSubId);\n        assert account.getPlan() == Plan.TEAM;\n        assert account.getExtraTunnels() == 5;\n    }\n\n    @Test\n    void handleSubscriptionDeleted_forOldSubscription_shouldNotCancelAccount() throws Exception {\n        final var customerId = \"cus_123\";\n        final var currentSubId = \"sub_current\";\n        final var oldSubId = \"sub_old\";\n\n        final var account = new AccountEntity();\n        account.setId(UUID.randomUUID());\n        account.setStripeCustomerId(customerId);\n        account.setStripeSubscriptionId(currentSubId);\n        account.setPlan(Plan.TEAM);\n        account.setSubscriptionStatus(\"active\");\n\n        when(accountRepository.findByStripeCustomerId(customerId)).thenReturn(Optional.of(account));\n        when(stripeEventRepository.existsById(anyString())).thenReturn(false);\n\n        final var subscription = new Subscription();\n        subscription.setId(oldSubId);\n        subscription.setCustomer(customerId);\n        subscription.setStatus(\"canceled\");\n\n        final var event = mock(Event.class);\n        when(event.getId()).thenReturn(\"evt_old_deleted\");\n        when(event.getType()).thenReturn(\"customer.subscription.deleted\");\n\n        final var deserializer = mock(EventDataObjectDeserializer.class);\n        when(deserializer.getObject()).thenReturn(Optional.of(subscription));\n        when(event.getDataObjectDeserializer()).thenReturn(deserializer);\n\n        when(stripeWebhookService.constructEvent(anyString(), anyString(), any())).thenReturn(event);\n\n        mockMvc.perform(post(\"/api/webhooks/stripe\")\n                .header(\"Stripe-Signature\", \"sig\")\n                .content(\"{}\")\n                .contentType(MediaType.APPLICATION_JSON))\n            .andExpect(status().isOk());\n\n        // The account should still have the current subscription ID and active status\n        assert account.getStripeSubscriptionId().equals(currentSubId);\n        assert account.getSubscriptionStatus().equals(\"active\");\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/web/TeamControllerTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport java.time.OffsetDateTime;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;\nimport org.springframework.core.MethodParameter;\nimport org.springframework.http.MediaType;\nimport org.springframework.security.oauth2.jwt.Jwt;\nimport org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;\nimport org.springframework.test.context.ActiveProfiles;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\nimport org.springframework.test.web.servlet.MockMvc;\nimport org.springframework.test.web.servlet.setup.MockMvcBuilders;\nimport org.springframework.web.bind.support.WebDataBinderFactory;\nimport org.springframework.web.context.request.NativeWebRequest;\nimport org.springframework.web.method.support.HandlerMethodArgumentResolver;\nimport org.springframework.web.method.support.ModelAndViewContainer;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.InvitationEntity;\nimport tech.amak.portbuddy.server.db.entity.Role;\nimport tech.amak.portbuddy.server.db.entity.UserAccountEntity;\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserAccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.security.ApiTokenAuthFilter;\nimport tech.amak.portbuddy.server.security.JwtService;\nimport tech.amak.portbuddy.server.security.Oauth2SuccessHandler;\nimport tech.amak.portbuddy.server.service.TeamService;\n\n@WebMvcTest(TeamController.class)\n@ActiveProfiles(\"test\")\npublic class TeamControllerTest {\n\n    private MockMvc mockMvc;\n\n    @Autowired\n    private TeamController teamController;\n\n    @Autowired\n    private ObjectMapper objectMapper;\n\n    @MockitoBean\n    private TeamService teamService;\n\n    @MockitoBean\n    private UserRepository userRepository;\n\n    @MockitoBean\n    private AccountRepository accountRepository;\n\n    @MockitoBean\n    private JwtService jwtService;\n\n    @MockitoBean\n    private ApiTokenAuthFilter apiTokenAuthFilter;\n\n    @MockitoBean\n    private Oauth2SuccessHandler oauth2SuccessHandler;\n\n    private UUID accountId;\n    private UUID userId;\n    private AccountEntity account;\n    private UserEntity user;\n\n    @MockitoBean\n    private UserAccountRepository userAccountRepository;\n\n    @BeforeEach\n    void setUp() {\n        mockMvc = MockMvcBuilders.standaloneSetup(teamController)\n            .setControllerAdvice(new tech.amak.portbuddy.server.web.advice.GlobalExceptionHandler())\n            .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {\n                @Override\n                public boolean supportsParameter(final MethodParameter parameter) {\n                    return parameter.getParameterType().equals(Jwt.class);\n                }\n\n                @Override\n                public Object resolveArgument(final MethodParameter parameter,\n                                              final ModelAndViewContainer mavContainer,\n                                              final NativeWebRequest webRequest,\n                                              final WebDataBinderFactory binderFactory) {\n                    final var principal = webRequest.getUserPrincipal();\n                    if (principal instanceof JwtAuthenticationToken jwtToken) {\n                        return jwtToken.getToken();\n                    }\n                    return createJwt();\n                }\n            })\n            .build();\n\n        accountId = UUID.randomUUID();\n        userId = UUID.randomUUID();\n\n        account = new AccountEntity();\n        account.setId(accountId);\n        account.setName(\"Test Team\");\n\n        user = new UserEntity();\n        user.setId(userId);\n        user.setEmail(\"admin@example.com\");\n\n        when(accountRepository.findById(accountId)).thenReturn(Optional.of(account));\n        when(userRepository.findById(userId)).thenReturn(Optional.of(user));\n    }\n\n    @Test\n    void getMembers_ShouldReturnList() throws Exception {\n        when(teamService.getMembers(any())).thenReturn(List.of(user));\n        final var userAccount = new UserAccountEntity(user, account, Set.of(Role.USER));\n        when(userAccountRepository.findByUserIdAndAccountId(user.getId(), account.getId()))\n            .thenReturn(Optional.of(userAccount));\n\n        mockMvc.perform(get(\"/api/team/members\")\n                .principal(new JwtAuthenticationToken(createJwt())))\n            .andExpect(status().isOk())\n            .andExpect(jsonPath(\"$[0].email\").value(\"admin@example.com\"));\n    }\n\n    @Test\n    void getInvitations_ShouldReturnList() throws Exception {\n        final var invitation = new InvitationEntity();\n        invitation.setId(UUID.randomUUID());\n        invitation.setEmail(\"invited@example.com\");\n        invitation.setInvitedBy(user);\n        invitation.setCreatedAt(OffsetDateTime.now());\n        invitation.setExpiresAt(OffsetDateTime.now().plusDays(7));\n\n        when(teamService.getPendingInvitations(any())).thenReturn(List.of(invitation));\n\n        mockMvc.perform(get(\"/api/team/invitations\")\n                .principal(new JwtAuthenticationToken(createJwt())))\n            .andExpect(status().isOk())\n            .andExpect(jsonPath(\"$[0].email\").value(\"invited@example.com\"));\n    }\n\n    @Test\n    void inviteMember_ShouldCreateInvitation() throws Exception {\n        final var invitation = new InvitationEntity();\n        invitation.setId(UUID.randomUUID());\n        invitation.setEmail(\"new@example.com\");\n        invitation.setInvitedBy(user);\n        invitation.setCreatedAt(OffsetDateTime.now());\n        invitation.setExpiresAt(OffsetDateTime.now().plusDays(7));\n\n        when(teamService.inviteMember(any(), any(), eq(\"new@example.com\"))).thenReturn(invitation);\n\n        final var request = new TeamController.InviteRequest();\n        request.setEmail(\"new@example.com\");\n\n        mockMvc.perform(post(\"/api/team/invitations\")\n                .contentType(MediaType.APPLICATION_JSON)\n                .content(objectMapper.writeValueAsString(request))\n                .principal(new JwtAuthenticationToken(createJwt())))\n            .andExpect(status().isOk())\n            .andExpect(jsonPath(\"$.email\").value(\"new@example.com\"));\n    }\n\n    @Test\n    void cancelInvitation_ShouldCallService() throws Exception {\n        final var invId = UUID.randomUUID();\n\n        mockMvc.perform(delete(\"/api/team/invitations/\" + invId)\n                .principal(new JwtAuthenticationToken(createJwt())))\n            .andExpect(status().isNoContent());\n\n        verify(teamService).cancelInvitation(any(), eq(invId));\n    }\n\n    @Test\n    void resendInvitation_ShouldCallService() throws Exception {\n        final var invId = UUID.randomUUID();\n\n        mockMvc.perform(post(\"/api/team/invitations/\" + invId + \"/resend\")\n                .principal(new JwtAuthenticationToken(createJwt())))\n            .andExpect(status().isNoContent());\n\n        verify(teamService).resendInvitation(any(), eq(invId));\n    }\n\n    @Test\n    void removeMember_ShouldCallService() throws Exception {\n        final var memberId = UUID.randomUUID();\n\n        mockMvc.perform(delete(\"/api/team/members/\" + memberId)\n                .principal(new JwtAuthenticationToken(createJwt())))\n            .andExpect(status().isNoContent());\n\n        verify(teamService).removeMember(any(), eq(memberId), any());\n    }\n\n    @Test\n    void removeMember_ShouldReturnBadRequest_WhenRemovingSelf() throws Exception {\n        org.mockito.Mockito.doThrow(new IllegalArgumentException(\"You cannot remove yourself from the account.\"))\n            .when(teamService).removeMember(any(), eq(userId), any());\n\n        mockMvc.perform(delete(\"/api/team/members/\" + userId)\n                .principal(new JwtAuthenticationToken(createJwt())))\n            .andExpect(status().isBadRequest());\n    }\n\n    @Test\n    void getMembers_ShouldThrowException_WhenAccountIdClaimIsMissing() throws Exception {\n        final var jwtWithoutAccountId = org.springframework.security.oauth2.jwt.Jwt.withTokenValue(\"token\")\n            .header(\"alg\", \"none\")\n            .subject(userId.toString())\n            .build();\n\n        mockMvc.perform(get(\"/api/team/members\")\n                .principal(new JwtAuthenticationToken(jwtWithoutAccountId)))\n            .andExpect(status().isBadRequest());\n    }\n\n    @Test\n    void removeMember_ShouldCallService_WhenAdmin() throws Exception {\n        final var memberId = UUID.randomUUID();\n        final var jwt = org.springframework.security.oauth2.jwt.Jwt.withTokenValue(\"token\")\n            .header(\"alg\", \"none\")\n            .subject(userId.toString())\n            .claim(\"aid\", accountId.toString())\n            .claim(\"roles\", List.of(\"ADMIN\", \"USER\"))\n            .build();\n\n        mockMvc.perform(delete(\"/api/team/members/\" + memberId)\n                .principal(new JwtAuthenticationToken(jwt)))\n            .andExpect(status().isNoContent());\n\n        verify(teamService).removeMember(any(), eq(memberId), any());\n    }\n\n    private org.springframework.security.oauth2.jwt.Jwt createJwt() {\n        return org.springframework.security.oauth2.jwt.Jwt.withTokenValue(\"token\")\n            .header(\"alg\", \"none\")\n            .subject(userId.toString())\n            .claim(\"aid\", accountId.toString())\n            .claim(\"roles\", List.of(\"ACCOUNT_ADMIN\", \"USER\"))\n            .build();\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/web/TokensControllerTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;\nimport org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\nimport org.springframework.test.web.servlet.MockMvc;\n\nimport tech.amak.portbuddy.server.db.entity.UserEntity;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.service.ApiTokenService;\n\n@WebMvcTest(TokensController.class)\n@AutoConfigureMockMvc\nclass TokensControllerTest {\n\n    @Autowired\n    private MockMvc mockMvc;\n\n    @MockitoBean\n    private ApiTokenService apiTokenService;\n\n    @MockitoBean\n    private UserRepository userRepository;\n\n    @Test\n    void revoke_shouldReturnNoContent() throws Exception {\n        final var userId = UUID.randomUUID();\n        final var accountId = UUID.randomUUID();\n        final var tokenId = \"token-123\";\n\n        final var user = new UserEntity();\n        user.setId(userId);\n\n        mockMvc.perform(delete(\"/api/tokens/{id}\", tokenId)\n                .with(jwt().jwt(builder -> builder\n                    .subject(userId.toString())\n                    .claim(\"aid\", accountId.toString()))))\n            .andExpect(status().isNoContent());\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/web/UsersControllerTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;\nimport org.springframework.core.MethodParameter;\nimport org.springframework.http.MediaType;\nimport org.springframework.security.oauth2.jwt.Jwt;\nimport org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;\nimport org.springframework.test.context.ActiveProfiles;\nimport org.springframework.test.context.bean.override.mockito.MockitoBean;\nimport org.springframework.test.web.servlet.MockMvc;\nimport org.springframework.test.web.servlet.setup.MockMvcBuilders;\nimport org.springframework.web.bind.support.WebDataBinderFactory;\nimport org.springframework.web.context.request.NativeWebRequest;\nimport org.springframework.web.method.support.HandlerMethodArgumentResolver;\nimport org.springframework.web.method.support.ModelAndViewContainer;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport tech.amak.portbuddy.common.Plan;\nimport tech.amak.portbuddy.server.config.AppProperties;\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.entity.UserAccountEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.db.repo.TunnelRepository;\nimport tech.amak.portbuddy.server.db.repo.UserAccountRepository;\nimport tech.amak.portbuddy.server.db.repo.UserRepository;\nimport tech.amak.portbuddy.server.security.ApiTokenAuthFilter;\nimport tech.amak.portbuddy.server.security.JwtService;\nimport tech.amak.portbuddy.server.security.Oauth2SuccessHandler;\nimport tech.amak.portbuddy.server.service.StripeService;\nimport tech.amak.portbuddy.server.service.TeamService;\nimport tech.amak.portbuddy.server.service.TunnelService;\n\n@WebMvcTest(UsersController.class)\n@ActiveProfiles(\"test\")\npublic class UsersControllerTest {\n\n    private MockMvc mockMvc;\n\n    @Autowired\n    private UsersController usersController;\n\n    @Autowired\n    private ObjectMapper objectMapper;\n\n    @MockitoBean\n    private StripeService stripeService;\n\n    @MockitoBean\n    private TunnelService tunnelService;\n\n    @MockitoBean\n    private TeamService teamService;\n\n    @MockitoBean\n    private JwtService jwtService;\n\n    @MockitoBean\n    private UserRepository userRepository;\n\n    @MockitoBean\n    private AccountRepository accountRepository;\n\n    @MockitoBean\n    private UserAccountRepository userAccountRepository;\n\n    @MockitoBean\n    private TunnelRepository tunnelRepository;\n\n    @MockitoBean\n    private ApiTokenAuthFilter apiTokenAuthFilter;\n\n    @MockitoBean\n    private Oauth2SuccessHandler oauth2SuccessHandler;\n\n    @MockitoBean\n    private AppProperties properties;\n\n    private UUID accountId;\n    private UUID userId;\n    private AccountEntity account;\n    private UserAccountEntity userAccount;\n\n    @BeforeEach\n    void setUp() {\n        mockMvc = MockMvcBuilders.standaloneSetup(usersController)\n            .setControllerAdvice(new tech.amak.portbuddy.server.web.advice.GlobalExceptionHandler())\n            .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {\n                @Override\n                public boolean supportsParameter(final MethodParameter parameter) {\n                    return parameter.getParameterType().equals(Jwt.class);\n                }\n\n                @Override\n                public Object resolveArgument(final MethodParameter parameter,\n                                              final ModelAndViewContainer mavContainer,\n                                              final NativeWebRequest webRequest,\n                                              final WebDataBinderFactory binderFactory) {\n                    final var principal = webRequest.getUserPrincipal();\n                    if (principal instanceof JwtAuthenticationToken jwtToken) {\n                        return jwtToken.getToken();\n                    }\n                    return null;\n                }\n            })\n            .build();\n\n        accountId = UUID.randomUUID();\n        userId = UUID.randomUUID();\n\n        account = new AccountEntity();\n        account.setId(accountId);\n        account.setName(\"Test Account\");\n        account.setPlan(Plan.PRO);\n        account.setExtraTunnels(0);\n\n        userAccount = new UserAccountEntity();\n        userAccount.setAccount(account);\n\n        when(userAccountRepository.findByUserIdAndAccountId(eq(userId), eq(accountId)))\n            .thenReturn(Optional.of(userAccount));\n\n        final var subscriptions = new AppProperties.Subscriptions(\n            null,\n            null,\n            new AppProperties.Subscriptions.Tunnels(\n                Map.of(Plan.PRO, 1, Plan.TEAM, 10),\n                Map.of(Plan.PRO, 1, Plan.TEAM, 5)\n            )\n        );\n        when(properties.subscriptions()).thenReturn(subscriptions);\n    }\n\n    @Test\n    void updateExtraTunnels_withoutSubscription_shouldReturnCheckoutUrl() throws Exception {\n        final var request = new UsersController.UpdateTunnelsRequest();\n        request.setExtraTunnels(1);\n\n        final var checkoutUrl = \"https://checkout.stripe.com/test\";\n        when(stripeService.createCheckoutSession(any(), eq(Plan.PRO), eq(1))).thenReturn(checkoutUrl);\n\n        mockMvc.perform(patch(\"/api/users/me/account/tunnels\")\n                .contentType(MediaType.APPLICATION_JSON)\n                .content(objectMapper.writeValueAsString(request))\n                .principal(new JwtAuthenticationToken(createJwt(List.of(\"ACCOUNT_ADMIN\")))))\n            .andExpect(status().isOk())\n            .andExpect(jsonPath(\"$.extraTunnels\").value(0)) // Should still be 0 in the response as it's not saved\n            .andExpect(jsonPath(\"$.checkoutUrl\").value(checkoutUrl));\n\n        verify(accountRepository, org.mockito.Mockito.never()).save(account);\n    }\n\n    @Test\n    void updateExtraTunnels_withSubscription_shouldUpdateStripe() throws Exception {\n        account.setStripeSubscriptionId(\"sub_123\");\n\n        final var request = new UsersController.UpdateTunnelsRequest();\n        request.setExtraTunnels(1);\n\n        mockMvc.perform(patch(\"/api/users/me/account/tunnels\")\n                .contentType(MediaType.APPLICATION_JSON)\n                .content(objectMapper.writeValueAsString(request))\n                .principal(new JwtAuthenticationToken(createJwt(List.of(\"ACCOUNT_ADMIN\")))))\n            .andExpect(status().isOk())\n            .andExpect(jsonPath(\"$.extraTunnels\").value(1))\n            .andExpect(jsonPath(\"$.checkoutUrl\").isEmpty());\n\n        verify(stripeService).updateExtraTunnels(account, 1);\n        verify(accountRepository).save(account);\n    }\n\n    @Test\n    void updateExtraTunnels_proPlan_zeroExtra_withSubscription_shouldCancelSubscription() throws Exception {\n        account.setStripeSubscriptionId(\"sub_123\");\n        account.setPlan(Plan.PRO);\n        account.setExtraTunnels(1);\n\n        final var request = new UsersController.UpdateTunnelsRequest();\n        request.setExtraTunnels(0);\n\n        mockMvc.perform(patch(\"/api/users/me/account/tunnels\")\n                .contentType(MediaType.APPLICATION_JSON)\n                .content(objectMapper.writeValueAsString(request))\n                .principal(new JwtAuthenticationToken(createJwt(List.of(\"ACCOUNT_ADMIN\")))))\n            .andExpect(status().isOk())\n            .andExpect(jsonPath(\"$.extraTunnels\").value(0))\n            .andExpect(jsonPath(\"$.subscriptionStatus\").value(\"canceled\"));\n\n        verify(stripeService).cancelSubscription(account);\n        verify(accountRepository).save(account);\n    }\n\n    private Jwt createJwt(final List<String> roles) {\n        return Jwt.withTokenValue(\"token\")\n            .header(\"alg\", \"none\")\n            .subject(userId.toString())\n            .claim(\"aid\", accountId.toString())\n            .claim(\"roles\", roles)\n            .build();\n    }\n}\n"
  },
  {
    "path": "server/src/test/java/tech/amak/portbuddy/server/web/admin/AdminAccountControllerTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.server.web.admin;\n\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\nimport org.springframework.test.web.servlet.MockMvc;\nimport org.springframework.test.web.servlet.setup.MockMvcBuilders;\n\nimport tech.amak.portbuddy.server.db.entity.AccountEntity;\nimport tech.amak.portbuddy.server.db.repo.AccountRepository;\nimport tech.amak.portbuddy.server.service.TunnelService;\n\n@ExtendWith(MockitoExtension.class)\nclass AdminAccountControllerTest {\n\n    private MockMvc mockMvc;\n\n    @Mock\n    private AccountRepository accountRepository;\n\n    @Mock\n    private TunnelService tunnelService;\n\n    @InjectMocks\n    private AdminAccountController adminAccountController;\n\n    @BeforeEach\n    void setUp() {\n        mockMvc = MockMvcBuilders.standaloneSetup(adminAccountController).build();\n    }\n\n    @Test\n    void blockAccount_shouldSetBlockedTrueAndSaveAndCloseTunnels() throws Exception {\n        final var accountId = UUID.randomUUID();\n        final var account = new AccountEntity();\n        account.setId(accountId);\n        account.setBlocked(false);\n\n        when(accountRepository.findById(accountId)).thenReturn(Optional.of(account));\n\n        mockMvc.perform(post(\"/api/admin/accounts/{accountId}/block\", accountId))\n            .andExpect(status().isNoContent());\n\n        verify(accountRepository).save(account);\n        verify(tunnelService).closeAllTunnels(account);\n        assert (account.isBlocked());\n    }\n}\n"
  },
  {
    "path": "ssl-service/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n  ~ Copyright (c) 2025 AMAK Inc. All rights reserved.\n  -->\n\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n    <parent>\n        <groupId>tech.amak</groupId>\n        <artifactId>port-buddy</artifactId>\n        <version>1.0-SNAPSHOT</version>\n    </parent>\n    <artifactId>ssl-service</artifactId>\n    <name>port-buddy-ssl-service</name>\n\n    <dependencies>\n        <dependency>\n            <groupId>tech.amak</groupId>\n            <artifactId>common</artifactId>\n            <version>${project.version}</version>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-web</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.cloud</groupId>\n            <artifactId>spring-cloud-starter-openfeign</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-validation</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-actuator</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-data-jpa</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.cloud</groupId>\n            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.postgresql</groupId>\n            <artifactId>postgresql</artifactId>\n            <version>42.7.4</version>\n        </dependency>\n        <dependency>\n            <groupId>org.flywaydb</groupId>\n            <artifactId>flyway-core</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.flywaydb</groupId>\n            <artifactId>flyway-database-postgresql</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-security</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>\n        </dependency>\n        <dependency>\n            <groupId>org.projectlombok</groupId>\n            <artifactId>lombok</artifactId>\n            <version>${lombok.version}</version>\n            <scope>provided</scope>\n        </dependency>\n        <dependency>\n            <groupId>org.shredzone.acme4j</groupId>\n            <artifactId>acme4j-client</artifactId>\n            <version>3.5.1</version>\n        </dependency>\n        <!-- BouncyCastle for CSR/PEM handling -->\n        <dependency>\n            <groupId>org.bouncycastle</groupId>\n            <artifactId>bcpkix-jdk18on</artifactId>\n            <version>1.83</version>\n        </dependency>\n        <dependency>\n            <groupId>org.bouncycastle</groupId>\n            <artifactId>bcprov-jdk18on</artifactId>\n            <version>1.83</version>\n        </dependency>\n        <!-- ShedLock for cluster-safe scheduling -->\n        <dependency>\n            <groupId>net.javacrumbs.shedlock</groupId>\n            <artifactId>shedlock-spring</artifactId>\n            <version>7.2.0</version>\n        </dependency>\n        <dependency>\n            <groupId>net.javacrumbs.shedlock</groupId>\n            <artifactId>shedlock-provider-jdbc-template</artifactId>\n            <version>7.2.0</version>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-test</artifactId>\n            <scope>test</scope>\n        </dependency>\n    </dependencies>\n\n    <build>\n        <plugins>\n            <plugin>\n                <groupId>org.springframework.boot</groupId>\n                <artifactId>spring-boot-maven-plugin</artifactId>\n                <executions>\n                    <execution>\n                        <goals>\n                            <goal>repackage</goal>\n                        </goals>\n                    </execution>\n                </executions>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-checkstyle-plugin</artifactId>\n            </plugin>\n        </plugins>\n    </build>\n</project>\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/SslServiceApplication.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.boot.context.properties.ConfigurationPropertiesScan;\nimport org.springframework.cloud.openfeign.EnableFeignClients;\n\n@SpringBootApplication\n@ConfigurationPropertiesScan\n@EnableFeignClients\npublic class SslServiceApplication {\n\n    /**\n     * Application entry point.\n     *\n     * @param args the command line arguments\n     */\n    public static void main(final String[] args) {\n        SpringApplication.run(SslServiceApplication.class, args);\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/client/ServerClient.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.client;\n\nimport org.springframework.cloud.openfeign.FeignClient;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestParam;\n\nimport tech.amak.portbuddy.common.dto.DnsInstructionsEmailRequest;\n\n@FeignClient(name = \"port-buddy-server\")\npublic interface ServerClient {\n\n    @PostMapping(\"/api/internal/email/dns-instructions\")\n    void sendDnsInstructions(@RequestBody final DnsInstructionsEmailRequest request);\n\n    @PostMapping(\"/api/internal/domains/ssl-active\")\n    void markSslActive(@RequestParam(\"domain\") String domain);\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/config/AppProperties.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.config;\n\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n@ConfigurationProperties(prefix = \"app\")\npublic record AppProperties(\n    Jwt jwt,\n    Acme acme,\n    Storage storage\n) {\n    public record Jwt(\n        String issuer,\n        String jwkSetUri\n    ) {\n    }\n\n    public record Acme(\n        String serverUrl,\n        String accountKeyPath,\n        String contactEmail,\n        String accountLocation,\n        Retry retry\n    ) {\n    }\n\n    public record Retry(\n        int maxAttempts,\n        long initialDelayMs,\n        long maxDelayMs,\n        double multiplier,\n        long jitterMs\n    ) {\n    }\n\n    public record Storage(\n        String certificatesDir\n    ) {\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/config/AsyncConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.config;\n\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.scheduling.annotation.EnableAsync;\n\n@Configuration\n@EnableAsync\npublic class AsyncConfig {\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/config/JpaAuditingConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.config;\n\nimport java.time.OffsetDateTime;\nimport java.time.ZoneOffset;\nimport java.util.Optional;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.data.auditing.DateTimeProvider;\nimport org.springframework.data.domain.AuditorAware;\nimport org.springframework.data.jpa.repository.config.EnableJpaAuditing;\nimport org.springframework.security.core.Authentication;\nimport org.springframework.security.core.context.SecurityContextHolder;\n\n@Configuration\n@EnableJpaAuditing(dateTimeProviderRef = \"dateTimeProvider\")\npublic class JpaAuditingConfig {\n\n    @Bean\n    public AuditorAware<String> auditorAware() {\n        return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())\n            .filter(Authentication::isAuthenticated)\n            .map(Authentication::getName)\n            .or(() -> Optional.of(\"system\"));\n    }\n\n    @Bean\n    public DateTimeProvider dateTimeProvider() {\n        return () -> Optional.of(OffsetDateTime.now(ZoneOffset.UTC));\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/config/RestConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.config;\n\nimport org.springframework.cloud.client.loadbalancer.LoadBalanced;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.web.client.RestTemplate;\n\n@Configuration\npublic class RestConfig {\n\n    /**\n     * Creates a load-balanced RestTemplate bean.\n     *\n     * @return the RestTemplate\n     */\n    @Bean\n    @LoadBalanced\n    public RestTemplate loadBalancedRestTemplate() {\n        return new RestTemplate();\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/config/SchedulingConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.config;\n\nimport javax.sql.DataSource;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.jdbc.core.JdbcTemplate;\nimport org.springframework.scheduling.annotation.EnableScheduling;\n\nimport net.javacrumbs.shedlock.core.LockProvider;\nimport net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;\nimport net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;\n\n/**\n * Configuration for ShedLock to ensure scheduled tasks run on one instance at a time.\n */\n@Configuration\n@EnableScheduling\n@EnableSchedulerLock(defaultLockAtMostFor = \"PT5M\", defaultLockAtLeastFor = \"PT5S\")\npublic class SchedulingConfig {\n\n    /**\n     * Provides a LockProvider based on JDBC Template.\n     *\n     * @param dataSource the data source\n     * @return the lock provider\n     */\n    @Bean\n    public LockProvider lockProvider(final DataSource dataSource) {\n        return new JdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder()\n            .withJdbcTemplate(new JdbcTemplate(dataSource))\n            .usingDbTime()\n            .withTableName(\"shedlock\")\n            .build());\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/domain/CertificateEntity.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.domain;\n\nimport java.time.OffsetDateTime;\nimport java.util.UUID;\n\nimport org.springframework.data.annotation.CreatedBy;\nimport org.springframework.data.annotation.CreatedDate;\nimport org.springframework.data.annotation.LastModifiedBy;\nimport org.springframework.data.annotation.LastModifiedDate;\nimport org.springframework.data.jpa.domain.support.AuditingEntityListener;\n\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.EntityListeners;\nimport jakarta.persistence.EnumType;\nimport jakarta.persistence.Enumerated;\nimport jakarta.persistence.GeneratedValue;\nimport jakarta.persistence.Id;\nimport jakarta.persistence.Table;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@Builder\n@Entity\n@Table(name = \"ssl_certificates\")\n@EntityListeners(AuditingEntityListener.class)\npublic class CertificateEntity {\n\n    @Id\n    @GeneratedValue\n    private UUID id;\n\n    @Column(name = \"domain\", nullable = false, unique = true, length = 255)\n    private String domain;\n\n    @Enumerated(EnumType.STRING)\n    @Column(name = \"status\", nullable = false, length = 32)\n    private CertificateStatus status = CertificateStatus.NEW;\n\n    /**\n     * Indicates that this certificate/domain is managed by the service (auto-renewal enabled).\n     */\n    @Column(name = \"managed\", nullable = false)\n    private boolean managed = false;\n\n    /**\n     * Verification method for ACME challenges. For now: MANUAL_DNS01 or HTTP01.\n     */\n    @Column(name = \"verification_method\", length = 64)\n    private String verificationMethod;\n\n    /**\n     * Optional contact email to notify about actions required (DNS setup) and results.\n     */\n    @Column(name = \"contact_email\", length = 255)\n    private String contactEmail;\n\n    @Column(name = \"issued_at\")\n    private OffsetDateTime issuedAt;\n\n    @Column(name = \"expires_at\")\n    private OffsetDateTime expiresAt;\n\n    @Column(name = \"certificate_path\", length = 1024)\n    private String certificatePath;\n\n    @Column(name = \"private_key_path\", length = 1024)\n    private String privateKeyPath;\n\n    @Column(name = \"chain_path\", length = 1024)\n    private String chainPath;\n\n    @Column(name = \"full_chain_path\", length = 1024)\n    private String fullChainPath;\n\n    @CreatedBy\n    @Column(name = \"created_by\", length = 100, updatable = false)\n    private String createdBy;\n\n    @CreatedDate\n    @Column(name = \"created_at\", updatable = false)\n    private OffsetDateTime createdAt;\n\n    @LastModifiedBy\n    @Column(name = \"updated_by\", length = 100)\n    private String updatedBy;\n\n    @LastModifiedDate\n    @Column(name = \"updated_at\")\n    private OffsetDateTime updatedAt;\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/domain/CertificateJobEntity.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.domain;\n\nimport java.time.OffsetDateTime;\nimport java.util.UUID;\n\nimport org.springframework.data.annotation.CreatedBy;\nimport org.springframework.data.annotation.CreatedDate;\nimport org.springframework.data.annotation.LastModifiedBy;\nimport org.springframework.data.annotation.LastModifiedDate;\nimport org.springframework.data.jpa.domain.support.AuditingEntityListener;\n\nimport jakarta.persistence.Column;\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.EntityListeners;\nimport jakarta.persistence.EnumType;\nimport jakarta.persistence.Enumerated;\nimport jakarta.persistence.GeneratedValue;\nimport jakarta.persistence.Id;\nimport jakarta.persistence.Table;\nimport lombok.Getter;\nimport lombok.Setter;\n\n@Getter\n@Setter\n@Entity\n@Table(name = \"ssl_certificate_jobs\")\n@EntityListeners(AuditingEntityListener.class)\npublic class CertificateJobEntity {\n\n    @Id\n    @GeneratedValue\n    private UUID id;\n\n    @Column(name = \"domain\", nullable = false, length = 255)\n    private String domain;\n\n    @Enumerated(EnumType.STRING)\n    @Column(name = \"status\", nullable = false, length = 32)\n    private CertificateJobStatus status = CertificateJobStatus.PENDING;\n\n    @Column(name = \"message\", length = 2000)\n    private String message;\n\n    @Column(name = \"contact_email\", length = 255)\n    private String contactEmail;\n\n    @Column(name = \"challenge_records_json\", columnDefinition = \"TEXT\")\n    private String challengeRecordsJson;\n\n    @Column(name = \"order_location\", length = 1024)\n    private String orderLocation;\n\n    @Column(name = \"authorization_urls_json\", columnDefinition = \"TEXT\")\n    private String authorizationUrlsJson;\n\n    @Column(name = \"challenge_expires_at\")\n    private OffsetDateTime challengeExpiresAt;\n\n    @Column(name = \"managed\", nullable = false)\n    private boolean managed = false;\n\n    @Column(name = \"started_at\")\n    private OffsetDateTime startedAt;\n\n    @Column(name = \"finished_at\")\n    private OffsetDateTime finishedAt;\n\n    @CreatedBy\n    @Column(name = \"created_by\", length = 100, updatable = false)\n    private String createdBy;\n\n    @CreatedDate\n    @Column(name = \"created_at\", updatable = false)\n    private OffsetDateTime createdAt;\n\n    @LastModifiedBy\n    @Column(name = \"updated_by\", length = 100)\n    private String updatedBy;\n\n    @LastModifiedDate\n    @Column(name = \"updated_at\")\n    private OffsetDateTime updatedAt;\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/domain/CertificateJobStatus.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.domain;\n\n/**\n * Status of certificate processing job.\n */\npublic enum CertificateJobStatus {\n    PENDING,\n    RUNNING,\n    WAITING_DNS_INSTRUCTIONS,\n    AWAITING_ADMIN_CONFIRMATION,\n    VERIFYING_DNS,\n    FINALIZING,\n    SUCCEEDED,\n    FAILED\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/domain/CertificateStatus.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.domain;\n\n/**\n * Status of SSL certificate lifecycle.\n */\npublic enum CertificateStatus {\n    NEW,\n    ACTIVE,\n    EXPIRED,\n    FAILED\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/repo/CertificateJobRepository.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.repo;\n\nimport java.util.Collection;\nimport java.util.UUID;\n\nimport org.springframework.data.jpa.repository.JpaRepository;\n\nimport tech.amak.portbuddy.sslservice.domain.CertificateJobEntity;\nimport tech.amak.portbuddy.sslservice.domain.CertificateJobStatus;\n\npublic interface CertificateJobRepository extends JpaRepository<CertificateJobEntity, UUID> {\n\n    boolean existsByDomainIgnoreCaseAndStatusIn(String domain, Collection<CertificateJobStatus> statuses);\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/repo/CertificateRepository.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.repo;\n\nimport java.time.OffsetDateTime;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\n\nimport org.springframework.data.jpa.repository.JpaRepository;\n\nimport tech.amak.portbuddy.sslservice.domain.CertificateEntity;\n\npublic interface CertificateRepository extends JpaRepository<CertificateEntity, UUID> {\n\n    /**\n     * Finds a certificate by domain.\n     *\n     * @param domain the domain name\n     * @return optional entity\n     */\n    Optional<CertificateEntity> findByDomain(String domain);\n\n    /**\n     * Finds a certificate by domain (case-insensitive).\n     *\n     * @param domain the domain name\n     * @return optional entity\n     */\n    Optional<CertificateEntity> findByDomainIgnoreCase(String domain);\n\n    /**\n     * Returns all certificates that are marked as managed by the service.\n     *\n     * @return list of managed certificates\n     */\n    List<CertificateEntity> findAllByManagedTrue();\n\n    /**\n     * Finds all managed certificates that expire before the given date.\n     *\n     * @param dateTime expiration threshold\n     * @return list of expiring certificates\n     */\n    List<CertificateEntity> findAllByManagedTrueAndExpiresAtBefore(OffsetDateTime dateTime);\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/security/SecurityConfig.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.security;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.http.HttpMethod;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;\nimport org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;\nimport org.springframework.security.config.http.SessionCreationPolicy;\nimport org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;\nimport org.springframework.security.oauth2.jwt.JwtDecoder;\nimport org.springframework.security.oauth2.jwt.JwtIssuerValidator;\nimport org.springframework.security.oauth2.jwt.JwtTimestampValidator;\nimport org.springframework.security.oauth2.jwt.NimbusJwtDecoder;\nimport org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.web.client.RestTemplate;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.sslservice.config.AppProperties;\n\n@Configuration\n@EnableWebSecurity\n@RequiredArgsConstructor\npublic class SecurityConfig {\n\n    private final AppProperties appProperties;\n\n    /**\n     * Configures HTTP security for the SSL service.\n     *\n     * @param http the HttpSecurity\n     * @param jwtDecoder the JwtDecoder\n     * @return the security filter chain\n     * @throws Exception if configuration fails\n     */\n    @Bean\n    public SecurityFilterChain securityFilterChain(final HttpSecurity http,\n                                                   final JwtDecoder jwtDecoder) throws Exception {\n        http\n            .cors(AbstractHttpConfigurer::disable)\n            .csrf(AbstractHttpConfigurer::disable)\n            .authorizeHttpRequests(auth -> auth\n                .requestMatchers(\"/actuator/health**\").permitAll()\n                .requestMatchers(HttpMethod.GET, \"/.well-known/acme-challenge/**\").permitAll()\n                .requestMatchers(\"/internal/api/**\").permitAll()\n                .requestMatchers(\"/api/**\").authenticated()\n                .anyRequest().permitAll()\n            )\n            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))\n            .oauth2ResourceServer(oauth2 -> oauth2\n                .jwt(jwt -> jwt\n                    .decoder(jwtDecoder)\n                    .jwtAuthenticationConverter(jwtAuthenticationConverter())\n                )\n            );\n        return http.build();\n    }\n\n    /**\n     * JWT decoder configured with remote JWK Set URI and issuer validation.\n     *\n     * @param restTemplate the RestTemplate to use for fetching JWK set\n     * @return the JwtDecoder\n     */\n    @Bean\n    public JwtDecoder jwtDecoder(final RestTemplate restTemplate) {\n        final var decoder = NimbusJwtDecoder\n            .withJwkSetUri(appProperties.jwt().jwkSetUri())\n            .restOperations(restTemplate)\n            .build();\n        final var withIssuer = new JwtIssuerValidator(appProperties.jwt().issuer());\n        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(\n            new JwtTimestampValidator(), withIssuer\n        ));\n        return decoder;\n    }\n\n    /**\n     * Converter for JWT authentication.\n     *\n     * @return JwtAuthenticationConverter instance\n     */\n    @Bean\n    public JwtAuthenticationConverter jwtAuthenticationConverter() {\n        return new JwtAuthenticationConverter();\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/AcmeAccountService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.service;\n\nimport java.io.IOException;\nimport java.io.InputStreamReader;\nimport java.nio.file.Files;\nimport java.security.KeyPair;\nimport java.security.Security;\n\nimport org.bouncycastle.jce.provider.BouncyCastleProvider;\nimport org.shredzone.acme4j.util.KeyPairUtils;\nimport org.springframework.core.io.ResourceLoader;\nimport org.springframework.stereotype.Service;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.sslservice.config.AppProperties;\n\n/**\n * Loads or creates ACME account key pair from configured location.\n */\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class AcmeAccountService {\n\n    static {\n        Security.addProvider(new BouncyCastleProvider());\n    }\n\n    private final AppProperties properties;\n    private final ResourceLoader resourceLoader;\n\n    /**\n     * Loads the ACME account {@link KeyPair} from {@code app.acme.accountKeyPath}.\n     * The key must be in PEM format (PKCS#1 or PKCS#8). If the file does not exist,\n     * a new key pair is generated and saved.\n     *\n     * @return account key pair\n     */\n    public KeyPair loadAccountKeyPair() {\n        final var pathString = properties.acme().accountKeyPath();\n        final var resource = resourceLoader.getResource(pathString);\n\n        if (resource.exists()) {\n            try (final var reader = new InputStreamReader(resource.getInputStream())) {\n                return KeyPairUtils.readKeyPair(reader);\n            } catch (final IOException e) {\n                log.error(\"Failed to load ACME account key pair from {}\", pathString, e);\n                throw new IllegalStateException(\"Failed to load ACME account key pair\", e);\n            }\n        } else {\n            final var keyPair = KeyPairUtils.createKeyPair();\n            try {\n                final var file = resource.getFile();\n                final var parentFile = file.getParentFile();\n                if (parentFile != null && !parentFile.exists() && !parentFile.mkdirs()) {\n                    throw new IOException(\"Failed to create directory: \" + parentFile);\n                }\n                try (final var writer = Files.newBufferedWriter(file.toPath())) {\n                    KeyPairUtils.writeKeyPair(keyPair, writer);\n                }\n            } catch (final IOException e) {\n                log.error(\"Failed to save ACME account key pair to {}\", pathString, e);\n                throw new IllegalStateException(\"Failed to save ACME account key pair\", e);\n            }\n            return keyPair;\n        }\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/AcmeCertificateService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.service;\n\nimport static java.time.ZoneOffset.UTC;\nimport static org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers.pkcs_9_at_extensionRequest;\nimport static org.bouncycastle.asn1.x509.GeneralName.dNSName;\n\nimport java.nio.charset.StandardCharsets;\nimport java.security.KeyPair;\nimport java.security.cert.X509Certificate;\nimport java.time.OffsetDateTime;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.UUID;\n\nimport org.bouncycastle.asn1.x500.X500Name;\nimport org.bouncycastle.operator.ContentSigner;\nimport org.bouncycastle.operator.OperatorCreationException;\nimport org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;\nimport org.bouncycastle.pkcs.PKCS10CertificationRequest;\nimport org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder;\nimport org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder;\nimport org.shredzone.acme4j.Account;\nimport org.shredzone.acme4j.Authorization;\nimport org.shredzone.acme4j.Order;\nimport org.shredzone.acme4j.Session;\nimport org.shredzone.acme4j.Status;\nimport org.shredzone.acme4j.challenge.Dns01Challenge;\nimport org.shredzone.acme4j.challenge.Http01Challenge;\nimport org.slf4j.MDC;\nimport org.springframework.beans.factory.ObjectProvider;\nimport org.springframework.scheduling.annotation.Async;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.transaction.support.TransactionSynchronization;\nimport org.springframework.transaction.support.TransactionSynchronizationManager;\n\nimport com.fasterxml.jackson.core.type.TypeReference;\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.sslservice.client.ServerClient;\nimport tech.amak.portbuddy.sslservice.domain.CertificateEntity;\nimport tech.amak.portbuddy.sslservice.domain.CertificateJobEntity;\nimport tech.amak.portbuddy.sslservice.domain.CertificateJobStatus;\nimport tech.amak.portbuddy.sslservice.domain.CertificateStatus;\nimport tech.amak.portbuddy.sslservice.repo.CertificateJobRepository;\nimport tech.amak.portbuddy.sslservice.repo.CertificateRepository;\nimport tech.amak.portbuddy.sslservice.work.ChallengeTokenStore;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class AcmeCertificateService {\n\n    private final CertificateRepository certificateRepository;\n    private final CertificateJobRepository jobRepository;\n    private final ChallengeTokenStore challengeTokenStore;\n    private final AcmeAccountService acmeAccountService;\n    private final AcmeClientService acmeClientService;\n    private final CertificateStorageService storageService;\n    private final RetryExecutor retryExecutor;\n    private final DnsResolverService dnsResolverService;\n    private final EmailService emailService;\n    private final ObjectMapper objectMapper;\n    private final ServerClient serverClient;\n    private final ObjectProvider<AcmeCertificateService> self;\n\n    /**\n     * Submits an asynchronous job to issue or renew a certificate for the given domain.\n     *\n     * @param domain      the domain to issue or renew certificate for\n     * @param requestedBy username of requester\n     * @param managed     whether the certificate should be managed (auto-renewed)\n     * @return persisted job entity\n     */\n    @Transactional\n    public CertificateJobEntity submitJob(final String domain, final String requestedBy, final boolean managed) {\n        final var normalizedDomain = domain.toLowerCase();\n\n        // Prevent duplicate jobs for the same domain when a job is already pending or running\n        final var activeStatuses = Set.of(CertificateJobStatus.PENDING, CertificateJobStatus.RUNNING);\n        final var existsActive = jobRepository.existsByDomainIgnoreCaseAndStatusIn(normalizedDomain, activeStatuses);\n        if (existsActive) {\n            throw new IllegalStateException(\"A certificate job is already in progress for domain: \" + normalizedDomain);\n        }\n\n        final var job = new CertificateJobEntity();\n        job.setDomain(normalizedDomain);\n        job.setStatus(CertificateJobStatus.PENDING);\n        job.setManaged(managed);\n        // If we have a managed certificate record, inherit contact email for notifications, \n        // otherwise use the one provided by the requester\n        final var existingCert = certificateRepository.findByDomainIgnoreCase(normalizedDomain);\n        if (existingCert.isPresent()) {\n            job.setContactEmail(existingCert.get().getContactEmail());\n        } else {\n            job.setContactEmail(requestedBy);\n        }\n        final var savedJob = jobRepository.save(job);\n        // Fire and forget async processing after transaction commit to avoid race condition\n        if (TransactionSynchronizationManager.isActualTransactionActive()) {\n            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {\n                @Override\n                public void afterCommit() {\n                    self.getIfAvailable().processJobAsync(savedJob.getId());\n                }\n            });\n        } else {\n            self.getIfAvailable().processJobAsync(savedJob.getId());\n        }\n        return savedJob;\n    }\n\n    /**\n     * Processes the job asynchronously. This method encapsulates ACME/Let’s Encrypt logic.\n     * In this initial implementation, it simulates success and updates DB records.\n     *\n     * @param jobId the job identifier\n     */\n    @Async\n    @Transactional\n    public void processJobAsync(final UUID jobId) {\n        final var job = jobRepository.findById(jobId).orElseThrow();\n\n        try {\n            MDC.put(\"jobId\", String.valueOf(jobId));\n            MDC.put(\"domain\", job.getDomain());\n            job.setStatus(CertificateJobStatus.RUNNING);\n            job.setStartedAt(OffsetDateTime.now());\n            jobRepository.save(job);\n\n            // If the requested domain contains a wildcard, use manual DNS-01 flow with admin confirmation.\n            if (job.getDomain().contains(\"*\")) {\n                performAcmeDns01Initiate(job);\n            } else {\n                performAcmeHttp01Issuance(job);\n            }\n        } catch (final Exception e) {\n            log.error(\"Certificate job failed\", e);\n            job.setStatus(CertificateJobStatus.FAILED);\n            job.setFinishedAt(OffsetDateTime.now());\n            job.setMessage(e.getMessage());\n            jobRepository.save(job);\n        } finally {\n            MDC.clear();\n        }\n    }\n\n    /**\n     * Performs the full ACME HTTP-01 issuance flow for the job's domain.\n     *\n     * @param job the job to process\n     */\n    private void performAcmeHttp01Issuance(final CertificateJobEntity job) throws Exception {\n        final var domain = job.getDomain();\n\n        updateJobMessage(job, \"Starting issuance for '%s'\", domain);\n\n        // 1) Create session and load/login account\n        final Session session = acmeClientService.newSession();\n        updateJobMessage(job, \"ACME session created\");\n\n        final KeyPair accountKeyPair = acmeAccountService.loadAccountKeyPair();\n        updateJobMessage(job, \"Account key loaded\");\n\n        final Account account = retryExecutor.callWithRetry(\"acme.login\", () ->\n            acmeClientService.loginOrRegister(session, accountKeyPair));\n        updateJobMessage(job, \"Logged into ACME account\");\n\n        // 2) Create a new order for the domain\n        final Order order = retryExecutor.callWithRetry(\"acme.order.create\", () -> account\n            .newOrder().domains(domain).create());\n        updateJobMessage(job, \"ACME order created\");\n\n        // 3) Complete HTTP-01 challenge for each authorization\n        for (final Authorization auth : order.getAuthorizations()) {\n            if (auth.getStatus() == Status.VALID) {\n                continue;\n            }\n            final Http01Challenge httpChallenge = auth.getChallenges().stream()\n                .filter(challenge -> challenge.getType().equals(Http01Challenge.TYPE))\n                .map(challenge -> (Http01Challenge) challenge)\n                .findFirst()\n                .orElse(null);\n            if (httpChallenge == null) {\n                throw new IllegalStateException(\"HTTP-01 challenge not available for domain: \" + domain);\n            }\n            final var token = httpChallenge.getToken();\n            final var challengeContent = httpChallenge.getAuthorization();\n            challengeTokenStore.putToken(token, challengeContent);\n            try {\n                updateJobMessage(job, \"HTTP-01 challenge published (token=%s)\", token);\n                retryExecutor.callWithRetry(\"acme.challenge.trigger\", () -> {\n                    httpChallenge.trigger();\n                    return Boolean.TRUE;\n                });\n                // poll until VALID or failure with backoff\n                pollAuthorizationValidWithRetry(auth, 90, 2_000);\n                updateJobMessage(job, \"HTTP-01 challenge validated\");\n            } finally {\n                challengeTokenStore.removeToken(token);\n            }\n        }\n\n        // 4) Generate domain key and CSR\n        final KeyPair domainKeyPair = storageService.generateRsaKeyPair();\n        final var csr = buildCsrDer(domain, domainKeyPair);\n        updateJobMessage(job, \"CSR generated\");\n\n        // 5) Finalize order\n        retryExecutor.callWithRetry(\"acme.order.finalize\", () -> {\n            order.execute(csr);\n            return Boolean.TRUE;\n        });\n        updateJobMessage(job, \"Order finalized, waiting for issuance\");\n        pollOrderValidWithRetry(order, 120, 2_000);\n\n        // 6) Download certificate: acme4j returns X509Certificate(s); convert to PEM strings\n        final var downloaded = retryExecutor.callWithRetry(\"acme.cert.download\", order::getCertificate);\n        final var certChain = downloaded.getCertificateChain();\n\n        // Convert primary certificate to PEM string\n        final var leafCertPem = toPem(downloaded.getCertificate());\n        final var chainPem = certChain == null\n            ? \"\"\n            : certChain.stream()\n            .skip(1)\n            .map(this::toPem)\n            .reduce(\"\", (a, b) -> a + b);\n\n        // 7) Store files\n        final var keyPath = storageService.writePrivateKeyPem(domain, domainKeyPair);\n        final var certPath = storageService.writeCertPem(domain, leafCertPem);\n        final var chainPath = storageService.writeChainPem(domain, chainPem);\n        final var fullChainPath = storageService.writeFullChainPem(domain, leafCertPem + chainPem);\n\n        // 8) Update DB\n        var certificate = certificateRepository.findByDomain(domain).orElse(null);\n        if (certificate == null) {\n            certificate = new CertificateEntity();\n            certificate.setDomain(domain);\n        }\n        certificate.setManaged(job.isManaged());\n        certificate.setContactEmail(job.getContactEmail());\n\n        // Try to extract validity from leaf certificate\n        final var x509 = downloaded.getCertificate();\n        certificate.setStatus(CertificateStatus.ACTIVE);\n        certificate.setIssuedAt(OffsetDateTime.ofInstant(x509.getNotBefore().toInstant(), UTC));\n        certificate.setExpiresAt(OffsetDateTime.ofInstant(x509.getNotAfter().toInstant(), UTC));\n        certificate.setPrivateKeyPath(keyPath.toAbsolutePath().toString());\n        certificate.setCertificatePath(certPath.toAbsolutePath().toString());\n        certificate.setChainPath(chainPath.toAbsolutePath().toString());\n        certificate.setFullChainPath(fullChainPath.toAbsolutePath().toString());\n        certificateRepository.save(certificate);\n\n        // Notify server module about successful issuance\n        try {\n            serverClient.markSslActive(domain);\n        } catch (final Exception e) {\n            log.warn(\"Failed to notify server module about SSL activation for {}\", domain, e);\n        }\n\n        // Single-entity model: no separate root-domain metadata to update\n\n        job.setStatus(CertificateJobStatus.SUCCEEDED);\n        job.setFinishedAt(OffsetDateTime.now());\n        job.setMessage(\"Certificate issued/renewed successfully.\");\n        jobRepository.save(job);\n    }\n\n    /**\n     * Initiates ACME DNS-01 flow for wildcard domains and pauses awaiting admin DNS confirmation.\n     * Stores ACME order and authorization info as well as required TXT records in the job.\n     *\n     * @param job the job to process\n     */\n    private void performAcmeDns01Initiate(final CertificateJobEntity job) throws Exception {\n        final var requestedDomain = job.getDomain();\n        updateJobMessage(job, \"Starting DNS-01 issuance for '%s'\", requestedDomain);\n\n        final Session session = acmeClientService.newSession();\n        updateJobMessage(job, \"ACME session created\");\n\n        final KeyPair accountKeyPair = acmeAccountService.loadAccountKeyPair();\n        final Account account = retryExecutor\n            .callWithRetry(\"acme.login\", () -> acmeClientService.loginOrRegister(session, accountKeyPair));\n        updateJobMessage(job, \"Logged into ACME account\");\n\n        // Create order for apex + wildcard when wildcard requested, otherwise single domain\n        final var domains = new ArrayList<String>();\n        if (requestedDomain.startsWith(\"*.\")) {\n            final var apex = requestedDomain.substring(2);\n            domains.add(apex);\n        }\n        domains.add(requestedDomain);\n\n        final Order order = retryExecutor.callWithRetry(\"acme.order.create\", () -> account\n            .newOrder().domains(domains.toArray(String[]::new)).create());\n        job.setOrderLocation(order.getLocation().toString());\n\n        // Collect DNS-01 challenges and build instruction payload\n        final var authorizations = order.getAuthorizations();\n        final var authUrls = new ArrayList<String>();\n        final var records = new ArrayList<Map<String, String>>();\n        OffsetDateTime authExpiresAt = null;\n        for (final Authorization auth : authorizations) {\n            authUrls.add(auth.getLocation().toString());\n            final var idDomain = auth.getIdentifier().getDomain();\n            final var recordHost = idDomain.startsWith(\"*.\") ? idDomain.substring(2) : idDomain;\n            final var recordName = \"_acme-challenge.\" + recordHost;\n            final var dns01 = auth.getChallenges().stream()\n                .filter(challenge -> challenge.getType().equals(Dns01Challenge.TYPE))\n                .map(challenge -> (Dns01Challenge) challenge)\n                .findFirst()\n                .orElse(null);\n            if (dns01 == null) {\n                throw new IllegalStateException(\"DNS-01 challenge not available for domain: \" + idDomain);\n            }\n            final var txtValue = dns01.getDigest();\n            final var map = new HashMap<String, String>();\n            map.put(\"name\", recordName);\n            map.put(\"value\", txtValue);\n            records.add(map);\n            // Track the earliest authorization expiration across all identifiers\n            final var expiresOpt = auth.getExpires();\n            if (expiresOpt != null && expiresOpt.isPresent()) {\n                final var expiresAt = OffsetDateTime.ofInstant(expiresOpt.get(), UTC);\n                if (authExpiresAt == null || expiresAt.isBefore(authExpiresAt)) {\n                    authExpiresAt = expiresAt;\n                }\n            }\n        }\n        job.setAuthorizationUrlsJson(objectMapper.writeValueAsString(authUrls));\n        job.setChallengeRecordsJson(objectMapper.writeValueAsString(records));\n        job.setChallengeExpiresAt(authExpiresAt);\n        job.setStatus(CertificateJobStatus.WAITING_DNS_INSTRUCTIONS);\n        jobRepository.save(job);\n\n        // Send email to admin with instructions (best-effort)\n        emailService.sendDnsInstructions(job, records, authExpiresAt);\n        job.setStatus(CertificateJobStatus.AWAITING_ADMIN_CONFIRMATION);\n        updateJobMessage(job, \"Awaiting admin DNS TXT creation for %s\", requestedDomain);\n    }\n\n    /**\n     * Confirms DNS TXT records are in place and continues ACME DNS-01 flow to issuance.\n     *\n     * @param jobId certificate job id\n     */\n    @Transactional\n    public void confirmDnsAndContinue(final UUID jobId) {\n        final var job = jobRepository.findById(jobId).orElseThrow();\n        MDC.put(\"jobId\", String.valueOf(jobId));\n        MDC.put(\"domain\", job.getDomain());\n        try {\n            if (job.getStatus() != CertificateJobStatus.AWAITING_ADMIN_CONFIRMATION) {\n                throw new IllegalStateException(\"Job is not awaiting admin confirmation\");\n            }\n            job.setStatus(CertificateJobStatus.VERIFYING_DNS);\n            jobRepository.save(job);\n\n            final var records = objectMapper.readValue(job.getChallengeRecordsJson(),\n                new TypeReference<List<Map<String, String>>>() {\n                });\n\n            // Verify TXT visibility for each record\n            for (final var record : records) {\n                final var name = record.get(\"name\");\n                final var value = record.get(\"value\");\n                updateJobMessage(job, \"Checking TXT %s\", name);\n                retryExecutor.callWithRetry(\"dns.check.\" + name, () -> {\n                    final var ok = dnsResolverService.isTxtRecordVisible(name, value);\n                    if (!ok) {\n                        throw new IllegalStateException(\"TXT record not visible yet: \" + name);\n                    }\n                    return Boolean.TRUE;\n                });\n            }\n\n            // Re-bind order and trigger challenges\n            final Session session = acmeClientService.newSession();\n            final KeyPair accountKeyPair = acmeAccountService.loadAccountKeyPair();\n            final Account account = retryExecutor\n                .callWithRetry(\"acme.login\", () -> acmeClientService.loginOrRegister(session, accountKeyPair));\n            final var orderLocation = job.getOrderLocation();\n            final Order order = acmeClientService.bindOrder(session, account, accountKeyPair, orderLocation);\n\n            final var authorizations = order.getAuthorizations();\n            for (final Authorization auth : authorizations) {\n                if (auth.getStatus() == Status.VALID) {\n                    continue;\n                }\n                final var dns01 = auth.getChallenges().stream()\n                    .filter(challenge -> challenge.getType().equals(Dns01Challenge.TYPE))\n                    .map(challenge -> (Dns01Challenge) challenge)\n                    .findFirst()\n                    .orElse(null);\n                if (dns01 == null) {\n                    throw new IllegalStateException(\"DNS-01 challenge not available: \" + auth.getIdentifier());\n                }\n                retryExecutor.callWithRetry(\"acme.challenge.trigger\", () -> {\n                    dns01.trigger();\n                    return Boolean.TRUE;\n                });\n                pollAuthorizationValidWithRetry(auth, 180, 2_000);\n            }\n\n            // Generate key and CSR for apex + wildcard (or single domain)\n            final var domain = job.getDomain();\n            final KeyPair domainKeyPair = storageService.generateRsaKeyPair();\n            final byte[] csr;\n            if (domain.startsWith(\"*.\")) {\n                final var apex = domain.substring(2);\n                csr = buildCsrDer(List.of(apex, domain), domainKeyPair);\n            } else {\n                csr = buildCsrDer(List.of(domain), domainKeyPair);\n            }\n\n            retryExecutor.callWithRetry(\"acme.order.finalize\", () -> {\n                order.execute(csr);\n                return Boolean.TRUE;\n            });\n            updateJobMessage(job, \"Order finalized, waiting for issuance\");\n            pollOrderValidWithRetry(order, 180, 2_000);\n\n            final var downloaded = retryExecutor.callWithRetry(\"acme.cert.download\", order::getCertificate);\n            final var certChain = downloaded.getCertificateChain();\n            final var leafCertPem = toPem(downloaded.getCertificate());\n            final var chainPem = certChain == null\n                ? \"\"\n                : certChain.stream()\n                .skip(1)\n                .map(this::toPem)\n                .reduce(\"\", (a, b) -> a + b);\n\n            final var keyPath = storageService.writePrivateKeyPem(domain, domainKeyPair);\n            final var certPath = storageService.writeCertPem(domain, leafCertPem);\n            final var chainPath = storageService.writeChainPem(domain, chainPem);\n            final var fullChainPath = storageService.writeFullChainPem(domain, leafCertPem + chainPem);\n\n            var certificate = certificateRepository.findByDomain(domain).orElse(null);\n            if (certificate == null) {\n                certificate = new CertificateEntity();\n                certificate.setDomain(domain);\n            }\n            certificate.setManaged(job.isManaged());\n            certificate.setContactEmail(job.getContactEmail());\n\n            final var x509 = downloaded.getCertificate();\n            certificate.setStatus(CertificateStatus.ACTIVE);\n            certificate.setIssuedAt(OffsetDateTime.ofInstant(x509.getNotBefore().toInstant(), UTC));\n            certificate.setExpiresAt(OffsetDateTime.ofInstant(x509.getNotAfter().toInstant(), UTC));\n            certificate.setPrivateKeyPath(keyPath.toAbsolutePath().toString());\n            certificate.setCertificatePath(certPath.toAbsolutePath().toString());\n            certificate.setChainPath(chainPath.toAbsolutePath().toString());\n            certificate.setFullChainPath(fullChainPath.toAbsolutePath().toString());\n            certificateRepository.save(certificate);\n\n            // Notify server module about successful issuance\n            try {\n                serverClient.markSslActive(domain);\n            } catch (final Exception e) {\n                log.warn(\"Failed to notify server module about SSL activation for {}\", domain, e);\n            }\n\n            // Single-entity model: no separate root-domain metadata to update\n\n            job.setStatus(CertificateJobStatus.SUCCEEDED);\n            job.setFinishedAt(OffsetDateTime.now());\n            job.setMessage(\"Certificate issued successfully.\");\n            jobRepository.save(job);\n        } catch (final Exception e) {\n            log.error(\"confirmDnsAndContinue failed\", e);\n            job.setStatus(CertificateJobStatus.FAILED);\n            job.setFinishedAt(OffsetDateTime.now());\n            job.setMessage(e.getMessage());\n            jobRepository.save(job);\n            throw new IllegalStateException(\"DNS confirmation/issuance failed\", e);\n        } finally {\n            MDC.clear();\n        }\n    }\n\n    private void pollAuthorizationValidWithRetry(final Authorization auth, final int maxSeconds, final long sleepMillis)\n        throws InterruptedException {\n        final long deadline = System.currentTimeMillis() + maxSeconds * 1000L;\n        while (System.currentTimeMillis() < deadline) {\n            try {\n                retryExecutor.callWithRetry(\"acme.auth.update\", () -> {\n                    auth.update();\n                    return Boolean.TRUE;\n                });\n            } catch (final Exception e) {\n                // If a non-transient error bubbles up, rethrow as IllegalStateException\n                throw new IllegalStateException(\"Authorization update failed\", e);\n            }\n            final var status = auth.getStatus();\n            if (status == Status.VALID) {\n                return;\n            }\n            if (status == Status.INVALID) {\n                throw new IllegalStateException(\"Authorization invalid: \" + auth.getLocation());\n            }\n            Thread.sleep(sleepMillis);\n        }\n        throw new IllegalStateException(\"Authorization validation timed out for \" + auth.getIdentifier());\n    }\n\n    private void pollOrderValidWithRetry(final Order order, final int maxSeconds, final long sleepMillis)\n        throws InterruptedException {\n        final long deadline = System.currentTimeMillis() + maxSeconds * 1000L;\n        while (System.currentTimeMillis() < deadline) {\n            try {\n                retryExecutor.callWithRetry(\"acme.order.update\", () -> {\n                    order.update();\n                    return Boolean.TRUE;\n                });\n            } catch (final Exception e) {\n                throw new IllegalStateException(\"Order update failed\", e);\n            }\n            final var status = order.getStatus();\n            if (status == Status.VALID) {\n                return;\n            }\n            if (status == Status.INVALID) {\n                throw new IllegalStateException(\"Order became INVALID\");\n            }\n            Thread.sleep(sleepMillis);\n        }\n        throw new IllegalStateException(\"Order finalization timed out\");\n    }\n\n    private byte[] buildCsrDer(final List<String> domains, final KeyPair keyPair)\n        throws OperatorCreationException, java.io.IOException {\n        final var primary = domains.getFirst();\n        final X500Name subject = new X500Name(\"CN=\" + primary);\n        final PKCS10CertificationRequestBuilder p10Builder =\n            new JcaPKCS10CertificationRequestBuilder(subject, keyPair.getPublic());\n\n        // Add SAN extension with all domains\n        final var genNames = domains.stream()\n            .map(d -> new org.bouncycastle.asn1.x509.GeneralName(dNSName, d))\n            .toArray(org.bouncycastle.asn1.x509.GeneralName[]::new);\n        final var subjectAltName = new org.bouncycastle.asn1.x509.GeneralNames(genNames);\n        final var extGen = new org.bouncycastle.asn1.x509.ExtensionsGenerator();\n        extGen.addExtension(org.bouncycastle.asn1.x509.Extension.subjectAlternativeName, false, subjectAltName);\n        final var extensions = extGen.generate();\n        p10Builder.addAttribute(pkcs_9_at_extensionRequest, extensions);\n\n        final ContentSigner signer = new JcaContentSignerBuilder(\"SHA256withRSA\").build(keyPair.getPrivate());\n        final PKCS10CertificationRequest csr = p10Builder.build(signer);\n        return csr.getEncoded();\n    }\n\n    private byte[] buildCsrDer(final String domain, final KeyPair keyPair)\n        throws OperatorCreationException, java.io.IOException {\n        return buildCsrDer(List.of(domain), keyPair);\n    }\n\n    private String toPem(final X509Certificate cert) {\n        try {\n            final var base64 = java.util.Base64.getMimeEncoder(64, \"\\n\".getBytes(StandardCharsets.US_ASCII))\n                .encodeToString(cert.getEncoded());\n            return \"-----BEGIN CERTIFICATE-----\\n\" + base64 + \"\\n-----END CERTIFICATE-----\\n\";\n        } catch (final Exception e) {\n            throw new IllegalStateException(\"Failed to encode certificate to PEM\", e);\n        }\n    }\n\n    private void updateJobMessage(final CertificateJobEntity job, final String template, final Object... args) {\n        final var message = String.format(template, args);\n        job.setMessage(message);\n        jobRepository.save(job);\n        log.info(message);\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/AcmeClientService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.service;\n\nimport java.net.URL;\nimport java.security.KeyPair;\n\nimport org.shredzone.acme4j.Account;\nimport org.shredzone.acme4j.AccountBuilder;\nimport org.shredzone.acme4j.Login;\nimport org.shredzone.acme4j.Order;\nimport org.shredzone.acme4j.Session;\nimport org.shredzone.acme4j.exception.AcmeException;\nimport org.springframework.stereotype.Service;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.sslservice.config.AppProperties;\n\n/**\n * Provides ACME Session and Account using configured ACME directory and contact email.\n */\n@Service\n@RequiredArgsConstructor\npublic class AcmeClientService {\n\n    private final AppProperties properties;\n\n    /**\n     * Creates an ACME {@link Session} for the configured server URL.\n     *\n     * @return a new Session\n     */\n    public Session newSession() {\n        return new Session(properties.acme().serverUrl());\n    }\n\n    /**\n     * Logs into an existing ACME account using the provided key, or creates it on-the-fly if not found.\n     *\n     * @param session the ACME session\n     * @param keyPair the ACME account key pair\n     * @return logged-in or newly created {@link Account}\n     * @throws AcmeException on ACME failures\n     */\n    public Account loginOrRegister(final Session session, final KeyPair keyPair) throws AcmeException {\n        // Try to log in to an existing account identified by the key pair\n        try {\n            return new AccountBuilder()\n                .onlyExisting()\n                .useKeyPair(keyPair)\n                .create(session);\n        } catch (final AcmeException ex) {\n            // Not found -> create a new account below\n        }\n\n        final var contactEmail = properties.acme().contactEmail();\n        final var builder = new AccountBuilder()\n            .agreeToTermsOfService()\n            .useKeyPair(keyPair);\n        if (contactEmail != null && !contactEmail.isEmpty()) {\n            builder.addContact(\"mailto:\" + contactEmail);\n        }\n        return builder.create(session);\n    }\n\n    /**\n     * Binds an existing ACME order by its location URL using configured account location and the provided key.\n     *\n     * @param session       ACME session\n     * @param keyPair       ACME account key pair\n     * @param orderLocation order URL as string\n     * @return bound {@link Order}\n     * @throws AcmeException on ACME failures\n     */\n    public Order bindOrder(final Session session,\n                           final Account account,\n                           final KeyPair keyPair,\n                           final String orderLocation) throws AcmeException {\n        try {\n            final Login login = session.login(account.getLocation(), keyPair);\n            return login.bindOrder(new URL(orderLocation));\n        } catch (final Exception e) {\n            throw new AcmeException(\"Failed to bind order: \" + e.getMessage(), e);\n        }\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/CertificateStorageService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.service;\n\nimport java.io.IOException;\nimport java.io.OutputStreamWriter;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.security.KeyPair;\nimport java.security.KeyPairGenerator;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.SecureRandom;\n\nimport org.bouncycastle.openssl.jcajce.JcaPEMWriter;\nimport org.springframework.stereotype.Service;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.sslservice.config.AppProperties;\n\n/**\n * Handles certificate and key file storage in PEM format.\n */\n@Service\n@RequiredArgsConstructor\npublic class CertificateStorageService {\n\n    private final AppProperties properties;\n\n    /**\n     * Generates a new RSA key pair.\n     *\n     * @return generated key pair\n     */\n    public KeyPair generateRsaKeyPair() {\n        try {\n            final var kpg = KeyPairGenerator.getInstance(\"RSA\");\n            kpg.initialize(2048, SecureRandom.getInstanceStrong());\n            return kpg.generateKeyPair();\n        } catch (final NoSuchAlgorithmException e) {\n            throw new IllegalStateException(\"RSA not available\", e);\n        }\n    }\n\n    /**\n     * Writes a private key to PEM file.\n     *\n     * @param domain  domain name (used for file names)\n     * @param keyPair key pair\n     * @return path to written private key file\n     */\n    public Path writePrivateKeyPem(final String domain, final KeyPair keyPair) {\n        final var baseDir = resolveBaseDir();\n        final var file = baseDir.resolve(safe(domain) + \".key.pem\");\n        writePem(file, keyPair.getPrivate());\n        return file;\n    }\n\n    /**\n     * Writes certificate chain to PEM file.\n     *\n     * @param domain   domain\n     * @param chainPem full chain in PEM string\n     * @return path to chain file\n     */\n    public Path writeChainPem(final String domain, final String chainPem) {\n        final var baseDir = resolveBaseDir();\n        final var file = baseDir.resolve(safe(domain) + \".chain.pem\");\n        writeString(file, chainPem);\n        return file;\n    }\n\n    /**\n     * Writes leaf certificate to PEM file.\n     *\n     * @param domain  domain\n     * @param certPem certificate PEM\n     * @return path to cert file\n     */\n    public Path writeCertPem(final String domain, final String certPem) {\n        final var baseDir = resolveBaseDir();\n        final var file = baseDir.resolve(safe(domain) + \".cert.pem\");\n        writeString(file, certPem);\n        return file;\n    }\n\n    /**\n     * Writes full certificate chain to PEM file.\n     *\n     * @param domain       domain\n     * @param fullChainPem full chain PEM\n     * @return path to full chain file\n     */\n    public Path writeFullChainPem(final String domain, final String fullChainPem) {\n        final var baseDir = resolveBaseDir();\n        final var file = baseDir.resolve(safe(domain) + \".fullchain.pem\");\n        writeString(file, fullChainPem);\n        return file;\n    }\n\n    private Path resolveBaseDir() {\n        final var resource = properties.storage().certificatesDir();\n        // Expecting formats like \"file:/abs/path\" or just a directory. Normalize to Path.\n        final Path dir;\n        if (resource.startsWith(\"file:\")) {\n            dir = Path.of(resource.substring(\"file:\".length()));\n        } else {\n            dir = Path.of(resource);\n        }\n        try {\n            Files.createDirectories(dir);\n        } catch (final IOException e) {\n            throw new IllegalStateException(\"Cannot create certificates directory: \" + dir, e);\n        }\n        return dir;\n    }\n\n    private void writePem(final Path file, final Object pemObject) {\n        try {\n            try (var os = Files.newOutputStream(file);\n                 var writer = new OutputStreamWriter(os, StandardCharsets.UTF_8);\n                 var pemWriter = new JcaPEMWriter(writer)) {\n                pemWriter.writeObject(pemObject);\n            }\n        } catch (final IOException e) {\n            throw new IllegalStateException(\"Failed to write PEM file: \" + file, e);\n        }\n    }\n\n    private void writeString(final Path file, final String content) {\n        try {\n            Files.writeString(file, content, StandardCharsets.UTF_8);\n        } catch (final IOException e) {\n            throw new IllegalStateException(\"Failed to write file: \" + file, e);\n        }\n    }\n\n    private String safe(final String domain) {\n        return domain.toLowerCase().replaceAll(\"[^a-z0-9_.-]\", \"_\");\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/DnsResolverService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.service;\n\n/**\n * Resolves DNS TXT records and verifies visibility of ACME DNS-01 tokens.\n */\npublic interface DnsResolverService {\n\n    /**\n     * Checks whether a TXT record with the expected value is visible at the given FQDN.\n     * Implementations may query multiple public resolvers.\n     *\n     * @param fqdn fully-qualified domain name of the TXT record\n     * @param expectedValue expected TXT value\n     * @return true if visible, false otherwise\n     */\n    boolean isTxtRecordVisible(String fqdn, String expectedValue);\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/EmailService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.service;\n\nimport java.time.OffsetDateTime;\nimport java.util.List;\nimport java.util.Map;\n\nimport tech.amak.portbuddy.sslservice.domain.CertificateJobEntity;\n\n/**\n * Sends notification emails to administrators about DNS setup and results.\n */\npublic interface EmailService {\n\n    /**\n     * Sends DNS TXT record setup instructions to the administrator.\n     *\n     * @param job certificate job\n     * @param records list of maps with keys: name, value\n     * @param expiresAt optional expiration of the ACME authorization\n     */\n    void sendDnsInstructions(CertificateJobEntity job, List<Map<String, String>> records, OffsetDateTime expiresAt);\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/RenewalScheduler.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.service;\n\nimport java.time.OffsetDateTime;\nimport java.util.Set;\n\nimport org.springframework.scheduling.annotation.Scheduled;\nimport org.springframework.stereotype.Component;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport net.javacrumbs.shedlock.spring.annotation.SchedulerLock;\nimport tech.amak.portbuddy.sslservice.domain.CertificateJobStatus;\nimport tech.amak.portbuddy.sslservice.domain.CertificateStatus;\nimport tech.amak.portbuddy.sslservice.repo.CertificateJobRepository;\nimport tech.amak.portbuddy.sslservice.repo.CertificateRepository;\n\n/**\n * Periodically scans managed root domains and creates certificate jobs\n * for wildcard domains when missing or nearing expiry.\n */\n@Component\n@RequiredArgsConstructor\n@Slf4j\npublic class RenewalScheduler {\n\n    private static final Set<CertificateJobStatus> ACTIVE_JOB_STATUSES = Set.of(\n        CertificateJobStatus.PENDING,\n        CertificateJobStatus.RUNNING,\n        CertificateJobStatus.WAITING_DNS_INSTRUCTIONS,\n        CertificateJobStatus.AWAITING_ADMIN_CONFIRMATION,\n        CertificateJobStatus.VERIFYING_DNS,\n        CertificateJobStatus.FINALIZING\n    );\n    private static final int RENEW_DAYS_BEFORE = 30;\n\n    private final CertificateRepository certificateRepository;\n    private final CertificateJobRepository jobRepository;\n    private final AcmeCertificateService acmeCertificateService;\n\n    /**\n     * Runs every 5 minutes with 5 seconds initial delay.\n     */\n    @Scheduled(initialDelay = 5_000, fixedDelay = 300_000)\n    @SchedulerLock(name = \"RenewalScheduler_scheduleRenewals\", lockAtMostFor = \"PT10M\", lockAtLeastFor = \"PT1M\")\n    public void scheduleRenewals() {\n        final var managed = certificateRepository.findAllByManagedTrue();\n        if (managed.isEmpty()) {\n            return;\n        }\n        final var cutoff = OffsetDateTime.now().plusDays(RENEW_DAYS_BEFORE);\n\n        for (final var certMeta : managed) {\n            final var wildcardDomain = certMeta.getDomain();\n\n            // Skip if there is an active job already\n            if (jobRepository.existsByDomainIgnoreCaseAndStatusIn(wildcardDomain, ACTIVE_JOB_STATUSES)) {\n                continue;\n            }\n\n            final var needsRenewal = certificateRepository.findByDomainIgnoreCase(wildcardDomain)\n                .filter(cert -> cert.getStatus() == CertificateStatus.ACTIVE)\n                .filter(cert -> cert.getExpiresAt() != null)\n                .filter(cert -> cert.getExpiresAt().isAfter(cutoff))\n                .isEmpty();\n\n            if (needsRenewal) {\n                try {\n                    log.info(\"Scheduling certificate job for {}\", wildcardDomain);\n                    acmeCertificateService.submitJob(wildcardDomain, \"renewal-scheduler\", true);\n                } catch (final Exception e) {\n                    log.warn(\"Failed to schedule job for {}: {}\", wildcardDomain, e.getMessage());\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/RetryExecutor.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.service;\n\nimport java.util.concurrent.Callable;\n\nimport org.springframework.stereotype.Component;\n\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.sslservice.config.AppProperties;\n\n/**\n * Simple exponential backoff retry executor for transient errors.\n */\n@Component\n@RequiredArgsConstructor\n@Slf4j\npublic class RetryExecutor {\n\n    private final AppProperties properties;\n\n    /**\n     * Executes the given {@code action} with retry/backoff for transient errors.\n     *\n     * @param stepName a human readable step name for logs\n     * @param action   the action to execute\n     * @param <T>      return type\n     * @return result of action\n     * @throws Exception last thrown error if all attempts fail or a non-transient error occurs\n     */\n    public <T> T callWithRetry(final String stepName, final Callable<T> action) throws Exception {\n        final var retry = properties.acme().retry();\n        final int maxAttempts = Math.max(1, retry.maxAttempts());\n        long delay = Math.max(0L, retry.initialDelayMs());\n        final long maxDelay = Math.max(delay, retry.maxDelayMs());\n        final double multiplier = Math.max(1.0, retry.multiplier());\n        final long jitter = Math.max(0L, retry.jitterMs());\n\n        Exception last = null;\n        for (int attempt = 1; attempt <= maxAttempts; attempt++) {\n            try {\n                if (attempt > 1) {\n                    final long sleep = Math.min(maxDelay, delay + jitterRandom(jitter));\n                    log.info(\"Retry step='{}' attempt={} sleepingMs={}\", stepName, attempt, sleep);\n                    Thread.sleep(sleep);\n                    delay = Math.min(maxDelay, (long) (delay * multiplier));\n                }\n                return action.call();\n            } catch (final Exception e) {\n                last = e;\n                final boolean transientErr = TransientErrorClassifier.isTransient(e);\n                log.warn(\"Step '{}' attempt {} failed (transient={})\", stepName, attempt, transientErr, e);\n                if (!transientErr || attempt >= maxAttempts) {\n                    break;\n                }\n            }\n        }\n        if (last != null) {\n            throw last;\n        }\n        throw new IllegalStateException(\"Unknown failure in retry executor for step=\" + stepName);\n    }\n\n    private long jitterRandom(final long jitter) {\n        if (jitter <= 0L) {\n            return 0L;\n        }\n        // simple symmetric jitter in range [0, jitter]\n        return (long) (Math.random() * jitter);\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/TransientErrorClassifier.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.service;\n\nimport java.io.IOException;\nimport java.net.ConnectException;\nimport java.net.SocketException;\nimport java.net.SocketTimeoutException;\nimport java.net.UnknownHostException;\n\nimport org.shredzone.acme4j.exception.AcmeException;\nimport org.shredzone.acme4j.exception.AcmeRetryAfterException;\nimport org.shredzone.acme4j.exception.AcmeServerException;\n\nimport lombok.experimental.UtilityClass;\n\n/**\n * Classifies exceptions into transient vs permanent for retry purposes.\n */\n@UtilityClass\npublic class TransientErrorClassifier {\n\n    /**\n     * Returns true if the exception is likely transient and worth retrying.\n     *\n     * @param e exception\n     * @return true if transient\n     */\n    public static boolean isTransient(final Exception e) {\n        if (e == null) {\n            return false;\n        }\n        // Network related issues: retry\n        if (e instanceof SocketTimeoutException || e instanceof UnknownHostException\n            || e instanceof ConnectException || e instanceof SocketException\n            || e instanceof IOException) {\n            return true;\n        }\n\n        // ACME specific retriable conditions\n        if (e instanceof AcmeRetryAfterException) {\n            return true;\n        }\n        if (e instanceof AcmeServerException) {\n            // Treat ACME server-side errors as transient; specific status access may vary by version.\n            return true;\n        }\n\n        // Unknown AcmeException without details: treat as transient conservatively\n        if (e instanceof AcmeException) {\n            return true;\n        }\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/impl/ServerEmailService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.service.impl;\n\nimport java.time.OffsetDateTime;\nimport java.util.List;\nimport java.util.Map;\n\nimport org.springframework.context.annotation.Primary;\nimport org.springframework.stereotype.Service;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.common.dto.DnsInstructionsEmailRequest;\nimport tech.amak.portbuddy.sslservice.client.ServerClient;\nimport tech.amak.portbuddy.sslservice.domain.CertificateJobEntity;\nimport tech.amak.portbuddy.sslservice.service.EmailService;\n\n/**\n * Implementation of {@link EmailService} that sends email via server API.\n */\n@Service\n@Primary\n@RequiredArgsConstructor\npublic class ServerEmailService implements EmailService {\n\n    private final ServerClient serverClient;\n\n    @Override\n    public void sendDnsInstructions(\n        final CertificateJobEntity job,\n        final List<Map<String, String>> records,\n        final OffsetDateTime expiresAt\n    ) {\n        final var request = DnsInstructionsEmailRequest.builder()\n            .jobId(job.getId())\n            .domain(job.getDomain())\n            .contactEmail(job.getContactEmail())\n            .records(records)\n            .expiresAt(expiresAt)\n            .build();\n\n        serverClient.sendDnsInstructions(request);\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/impl/SimpleDnsResolverService.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.service.impl;\n\nimport java.util.Hashtable;\nimport javax.naming.Context;\nimport javax.naming.directory.Attribute;\nimport javax.naming.directory.Attributes;\nimport javax.naming.directory.DirContext;\nimport javax.naming.directory.InitialDirContext;\n\nimport org.springframework.stereotype.Service;\n\nimport lombok.extern.slf4j.Slf4j;\nimport tech.amak.portbuddy.sslservice.service.DnsResolverService;\n\n/**\n * Simple DNS resolver that checks TXT record visibility using JNDI DNS provider.\n */\n@Service\n@Slf4j\npublic class SimpleDnsResolverService implements DnsResolverService {\n\n    @Override\n    public boolean isTxtRecordVisible(final String fqdn, final String expectedValue) {\n        try {\n            final var env = new Hashtable<String, String>();\n            env.put(Context.INITIAL_CONTEXT_FACTORY, \"com.sun.jndi.dns.DnsContextFactory\");\n            env.put(\"com.sun.jndi.dns.timeout.initial\", \"2000\");\n            env.put(\"com.sun.jndi.dns.timeout.retries\", \"1\");\n            final DirContext ctx = new InitialDirContext(env);\n            final Attributes attrs = ctx.getAttributes(fqdn, new String[] {\"TXT\"});\n            final Attribute txt = attrs.get(\"TXT\");\n            if (txt == null) {\n                return false;\n            }\n            final var values = txt.getAll();\n            while (values.hasMoreElements()) {\n                final var raw = String.valueOf(values.nextElement());\n                final var normalized = raw.replaceAll(\"^\\\"|\\\"$\", \"\");\n                if (normalized.contains(expectedValue)) {\n                    return true;\n                }\n            }\n            return false;\n        } catch (final Exception e) {\n            log.debug(\"TXT lookup failed for {}: {}\", fqdn, e.getMessage());\n            return false;\n        }\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/CertificatesController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.web;\n\nimport java.util.List;\nimport java.util.Optional;\n\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.security.core.Authentication;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport jakarta.validation.Valid;\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.sslservice.domain.CertificateEntity;\nimport tech.amak.portbuddy.sslservice.domain.CertificateJobEntity;\nimport tech.amak.portbuddy.sslservice.repo.CertificateRepository;\nimport tech.amak.portbuddy.sslservice.service.AcmeCertificateService;\nimport tech.amak.portbuddy.sslservice.web.dto.CreateCertificateRequest;\nimport tech.amak.portbuddy.sslservice.web.dto.CreateManagedCertificateRequest;\n\n@RestController\n@RequestMapping(\"/api/certificates\")\n@RequiredArgsConstructor\npublic class CertificatesController {\n\n    private final AcmeCertificateService acmeCertificateService;\n    private final CertificateRepository certificateRepository;\n\n    /**\n     * Creates a new certificate issuance/renewal job for the given domain.\n     *\n     * @param request        the request containing domain\n     * @param authentication current user authentication\n     * @return job summary\n     */\n    @PostMapping\n    public ResponseEntity<CertificateJobEntity> createCertificate(\n        @Valid @RequestBody final CreateCertificateRequest request,\n        final Authentication authentication\n    ) {\n        final var username = authentication == null ? \"system\" : authentication.getName();\n        final var job = acmeCertificateService.submitJob(request.domain(), username, false);\n        return ResponseEntity.accepted().body(job);\n    }\n\n    /**\n     * Retrieves certificate metadata for a given domain.\n     *\n     * @param domain domain name\n     * @return 200 with certificate or 404 if not found\n     */\n    @GetMapping(\"/{domain}\")\n    public ResponseEntity<CertificateEntity> getCertificateByDomain(@PathVariable(\"domain\") final String domain) {\n        final var normalized = domain.toLowerCase();\n        final var entity = certificateRepository.findByDomainIgnoreCase(normalized);\n        return entity.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build());\n    }\n\n    /**\n     * Lists certificates with pagination support.\n     *\n     * @return page of certificates\n     */\n    @GetMapping\n    public Page<CertificateEntity> listCertificates(final Pageable pageable) {\n        return certificateRepository.findAll(pageable);\n    }\n\n    /**\n     * Onboards or updates a managed certificate entry for a domain. Managed entries are\n     * scanned by the renewal scheduler and automatically renewed.\n     *\n     * @param request request payload\n     * @return created/updated certificate metadata\n     */\n    @PostMapping(\"/managed\")\n    public CertificateEntity createManagedCertificate(\n        @Valid @RequestBody final CreateManagedCertificateRequest request\n    ) {\n        final var domain = request.domain().toLowerCase();\n        final var certificate = certificateRepository.findByDomainIgnoreCase(domain)\n            .orElseGet(() -> CertificateEntity.builder()\n                .domain(domain)\n                .build());\n        certificate.setManaged(true);\n        certificate.setVerificationMethod(\n            Optional.ofNullable(request.verificationMethod())\n                .orElse(\"MANUAL_DNS01\"));\n        certificate.setContactEmail(request.contactEmail());\n        return certificateRepository.save(certificate);\n    }\n\n    /**\n     * Lists managed certificate entries.\n     *\n     * @return page of managed certificates\n     */\n    @GetMapping(\"/managed\")\n    public List<CertificateEntity> listManagedCertificates() {\n        return certificateRepository.findAllByManagedTrue();\n    }\n\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/ChallengeController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.web;\n\nimport java.util.concurrent.TimeUnit;\n\nimport org.springframework.http.CacheControl;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.sslservice.work.ChallengeTokenStore;\n\n@RestController\n@RequiredArgsConstructor\npublic class ChallengeController {\n\n    private final ChallengeTokenStore challengeTokenStore;\n\n    /**\n     * Serves HTTP-01 ACME challenge tokens.\n     *\n     * @param token token name\n     * @return token content or empty string if not found\n     */\n    @GetMapping(value = \"/.well-known/acme-challenge/{token}\", produces = MediaType.TEXT_PLAIN_VALUE)\n    public ResponseEntity<String> getChallengeToken(@PathVariable(\"token\") final String token) {\n        final var content = challengeTokenStore.getTokenContent(token);\n        final var body = content == null ? \"\" : content;\n        return ResponseEntity.ok()\n            .cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS).cachePublic())\n            .body(body);\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/InternalController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.web;\n\nimport java.util.UUID;\n\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.sslservice.domain.CertificateEntity;\nimport tech.amak.portbuddy.sslservice.repo.CertificateRepository;\nimport tech.amak.portbuddy.sslservice.service.AcmeCertificateService;\n\n@RestController\n@RequestMapping(\"/internal/api/certificates\")\n@RequiredArgsConstructor\npublic class InternalController {\n\n    private final AcmeCertificateService acmeCertificateService;\n    private final CertificateRepository certificateRepository;\n\n    /**\n     * Retrieves certificate metadata for a given domain.\n     *\n     * @param domain domain name\n     * @return 200 with certificate or 404 if not found\n     */\n    @GetMapping(\"/{domain}\")\n    public ResponseEntity<CertificateEntity> getCertificateByDomain(@PathVariable(\"domain\") final String domain) {\n        final var normalized = domain.toLowerCase();\n        final var entity = certificateRepository.findByDomainIgnoreCase(normalized);\n        return entity.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build());\n    }\n\n    /**\n     * Confirms that DNS TXT records were added for the job and continues issuance.\n     *\n     * @param id job id\n     * @return 202 Accepted on success\n     */\n    @PostMapping(\"/jobs/{id}/confirm-dns\")\n    public ResponseEntity<Void> confirmDns(@PathVariable(\"id\") final UUID id) {\n        acmeCertificateService.confirmDnsAndContinue(id);\n        return ResponseEntity.accepted().build();\n    }\n\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/JobsController.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.web;\n\nimport java.util.UUID;\n\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport lombok.RequiredArgsConstructor;\nimport tech.amak.portbuddy.sslservice.domain.CertificateJobEntity;\nimport tech.amak.portbuddy.sslservice.repo.CertificateJobRepository;\nimport tech.amak.portbuddy.sslservice.service.AcmeCertificateService;\n\n@RestController\n@RequestMapping(\"/api/certificates/jobs\")\n@RequiredArgsConstructor\npublic class JobsController {\n\n    private final CertificateJobRepository jobRepository;\n    private final AcmeCertificateService acmeCertificateService;\n\n    /**\n     * Submits a new certificate job.\n     *\n     * @param domain domain name\n     * @param requestedBy who requested the job\n     * @param managed whether the certificate should be managed (auto-renewed)\n     * @return created job entity\n     */\n    @PostMapping\n    public ResponseEntity<CertificateJobEntity> submitJob(\n        @RequestParam(\"domain\") final String domain,\n        @RequestParam(\"requestedBy\") final String requestedBy,\n        @RequestParam(value = \"managed\", defaultValue = \"false\") final boolean managed) {\n        return ResponseEntity.ok(acmeCertificateService.submitJob(domain, requestedBy, managed));\n    }\n\n    /**\n     * Returns job by id.\n     *\n     * @param id job id\n     * @return job entity or 404\n     */\n    @GetMapping(\"/{id}\")\n    public ResponseEntity<CertificateJobEntity> getJob(@PathVariable(\"id\") final UUID id) {\n        final var job = jobRepository.findById(id);\n        return job.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build());\n    }\n\n    /**\n     * Lists jobs with pagination.\n     *\n     * @return page of jobs\n     */\n    @GetMapping\n    public Page<CertificateJobEntity> listJobs(final Pageable pageable) {\n        return jobRepository.findAll(pageable);\n    }\n\n    /**\n     * Confirms that DNS TXT records were added for the job and continues issuance.\n     *\n     * @param id job id\n     * @return 202 Accepted on success\n     */\n    @PostMapping(\"/{id}/confirm-dns\")\n    public ResponseEntity<Void> confirmDns(@PathVariable(\"id\") final UUID id) {\n        acmeCertificateService.confirmDnsAndContinue(id);\n        return ResponseEntity.accepted().build();\n    }\n\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/dto/CreateCertificateRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.web.dto;\n\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.Pattern;\n\n/**\n * Request payload to create or renew an SSL certificate for a domain.\n */\npublic record CreateCertificateRequest(\n    @NotBlank\n    @Pattern(\n        // Allows domains like example.com or *.example.com\n        regexp = \"^(?:\\\\*\\\\.)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\\\.)+[A-Za-z]{2,}$\",\n        message = \"Invalid domain name\"\n    )\n    String domain\n) {\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/dto/CreateManagedCertificateRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.web.dto;\n\nimport jakarta.validation.constraints.Email;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.Pattern;\n\n/**\n * Request to create or update a managed certificate entry.\n */\npublic record CreateManagedCertificateRequest(\n    @NotBlank\n    @Pattern(\n        // Allows domains like example.com or *.example.com\n        regexp = \"^(?:\\\\*\\\\.)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\\\.)+[A-Za-z]{2,}$\",\n        message = \"Invalid domain name\"\n    )\n    String domain,\n\n    @Email\n    String contactEmail,\n\n    // For now supports values like MANUAL_DNS01 or HTTP01. Optional.\n    String verificationMethod\n) {\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/dto/CreateRootDomainRequest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.web.dto;\n\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.Pattern;\n\n/**\n * Request to onboard a managed root domain for wildcard certificate automation.\n */\npublic record CreateRootDomainRequest(\n    @NotBlank\n    @Pattern(\n        // Root domain (no wildcard)\n        regexp = \"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\\\.)+[A-Za-z]{2,}$\",\n        message = \"Invalid root domain\"\n    )\n    String rootDomain,\n\n    Boolean wildcardManaged\n) {\n}\n"
  },
  {
    "path": "ssl-service/src/main/java/tech/amak/portbuddy/sslservice/work/ChallengeTokenStore.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.work;\n\nimport java.util.concurrent.ConcurrentHashMap;\n\nimport org.springframework.stereotype.Component;\n\n/**\n * In-memory storage for ACME HTTP-01 challenge tokens.\n */\n@Component\npublic class ChallengeTokenStore {\n\n    private final ConcurrentHashMap<String, String> tokens = new ConcurrentHashMap<>();\n\n    /**\n     * Adds or updates a challenge token value.\n     *\n     * @param token token name\n     * @param content token content\n     */\n    public void putToken(final String token, final String content) {\n        tokens.put(token, content);\n    }\n\n    /**\n     * Retrieves challenge token content by token name.\n     *\n     * @param token token name\n     * @return token content or null\n     */\n    public String getTokenContent(final String token) {\n        return tokens.get(token);\n    }\n\n    /**\n     * Removes a token from the store.\n     *\n     * @param token token name\n     */\n    public void removeToken(final String token) {\n        tokens.remove(token);\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/main/resources/application.yml",
    "content": "server:\n  port: ${SSL_SERVICE_PORT:8050}\n\nspring:\n  application:\n    name: ssl-service\n  datasource:\n    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:portbuddy}\n    username: ${DB_USER:portbuddy}\n    password: ${DB_PASSWORD:portbuddy}\n  jpa:\n    hibernate:\n      ddl-auto: validate\n    properties:\n      hibernate:\n        jdbc:\n          time_zone: UTC\n        default_schema: certificates\n\n  flyway:\n    enabled: true\n    locations: classpath:db/migration\n    default-schema: certificates\n\neureka:\n  client:\n    registryFetchIntervalSeconds: 2\n    eurekaServiceUrlPollIntervalSeconds: 60\n    service-url:\n      defaultZone: ${EUREKA_ZONE:http://portbuddy:portbuddy@localhost:8761/eureka}\n\nmanagement:\n  endpoints:\n    web:\n      exposure:\n        include: health,info\n\napp:\n  jwt:\n    issuer: port-buddy\n    jwk-set-uri: lb://port-buddy-server/.well-known/jwks.json\n  acme:\n    serverUrl: https://acme-v02.api.letsencrypt.org/directory\n    accountKeyPath: ${ACME_ACCOUNT_KEY_PATH:file:config/acme/account.key}\n    contactEmail: admin@portbuddy.dev\n    retry:\n      maxAttempts: 6\n      initialDelayMs: 1000\n      maxDelayMs: 10000\n      multiplier: 2.0\n      jitterMs: 500\n  storage:\n    certificatesDir: file:certs\n\nlogging:\n  level:\n    root: info\n    reactor.netty.http.client: warn\n    io.netty.handler.codec.http.websocketx: warn\n    org.springframework.cloud.gateway: warn\n    org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator: warn\n    com.netflix.discovery: warn\n  file:\n    name: log/app.log\n  logback:\n    rollingpolicy:\n      max-history: 14\n      total-size-cap: 1000MB\n      max-file-size: 100MB\n"
  },
  {
    "path": "ssl-service/src/main/resources/db/migration/V1__init.sql",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\n-- Create tables for SSL certificates and jobs\n\nCREATE TABLE IF NOT EXISTS ssl_certificates (\n    id UUID PRIMARY KEY,\n    domain VARCHAR(255) NOT NULL UNIQUE,\n    status VARCHAR(32) NOT NULL,\n    managed BOOLEAN NOT NULL DEFAULT FALSE,\n    verification_method VARCHAR(64) NULL,\n    contact_email VARCHAR(255) NULL,\n    issued_at TIMESTAMPTZ NULL,\n    expires_at TIMESTAMPTZ NULL,\n    certificate_path VARCHAR(1024) NULL,\n    private_key_path VARCHAR(1024) NULL,\n    chain_path VARCHAR(1024) NULL,\n    created_by VARCHAR(100) NULL,\n    created_at TIMESTAMPTZ NULL,\n    updated_by VARCHAR(100) NULL,\n    updated_at TIMESTAMPTZ NULL\n);\n\nCREATE TABLE IF NOT EXISTS ssl_certificate_jobs (\n    id UUID PRIMARY KEY,\n    domain VARCHAR(255) NOT NULL,\n    status VARCHAR(32) NOT NULL,\n    message VARCHAR(2000) NULL,\n    contact_email VARCHAR(255) NULL,\n    challenge_records_json TEXT NULL,\n    order_location VARCHAR(1024) NULL,\n    authorization_urls_json TEXT NULL,\n    challenge_expires_at TIMESTAMPTZ NULL,\n    managed BOOLEAN NOT NULL DEFAULT FALSE,\n    started_at TIMESTAMPTZ NULL,\n    finished_at TIMESTAMPTZ NULL,\n    created_by VARCHAR(100) NULL,\n    created_at TIMESTAMPTZ NULL,\n    updated_by VARCHAR(100) NULL,\n    updated_at TIMESTAMPTZ NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_ssl_certificate_jobs_domain ON ssl_certificate_jobs(domain);\n"
  },
  {
    "path": "ssl-service/src/main/resources/db/migration/V2__shedlock_table.sql",
    "content": "/*\n * Copyright (c) 2026 AMAK Inc. All rights reserved.\n */\n\n-- ShedLock table for cluster-safe scheduling\nCREATE TABLE IF NOT EXISTS shedlock\n(\n    name       VARCHAR(64)  NOT NULL,\n    lock_until TIMESTAMP    NOT NULL,\n    locked_at  TIMESTAMP    NOT NULL,\n    locked_by  VARCHAR(255) NOT NULL,\n    PRIMARY KEY (name)\n);\n"
  },
  {
    "path": "ssl-service/src/main/resources/db/migration/V3__add_full_chain_path.sql",
    "content": "/*\n * Copyright (c) 2026 AMAK Inc. All rights reserved.\n */\n\nALTER TABLE ssl_certificates ADD COLUMN full_chain_path VARCHAR(1024);\n"
  },
  {
    "path": "ssl-service/src/test/java/tech/amak/portbuddy/sslservice/service/CertificateRenewalServiceTest.java",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage tech.amak.portbuddy.sslservice.service;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.time.OffsetDateTime;\nimport java.util.List;\nimport java.util.Optional;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport tech.amak.portbuddy.sslservice.domain.CertificateEntity;\nimport tech.amak.portbuddy.sslservice.domain.CertificateStatus;\nimport tech.amak.portbuddy.sslservice.repo.CertificateJobRepository;\nimport tech.amak.portbuddy.sslservice.repo.CertificateRepository;\n\n@ExtendWith(MockitoExtension.class)\nclass CertificateRenewalServiceTest {\n\n    @Mock\n    private CertificateRepository certificateRepository;\n\n    @Mock\n    private CertificateJobRepository jobRepository;\n\n    @Mock\n    private AcmeCertificateService acmeCertificateService;\n\n    @InjectMocks\n    private RenewalScheduler renewalService;\n\n    @Test\n    void checkAndRenewCertificates_ShouldTriggerRenewalForExpiringCerts() {\n        // Given\n        final var cert1 = new CertificateEntity();\n        cert1.setDomain(\"expiring.com\");\n        cert1.setManaged(true);\n        cert1.setExpiresAt(OffsetDateTime.now().plusDays(10));\n        cert1.setStatus(CertificateStatus.ACTIVE);\n\n        when(certificateRepository.findAllByManagedTrue()).thenReturn(List.of(cert1));\n        when(certificateRepository.findByDomainIgnoreCase(\"expiring.com\")).thenReturn(Optional.of(cert1));\n\n        // When\n        renewalService.scheduleRenewals();\n\n        // Then\n        verify(acmeCertificateService, times(1)).submitJob(eq(\"expiring.com\"), eq(\"renewal-scheduler\"), eq(true));\n    }\n\n    @Test\n    void checkAndRenewCertificates_NoExpiringCerts_ShouldDoNothing() {\n        // Given\n        final var cert1 = new CertificateEntity();\n        cert1.setDomain(\"not-expiring.com\");\n        cert1.setManaged(true);\n        cert1.setExpiresAt(OffsetDateTime.now().plusDays(40));\n        cert1.setStatus(CertificateStatus.ACTIVE);\n\n        when(certificateRepository.findAllByManagedTrue()).thenReturn(List.of(cert1));\n        when(certificateRepository.findByDomainIgnoreCase(\"not-expiring.com\")).thenReturn(Optional.of(cert1));\n\n        // When\n        renewalService.scheduleRenewals();\n\n        // Then\n        verify(acmeCertificateService, never()).submitJob(any(), any(), any(Boolean.class));\n    }\n}\n"
  },
  {
    "path": "ssl-service/src/test/resources/application-test.yml",
    "content": "spring:\n  datasource:\n    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:portbuddy}\n    username: ${DB_USER:portbuddy}\n    password: ${DB_PASSWORD:portbuddy}\n  jpa:\n    hibernate:\n      ddl-auto: validate\n    properties:\n      hibernate:\n        default_schema: certificates\n  flyway:\n    enabled: true\n    default-schema: certificates\n    create-schemas: true\n\neureka:\n  client:\n    enabled: false\n"
  },
  {
    "path": "web/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <title>Port Buddy</title>\n\n    <meta name=\"description\" content=\"Securely expose your local web server, database, or TCP/UDP service to the internet. The best free ngrok alternative for developers. Supports HTTP, TCP & UDP tunneling.\" />\n    <meta name=\"keywords\" content=\"ngrok alternative, localhost tunneling, expose port, port forwarding, reverse proxy, tcp proxy, udp proxy, local development, port buddy\" />\n\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta name=\"theme-color\" content=\"#0b1020\" />\n    <meta name=\"robots\" content=\"index,follow\"/>\n\n    <link rel=\"sitemap\" href=\"%VITE_CANONICAL%/sitemap.xml\" />\n\n    <link rel=\"icon\" href=\"/favicon.ico\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\" />\n    <link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\" />\n    <link rel=\"manifest\" href=\"/site.webmanifest\" />\n    <link rel=\"canonical\" href=\"%VITE_CANONICAL%\" />\n\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n    <link href=\"https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap\" rel=\"stylesheet\">\n\n    <!-- Open Graph tags -->\n    <meta property=\"og:site_name\" content=\"Port Buddy\" />\n    <meta property=\"og:type\" content=\"website\" />\n    <meta property=\"og:title\" content=\"Expose Localhost to the Internet | Ngrok Alternative\" />\n    <meta property=\"og:description\" content=\"Securely expose your local web server, database, or TCP/UDP service to the internet. The best free Ngrok alternative. Supports HTTP, TCP & UDP tunneling\" />\n    <meta property=\"og:locale\" content=\"en_US\" />\n    <meta property=\"og:url\" content=\"%VITE_CANONICAL%\" />\n    <meta property=\"og:image\" content=\"%VITE_CANONICAL%/og-image.png\" />\n\n    <!-- Twitter tags -->\n    <meta name=\"twitter:card\" content=\"summary_large_image\" />\n    <meta name=\"twitter:site\" content=\"@anton_liashenka\" />\n    <meta name=\"twitter:title\" content=\"Expose Localhost to the Internet | Ngrok Alternative\" />\n    <meta name=\"twitter:description\" content=\"Securely expose your local web server, database, or TCP/UDP service to the internet. The best free Ngrok alternative. Supports HTTP, TCP & UDP tunneling\" />\n    <meta name=\"twitter:creator\" content=\"@anton_liashenka\" />\n    <meta name=\"twitter:image\" content=\"%VITE_CANONICAL%/og-image.png\" />\n\n    <!-- Google tag (gtag.js) -->\n    <script async src=\"https://www.googletagmanager.com/gtag/js?id=G-DX4T53QL62\"></script>\n    <script>\n      window.dataLayer = window.dataLayer || [];\n      function gtag(){dataLayer.push(arguments);}\n      gtag('js', new Date());\n      gtag('config', 'G-DX4T53QL62');\n    </script>\n\n    <script type=\"application/ld+json\">\n      {\n        \"@context\": \"https://schema.org\",\n        \"@type\": \"SoftwareApplication\",\n        \"name\": \"Port Buddy\",\n        \"operatingSystem\": \"Windows, macOS, Linux\",\n        \"applicationCategory\": \"DeveloperApplication\",\n        \"description\": \"Securely expose your local web server, database, or TCP/UDP service to the internet.\",\n        \"offers\": {\n          \"@type\": \"Offer\",\n          \"price\": \"0\",\n          \"priceCurrency\": \"USD\"\n        }\n      }\n    </script>\n\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "web/package.json",
    "content": "{\n  \"name\": \"port-buddy-web\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite --host\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"@heroicons/react\": \"^2.1.5\",\n    \"@types/react\": \"18.3.12\",\n    \"@types/react-dom\": \"18.3.1\",\n    \"jwt-decode\": \"^4.0.0\",\n    \"react\": \"18.3.1\",\n    \"react-dom\": \"18.3.1\",\n    \"react-helmet-async\": \"^2.0.5\",\n    \"react-router-dom\": \"6.28.0\"\n  },\n  \"devDependencies\": {\n    \"@prerenderer/renderer-puppeteer\": \"^1.2.4\",\n    \"@prerenderer/rollup-plugin\": \"^0.3.12\",\n    \"@types/react-helmet-async\": \"^1.0.1\",\n    \"@vitejs/plugin-react\": \"4.3.3\",\n    \"autoprefixer\": \"10.4.20\",\n    \"postcss\": \"8.4.49\",\n    \"puppeteer\": \"^24.35.0\",\n    \"tailwindcss\": \"3.4.14\",\n    \"typescript\": \"5.6.3\",\n    \"vite\": \"5.4.10\"\n  }\n}\n"
  },
  {
    "path": "web/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n\n    <parent>\n        <groupId>tech.amak</groupId>\n        <artifactId>port-buddy</artifactId>\n        <version>1.0-SNAPSHOT</version>\n    </parent>\n\n    <artifactId>web</artifactId>\n    <name>port-buddy-web</name>\n    <packaging>jar</packaging>\n    <description>React+TypeScript SPA built with Vite and Tailwind (packaged into META-INF/resources)</description>\n\n    <build>\n        <plugins>\n            <!-- Provision Node/NPM and build the SPA during Maven build to avoid relying on a system-wide Node installation -->\n            <plugin>\n                <groupId>com.github.eirslett</groupId>\n                <artifactId>frontend-maven-plugin</artifactId>\n                <version>1.15.0</version>\n                <configuration>\n                    <workingDirectory>${project.basedir}</workingDirectory>\n                    <installDirectory>${project.build.directory}</installDirectory>\n                </configuration>\n                <executions>\n                    <execution>\n                        <id>install-node-and-npm</id>\n                        <phase>generate-resources</phase>\n                        <goals>\n                            <goal>install-node-and-npm</goal>\n                        </goals>\n                        <configuration>\n                            <nodeVersion>${node.version}</nodeVersion>\n                            <npmVersion>${npm.version}</npmVersion>\n                        </configuration>\n                    </execution>\n                    <execution>\n                        <id>npm-ci</id>\n                        <phase>generate-resources</phase>\n                        <goals>\n                            <goal>npm</goal>\n                        </goals>\n                        <configuration>\n                            <arguments>ci</arguments>\n                        </configuration>\n                    </execution>\n                    <execution>\n                        <id>npm-build</id>\n                        <phase>generate-resources</phase>\n                        <goals>\n                            <goal>npm</goal>\n                        </goals>\n                        <configuration>\n                            <arguments>run build</arguments>\n                        </configuration>\n                    </execution>\n                </executions>\n            </plugin>\n        </plugins>\n        <resources>\n            <!-- Also include the module's standard resources if present -->\n            <resource>\n                <directory>src/main/resources</directory>\n                <filtering>false</filtering>\n            </resource>\n            <!-- Copy the built SPA into the JAR under META-INF/resources so Spring Boot can serve it -->\n            <resource>\n                <directory>${project.basedir}/dist</directory>\n                <filtering>false</filtering>\n                <targetPath>META-INF/resources</targetPath>\n                <includes>\n                    <include>**/*</include>\n                </includes>\n            </resource>\n        </resources>\n    </build>\n</project>\n"
  },
  {
    "path": "web/postcss.config.js",
    "content": "export default {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "web/public/install.ps1",
    "content": "$owner = \"amak-tech\"\n$repo = \"port-buddy\"\n\nWrite-Host \"Detecting latest version...\" -ForegroundColor Cyan\n$releaseUrl = \"https://api.github.com/repos/$owner/$repo/releases/latest\"\n$release = Invoke-RestMethod -Uri $releaseUrl\n$version = $release.tag_name\n\nif (-not $version) {\n    Write-Error \"Could not determine the latest version.\"\n    exit 1\n}\n\n$url = \"https://github.com/$owner/$repo/releases/download/$version/portbuddy-windows-x64.exe\"\n$installDir = Join-Path $HOME \".portbuddy\"\n$exePath = Join-Path $installDir \"portbuddy.exe\"\n\nif (-not (Test-Path $installDir)) {\n    New-Item -ItemType Directory -Path $installDir | Out-Null\n}\n\nWrite-Host \"Downloading Port Buddy $version...\" -ForegroundColor Cyan\nInvoke-WebRequest -Uri $url -OutFile $exePath\n\n$path = [Environment]::GetEnvironmentVariable(\"Path\", \"User\")\nif ($path -notlike \"*$installDir*\") {\n    Write-Host \"Adding $installDir to PATH...\" -ForegroundColor Cyan\n    [Environment]::SetEnvironmentVariable(\"Path\", \"$path;$installDir\", \"User\")\n    $env:Path += \";$installDir\"\n}\n\nWrite-Host \"Port Buddy installed successfully!\" -ForegroundColor Green\nWrite-Host \"Please restart your terminal to start using 'portbuddy'.\" -ForegroundColor Yellow\n"
  },
  {
    "path": "web/public/install.sh",
    "content": "#!/bin/bash\n\n#\n# Copyright (c) 2026 AMAK Inc. All rights reserved.\n#\n\nset -e\n\n# Port Buddy Installation Script\n# This script detects the platform and architecture, then downloads and installs\n# the latest version of Port Buddy from GitHub releases.\n\nOWNER=\"amak-tech\"\nREPO=\"port-buddy\"\nBINARY_NAME=\"portbuddy\"\nINSTALL_DIR=\"/usr/local/bin\"\n\n# Detect OS\nOS=\"$(uname -s | tr '[:upper:]' '[:lower:]')\"\ncase \"${OS}\" in\n  linux*)   PLATFORM=\"linux\" ;;\n  darwin*)  PLATFORM=\"macos\" ;;\n  *)        echo \"Unsupported OS: ${OS}\"; exit 1 ;;\nesac\n\n# Detect Architecture\nARCH_RAW=\"$(uname -m)\"\ncase \"${ARCH_RAW}\" in\n  x86_64)   ARCH=\"x64\" ;;\n  aarch64|arm64) ARCH=\"arm64\" ;;\n  *)        echo \"Unsupported architecture: ${ARCH_RAW}\"; exit 1 ;;\nesac\n\nASSET_NAME=\"${BINARY_NAME}-${PLATFORM}-${ARCH}\"\n\necho \"Detecting latest version...\"\nLATEST_RELEASE_URL=\"https://api.github.com/repos/${OWNER}/${REPO}/releases/latest\"\nVERSION=$(curl -s \"${LATEST_RELEASE_URL}\" | grep '\"tag_name\":' | sed -E 's/.*\"([^\"]+)\".*/\\1/')\n\nif [ -z \"${VERSION}\" ]; then\n  echo \"Error: Could not determine the latest version.\"\n  exit 1\nfi\n\necho \"Latest version: ${VERSION}\"\nDOWNLOAD_URL=\"https://github.com/${OWNER}/${REPO}/releases/download/${VERSION}/${ASSET_NAME}\"\n\necho \"Downloading ${ASSET_NAME}...\"\ncurl -L \"${DOWNLOAD_URL}\" -o \"${BINARY_NAME}\"\n\necho \"Installing to ${INSTALL_DIR}...\"\nchmod +x \"${BINARY_NAME}\"\nsudo mv \"${BINARY_NAME}\" \"${INSTALL_DIR}/${BINARY_NAME}\"\n\necho \"Successfully installed Port Buddy ${VERSION} to ${INSTALL_DIR}/${BINARY_NAME}\"\necho \"Run 'portbuddy --help' to get started.\"\n"
  },
  {
    "path": "web/public/pages/contacts.html",
    "content": "<!--\n  ~ Licensed under the Apache License, Version 2.0 (the \"License\");\n  ~ you may not use this file except in compliance with the License.\n  ~ You may obtain a copy of the License at\n  ~\n  ~     http://www.apache.org/licenses/LICENSE-2.0\n  ~\n  ~ Unless required by applicable law or agreed to in writing, software\n  ~ distributed under the License is distributed on an \"AS IS\" BASIS,\n  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  ~ See the License for the specific language governing permissions and\n  ~ limitations under the License.\n  ~\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\" />\n    <title>Contact Us - Port Buddy</title>\n    <meta name=\"description\" content=\"Get in touch with Port Buddy support for help or abuse reports.\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta property=\"og:title\" content=\"Contact Us - Port Buddy\" />\n    <meta property=\"og:description\" content=\"Get in touch with Port Buddy support for help or abuse reports.\" />\n    <meta property=\"og:url\" content=\"%VITE_CANONICAL%/contacts\" />\n    <link rel=\"canonical\" href=\"%VITE_CANONICAL%/contacts\" />\n</head>\n<body>\n    <div id=\"root\"></div>\n</body>\n</html>\n"
  },
  {
    "path": "web/public/pages/docs/guides/hytale-server.html",
    "content": "<!--\n  ~ Licensed under the Apache License, Version 2.0 (the \"License\");\n  ~ you may not use this file except in compliance with the License.\n  ~ You may obtain a copy of the License at\n  ~\n  ~     http://www.apache.org/licenses/LICENSE-2.0\n  ~\n  ~ Unless required by applicable law or agreed to in writing, software\n  ~ distributed under the License is distributed on an \"AS IS\" BASIS,\n  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  ~ See the License for the specific language governing permissions and\n  ~ limitations under the License.\n  ~\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\" />\n    <title>How to Expose a Hytale Server to the Internet | Port Buddy</title>\n    <meta name=\"description\" content=\"Step-by-step guide to hosting a Hytale server on your local machine and playing with friends over the internet using Port Buddy.\" />\n    <meta name=\"keywords\" content=\"hytale server port forwarding, expose hytale server, play hytale with friends, port buddy hytale, hytale server hosting, localhost hytale server\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta property=\"og:title\" content=\"How to Expose a Hytale Server to the Internet | Port Buddy\" />\n    <meta property=\"og:description\" content=\"Step-by-step guide to hosting a Hytale server on your local machine and playing with friends over the internet using Port Buddy.\" />\n    <meta property=\"og:url\" content=\"%VITE_CANONICAL%/docs/guides/hytale-server\" />\n    <link rel=\"canonical\" href=\"%VITE_CANONICAL%/docs/guides/hytale-server\" />\n    <meta property=\"og:type\" content=\"article\" />\n    <script type=\"application/ld+json\">\n      {\n        \"@context\": \"https://schema.org\",\n        \"@type\": \"HowTo\",\n        \"name\": \"How to Expose a Hytale Server to the Internet\",\n        \"description\": \"Learn how to easily share your local Hytale server with friends without complex router configuration using Port Buddy.\",\n        \"step\": [\n          {\n            \"@type\": \"HowToStep\",\n            \"name\": \"Install Port Buddy\",\n            \"text\": \"Download and install the Port Buddy CLI for your operating system.\"\n          },\n          {\n            \"@type\": \"HowToStep\",\n            \"name\": \"Start Hytale Server\",\n            \"text\": \"Launch your Hytale server on your local machine.\"\n          },\n          {\n            \"@type\": \"HowToStep\",\n            \"name\": \"Expose the Port\",\n            \"text\": \"Run 'port-buddy udp 5520'.\"\n          },\n          {\n            \"@type\": \"HowToStep\",\n            \"name\": \"Share the Address\",\n            \"text\": \"Copy the generated public address and share it with your friends.\"\n          }\n        ]\n      }\n    </script>\n</head>\n<body>\n    <div id=\"root\"></div>\n</body>\n</html>\n"
  },
  {
    "path": "web/public/pages/docs/guides/minecraft-server.html",
    "content": "<!--\n  ~ Licensed under the Apache License, Version 2.0 (the \"License\");\n  ~ you may not use this file except in compliance with the License.\n  ~ You may obtain a copy of the License at\n  ~\n  ~     http://www.apache.org/licenses/LICENSE-2.0\n  ~\n  ~ Unless required by applicable law or agreed to in writing, software\n  ~ distributed under the License is distributed on an \"AS IS\" BASIS,\n  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  ~ See the License for the specific language governing permissions and\n  ~ limitations under the License.\n  ~\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\" />\n    <title>How to Expose a Minecraft Server to the Internet | Port Buddy</title>\n    <meta name=\"description\" content=\"Step-by-step guide to hosting a Minecraft server on your local machine and playing with friends over the internet using Port Buddy. Supports Java and Bedrock editions.\" />\n    <meta name=\"keywords\" content=\"minecraft server port forwarding, expose minecraft server, play minecraft with friends, port buddy minecraft, minecraft java server, minecraft bedrock server, localhost minecraft server\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta property=\"og:title\" content=\"How to Expose a Minecraft Server to the Internet | Port Buddy\" />\n    <meta property=\"og:description\" content=\"Step-by-step guide to hosting a Minecraft server on your local machine and playing with friends over the internet using Port Buddy. Supports Java and Bedrock editions.\" />\n    <meta property=\"og:url\" content=\"%VITE_CANONICAL%/docs/guides/minecraft-server\" />\n    <link rel=\"canonical\" href=\"%VITE_CANONICAL%/docs/guides/minecraft-server\" />\n    <meta property=\"og:type\" content=\"article\" />\n    <script type=\"application/ld+json\">\n      {\n        \"@context\": \"https://schema.org\",\n        \"@type\": \"HowTo\",\n        \"name\": \"How to Expose a Minecraft Server to the Internet\",\n        \"description\": \"Learn how to easily share your local Minecraft server with friends without complex router configuration using Port Buddy.\",\n        \"step\": [\n          {\n            \"@type\": \"HowToStep\",\n            \"name\": \"Install Port Buddy\",\n            \"text\": \"Download and install the Port Buddy CLI for your operating system.\"\n          },\n          {\n            \"@type\": \"HowToStep\",\n            \"name\": \"Start Minecraft Server\",\n            \"text\": \"Launch your Minecraft Java or Bedrock server on your local machine.\"\n          },\n          {\n            \"@type\": \"HowToStep\",\n            \"name\": \"Expose the Port\",\n            \"text\": \"Run 'port-buddy tcp 25565' (for Java) or the appropriate port for your server.\"\n          },\n          {\n            \"@type\": \"HowToStep\",\n            \"name\": \"Share the Address\",\n            \"text\": \"Copy the generated public address and share it with your friends.\"\n          }\n        ]\n      }\n    </script>\n</head>\n<body>\n    <div id=\"root\"></div>\n</body>\n</html>\n"
  },
  {
    "path": "web/public/pages/docs.html",
    "content": "<!--\n  ~ Licensed under the Apache License, Version 2.0 (the \"License\");\n  ~ you may not use this file except in compliance with the License.\n  ~ You may obtain a copy of the License at\n  ~\n  ~     http://www.apache.org/licenses/LICENSE-2.0\n  ~\n  ~ Unless required by applicable law or agreed to in writing, software\n  ~ distributed under the License is distributed on an \"AS IS\" BASIS,\n  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  ~ See the License for the specific language governing permissions and\n  ~ limitations under the License.\n  ~\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\" />\n    <title>Documentation - Port Buddy</title>\n    <meta name=\"description\" content=\"Learn how to use Port Buddy to expose your local services to the internet. Documentation for HTTP, TCP, and UDP tunnels.\" />\n    <meta name=\"keywords\" content=\"port buddy docs, port buddy documentation, how to use port buddy, port buddy tutorial\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta property=\"og:title\" content=\"Documentation - Port Buddy\" />\n    <meta property=\"og:description\" content=\"Learn how to use Port Buddy to expose your local services to the internet. Documentation for HTTP, TCP, and UDP tunnels.\" />\n    <meta property=\"og:url\" content=\"%VITE_CANONICAL%/docs\" />\n    <link rel=\"canonical\" href=\"%VITE_CANONICAL%/docs\" />\n</head>\n<body>\n    <div id=\"root\"></div>\n</body>\n</html>\n"
  },
  {
    "path": "web/public/pages/index.html",
    "content": "<!--\n  ~ Licensed under the Apache License, Version 2.0 (the \"License\");\n  ~ you may not use this file except in compliance with the License.\n  ~ You may obtain a copy of the License at\n  ~\n  ~     http://www.apache.org/licenses/LICENSE-2.0\n  ~\n  ~ Unless required by applicable law or agreed to in writing, software\n  ~ distributed under the License is distributed on an \"AS IS\" BASIS,\n  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  ~ See the License for the specific language governing permissions and\n  ~ limitations under the License.\n  ~\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\" />\n    <title>Port Buddy - Expose Localhost to the Internet | Ngrok Alternative</title>\n    <meta name=\"description\" content=\"Securely expose your local web server, database, or TCP/UDP service to the internet. The best free ngrok alternative for developers. Supports HTTP, TCP & UDP tunneling.\" />\n    <meta name=\"keywords\" content=\"ngrok alternative, localhost tunneling, expose port, port forwarding, reverse proxy, tcp proxy, udp proxy, local development, port buddy\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta property=\"og:title\" content=\"Port Buddy - Expose Localhost to the Internet | Ngrok Alternative\" />\n    <meta property=\"og:description\" content=\"Securely expose your local web server, database, or TCP/UDP service to the internet. The best free ngrok alternative for developers. Supports HTTP, TCP & UDP tunneling.\" />\n    <meta property=\"og:url\" content=\"%VITE_CANONICAL%/\" />\n    <link rel=\"canonical\" href=\"%VITE_CANONICAL%/\" />\n    <script type=\"application/ld+json\">\n      {\n        \"@context\": \"https://schema.org\",\n        \"@type\": \"SoftwareApplication\",\n        \"name\": \"Port Buddy\",\n        \"operatingSystem\": \"Windows, macOS, Linux\",\n        \"applicationCategory\": \"DeveloperApplication\",\n        \"description\": \"Securely expose your local web server, database, or TCP/UDP service to the internet.\",\n        \"offers\": {\n          \"@type\": \"Offer\",\n          \"price\": \"0\",\n          \"priceCurrency\": \"USD\"\n        }\n      }\n    </script>\n</head>\n<body>\n    <div id=\"root\"></div>\n</body>\n</html>\n"
  },
  {
    "path": "web/public/pages/install.html",
    "content": "<!--\n  ~ Licensed under the Apache License, Version 2.0 (the \"License\");\n  ~ you may not use this file except in compliance with the License.\n  ~ You may obtain a copy of the License at\n  ~\n  ~     http://www.apache.org/licenses/LICENSE-2.0\n  ~\n  ~ Unless required by applicable law or agreed to in writing, software\n  ~ distributed under the License is distributed on an \"AS IS\" BASIS,\n  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  ~ See the License for the specific language governing permissions and\n  ~ limitations under the License.\n  ~\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\" />\n    <title>Install Port Buddy - Windows, macOS, Linux, Docker | Port Buddy CLI</title>\n    <meta name=\"description\" content=\"Install Port Buddy CLI on your machine. Supports Homebrew (macOS), Scoop (Windows), Shell script (Linux), and Docker. Get started with secure localhost tunneling.\" />\n    <meta name=\"keywords\" content=\"install port buddy, download port buddy, port buddy cli, homebrew port buddy, scoop port buddy, linux port forwarding, docker port buddy\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta property=\"og:title\" content=\"Install Port Buddy - Windows, macOS, Linux, Docker | Port Buddy CLI\" />\n    <meta property=\"og:description\" content=\"Install Port Buddy CLI on your machine. Supports Homebrew (macOS), Scoop (Windows), Shell script (Linux), and Docker. Get started with secure localhost tunneling.\" />\n    <meta property=\"og:url\" content=\"%VITE_CANONICAL%/install\" />\n    <link rel=\"canonical\" href=\"%VITE_CANONICAL%/install\" />\n</head>\n<body>\n    <div id=\"root\"></div>\n</body>\n</html>\n"
  },
  {
    "path": "web/public/pages/privacy.html",
    "content": "<!--\n  ~ Licensed under the Apache License, Version 2.0 (the \"License\");\n  ~ you may not use this file except in compliance with the License.\n  ~ You may obtain a copy of the License at\n  ~\n  ~     http://www.apache.org/licenses/LICENSE-2.0\n  ~\n  ~ Unless required by applicable law or agreed to in writing, software\n  ~ distributed under the License is distributed on an \"AS IS\" BASIS,\n  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  ~ See the License for the specific language governing permissions and\n  ~ limitations under the License.\n  ~\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\" />\n    <title>Privacy Policy - Port Buddy</title>\n    <meta name=\"description\" content=\"Learn about how Port Buddy collects, uses, and protects your personal data.\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta property=\"og:title\" content=\"Privacy Policy - Port Buddy\" />\n    <meta property=\"og:description\" content=\"Learn about how Port Buddy collects, uses, and protects your personal data.\" />\n    <meta property=\"og:url\" content=\"%VITE_CANONICAL%/privacy\" />\n    <link rel=\"canonical\" href=\"%VITE_CANONICAL%/privacy\" />\n</head>\n<body>\n    <div id=\"root\"></div>\n</body>\n</html>\n"
  },
  {
    "path": "web/public/pages/terms.html",
    "content": "<!--\n  ~ Licensed under the Apache License, Version 2.0 (the \"License\");\n  ~ you may not use this file except in compliance with the License.\n  ~ You may obtain a copy of the License at\n  ~\n  ~     http://www.apache.org/licenses/LICENSE-2.0\n  ~\n  ~ Unless required by applicable law or agreed to in writing, software\n  ~ distributed under the License is distributed on an \"AS IS\" BASIS,\n  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  ~ See the License for the specific language governing permissions and\n  ~ limitations under the License.\n  ~\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\" />\n    <title>Terms and Conditions - Port Buddy</title>\n    <meta name=\"description\" content=\"Read the terms and conditions for using Port Buddy services.\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta property=\"og:title\" content=\"Terms and Conditions - Port Buddy\" />\n    <meta property=\"og:description\" content=\"Read the terms and conditions for using Port Buddy services.\" />\n    <meta property=\"og:url\" content=\"%VITE_CANONICAL%/terms\" />\n    <link rel=\"canonical\" href=\"%VITE_CANONICAL%/terms\" />\n</head>\n<body>\n    <div id=\"root\"></div>\n</body>\n</html>\n"
  },
  {
    "path": "web/public/robots.txt",
    "content": "User-agent: *\nAllow: /\nDisallow: /app/\nSitemap: https://portbuddy.dev/sitemap.xml"
  },
  {
    "path": "web/public/setup-portbuddy-service.ps1",
    "content": "<#\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n#>\n\n# Check if running as Administrator\n$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())\nif (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {\n    Write-Warning \"Please run as Administrator to register a service (Scheduled Task).\"\n    exit 1\n}\n\nparam(\n    [Parameter(Position=0, Mandatory=$true)]\n    [string]$Mode,\n    \n    [Parameter(Position=1, Mandatory=$true)]\n    [string]$Port,\n    \n    [Parameter(Position=2)]\n    [string]$HostName,\n    \n    [Parameter(Mandatory=$false)]\n    [string]$Name\n)\n\n# Construct target argument\nif ([string]::IsNullOrEmpty($HostName)) {\n    $Target = \"$Port\"\n} else {\n    $Target = \"$HostName`:$Port\"\n}\n\n# Determine Service Name\nif ([string]::IsNullOrEmpty($Name)) {\n    $ServiceName = \"portbuddy-$Mode-$Port\"\n} else {\n    $ServiceName = $Name\n}\n\n# Locate portbuddy executable\n$PortBuddyBin = Get-Command \"portbuddy\" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source\nif (-not $PortBuddyBin) {\n    # Try common location installed by install.ps1\n    $CommonPath = Join-Path $env:USERPROFILE \".portbuddy\\portbuddy.exe\"\n    if (Test-Path $CommonPath) {\n        $PortBuddyBin = $CommonPath\n    } else {\n        Write-Error \"Error: portbuddy executable not found in PATH or $CommonPath\"\n        Write-Error \"Please make sure portbuddy is installed and available.\"\n        exit 1\n    }\n}\n\nWrite-Host \"Setting up Port Buddy service...\" -ForegroundColor Cyan\nWrite-Host \"Service Name: $ServiceName\"\nWrite-Host \"Binary: $PortBuddyBin\"\nWrite-Host \"Command: $PortBuddyBin $Mode $Target -n\"\nWrite-Host \"User Profile: $env:USERPROFILE\"\n\n# Create Scheduled Task Action\n# We use cmd /c to ensure environment variables (specifically USERPROFILE) are set correctly for the context\n# or rely on the fact that if we run as SYSTEM, we need to point it to the config.\n# Port Buddy likely uses 'user.home' to find config.\n# We set USERPROFILE to the current user's profile so it finds the config.\n$UserHome = $env:USERPROFILE\n$ActionCmd = \"cmd.exe\"\n$ActionArg = \"/c set USERPROFILE=$UserHome && `\"$PortBuddyBin`\" $Mode $Target\"\n$Action = New-ScheduledTaskAction -Execute $ActionCmd -Argument $ActionArg\n\n# Create Trigger (At Startup)\n$Trigger = New-ScheduledTaskTrigger -AtStartup\n\n# Create Settings\n# Restart if it fails, don't stop on battery, etc.\n$Settings = New-ScheduledTaskSettingsSet `\n    -AllowStartIfOnBatteries `\n    -DontStopIfGoingOnBatteries `\n    -StartWhenAvailable `\n    -RestartCount 3 `\n    -RestartInterval (New-TimeSpan -Minutes 1) `\n    -ExecutionTimeLimit (New-TimeSpan -Days 3650) # Effectively infinite\n\n# Create Principal (SYSTEM account)\n$Principal = New-ScheduledTaskPrincipal -UserId \"SYSTEM\" -LogonType ServiceAccount -RunLevel Highest\n\n# Register Task\ntry {\n    Register-ScheduledTask -TaskName $ServiceName -Action $Action -Trigger $Trigger -Principal $Principal -Settings $Settings -Force | Out-Null\n    Write-Host \"----------------------------------------\"\n    Write-Host \"Service $ServiceName has been installed and started (scheduled).\" -ForegroundColor Green\n    Write-Host \"To start immediately: Start-ScheduledTask -TaskName $ServiceName\"\n    Write-Host \"Check status with: Get-ScheduledTask -TaskName $ServiceName\"\n    \n    # Attempt to start it now\n    Start-ScheduledTask -TaskName $ServiceName\n} catch {\n    Write-Error \"Failed to register task: $_\"\n    exit 1\n}\n"
  },
  {
    "path": "web/public/setup-portbuddy-service.sh",
    "content": "#!/bin/bash\n\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n#\n\n# Check if running as root\nif [ \"$EUID\" -ne 0 ]; then\n  echo \"Please run as root (sudo)\"\n  exit 1\nfi\n\n# Parse arguments\nPOSITIONAL_ARGS=()\nCUSTOM_NAME=\"\"\n\nwhile [[ $# -gt 0 ]]; do\n  case $1 in\n    --name)\n      CUSTOM_NAME=\"$2\"\n      shift # past argument\n      shift # past value\n      ;;\n    *)\n      POSITIONAL_ARGS+=(\"$1\") # save positional arg\n      shift # past argument\n      ;;\n  esac\ndone\n\nset -- \"${POSITIONAL_ARGS[@]}\" # restore positional parameters\n\nif [ \"$#\" -lt 2 ]; then\n    echo \"Usage: $0 [options] <mode> <port> [host]\"\n    echo \"Options:\"\n    echo \"  --name <name>    Custom name for the service\"\n    echo \"Example: $0 tcp 22\"\n    echo \"Example: $0 --name my-ssh-service tcp 22\"\n    exit 1\nfi\n\nMODE=$1\nPORT=$2\nHOST=$3\n\n# Construct the argument for portbuddy\nif [ -z \"$HOST\" ]; then\n    TARGET=\"$PORT\"\nelse\n    TARGET=\"$HOST:$PORT\"\nfi\n\nif [ -n \"$CUSTOM_NAME\" ]; then\n    SERVICE_NAME=\"$CUSTOM_NAME\"\nelse\n    SERVICE_NAME=\"portbuddy-${MODE}-${PORT}\"\nfi\n# Get the real user if running under sudo\nREAL_USER=${SUDO_USER:-$USER}\n# Get home dir of the real user\nREAL_USER_HOME=$(getent passwd \"$REAL_USER\" | cut -d: -f6)\n\n# Locate portbuddy binary\nPORTBUDDY_BIN=$(which portbuddy)\nif [ -z \"$PORTBUDDY_BIN\" ]; then\n    # Try common location\n    if [ -f \"/usr/local/bin/portbuddy\" ]; then\n        PORTBUDDY_BIN=\"/usr/local/bin/portbuddy\"\n    else \n        echo \"Error: portbuddy binary not found in PATH or /usr/local/bin\"\n        echo \"Please make sure portbuddy is installed and available.\"\n        exit 1\n    fi\nfi\n\necho \"Setting up Port Buddy service...\"\necho \"User: $REAL_USER\"\necho \"Binary: $PORTBUDDY_BIN\"\necho \"Command: $PORTBUDDY_BIN $MODE $TARGET\"\n\n# Create systemd service file\ncat <<EOF > /etc/systemd/system/$SERVICE_NAME.service\n[Unit]\nDescription=Port Buddy Service\nAfter=network.target\n\n[Service]\nType=simple\nUser=$REAL_USER\nExecStart=$PORTBUDDY_BIN $MODE $TARGET -n\nRestart=on-failure\nRestartSec=5\nEnvironment=HOME=$REAL_USER_HOME\n\n[Install]\nWantedBy=multi-user.target\nEOF\n\n# Reload systemd, enable and start service\nsystemctl daemon-reload\nsystemctl enable $SERVICE_NAME\nsystemctl restart $SERVICE_NAME\n\necho \"----------------------------------------\"\necho \"Service $SERVICE_NAME has been installed and started.\"\necho \"Check status with: systemctl status $SERVICE_NAME\"\necho \"View logs with: journalctl -u $SERVICE_NAME -f\"\n"
  },
  {
    "path": "web/public/site.webmanifest",
    "content": "{\n  \"name\" : \"Port Buddy\",\n  \"short_name\" : \"PortBuddy\",\n  \"start_url\": \"https://portbuddy.dev\",\n  \"icons\" : [\n    {\n      \"src\" : \"/favicon-192x192.png\",\n      \"sizes\" : \"192x192\",\n      \"type\" : \"image/png\"\n    },\n    {\n      \"src\" : \"/favicon-512x512.png\",\n      \"sizes\" : \"512x512\",\n      \"type\" : \"image/png\"\n    }\n  ],\n  \"theme_color\" : \"#ffffff\",\n  \"background_color\" : \"#ffffff\",\n  \"display\" : \"standalone\"\n}"
  },
  {
    "path": "web/public/sitemap.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n  <url>\n    <loc>https://portbuddy.dev/</loc>\n    <priority>1.0</priority>\n    <changefreq>daily</changefreq>\n  </url>\n  <url>\n    <loc>https://portbuddy.dev/install</loc>\n    <priority>0.8</priority>\n    <changefreq>monthly</changefreq>\n  </url>\n  <url>\n    <loc>https://portbuddy.dev/docs</loc>\n    <priority>0.8</priority>\n    <changefreq>monthly</changefreq>\n  </url>\n  <url>\n    <loc>https://portbuddy.dev/login</loc>\n    <priority>0.5</priority>\n    <changefreq>monthly</changefreq>\n  </url>\n  <url>\n    <loc>https://portbuddy.dev/register</loc>\n    <priority>0.5</priority>\n    <changefreq>monthly</changefreq>\n  </url>\n  <url>\n    <loc>https://portbuddy.dev/forgot-password</loc>\n    <priority>0.1</priority>\n    <changefreq>monthly</changefreq>\n  </url>\n  <url>\n    <loc>https://portbuddy.dev/terms</loc>\n    <priority>0.3</priority>\n    <changefreq>monthly</changefreq>\n  </url>\n  <url>\n    <loc>https://portbuddy.dev/privacy</loc>\n    <priority>0.3</priority>\n    <changefreq>monthly</changefreq>\n  </url>\n  <url>\n    <loc>https://portbuddy.dev/contacts</loc>\n    <priority>0.3</priority>\n    <changefreq>monthly</changefreq>\n  </url>\n  <url>\n    <loc>https://portbuddy.dev/docs/guides/minecraft-server</loc>\n    <priority>0.8</priority>\n    <changefreq>monthly</changefreq>\n  </url>\n  <url>\n    <loc>https://portbuddy.dev/docs/guides/hytale-server</loc>\n    <priority>0.8</priority>\n    <changefreq>monthly</changefreq>\n  </url>\n</urlset>"
  },
  {
    "path": "web/src/App.tsx",
    "content": "import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'\nimport { useEffect, useState } from 'react'\nimport { Bars3Icon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/outline'\nimport Landing from './pages/Landing'\nimport Installation from './pages/Installation'\nimport DocsLayout from './pages/docs/DocsLayout'\nimport DocsOverview from './pages/docs/DocsOverview'\nimport MinecraftGuide from './pages/docs/guides/MinecraftGuide'\nimport HytaleGuide from './pages/docs/guides/HytaleGuide'\nimport AcceptInvite from './pages/AcceptInvite'\nimport Login from './pages/Login'\nimport Register from './pages/Register'\nimport ForgotPassword from './pages/ForgotPassword'\nimport ResetPassword from './pages/ResetPassword'\nimport Billing from './pages/app/Billing'\nimport BillingSuccess from './pages/app/BillingSuccess'\nimport BillingCancel from './pages/app/BillingCancel'\nimport ProtectedRoute from './components/ProtectedRoute'\nimport { useAuth } from './auth/AuthContext'\nimport AppLayout from './components/AppLayout'\nimport { useLoading } from './components/LoadingContext'\nimport ProgressBar from './components/ProgressBar'\nimport { setLoadingCallbacks } from './lib/api'\nimport Tunnels from './pages/app/Tunnels'\nimport Tokens from './pages/app/Tokens'\nimport Domains from './pages/app/Domains'\nimport Settings from './pages/app/Settings'\nimport Team from './pages/app/Team'\nimport Ports from './pages/app/Ports'\nimport Profile from './pages/app/Profile'\nimport AdminPanel from './pages/app/AdminPanel'\nimport AdminAccounts from './pages/app/AdminAccounts'\nimport AdminUsers from './pages/app/AdminUsers'\nimport AdminTunnels from './pages/app/AdminTunnels'\nimport Terms from './pages/Terms'\nimport Privacy from './pages/Privacy'\nimport Contacts from './pages/Contacts'\nimport NotFound from './pages/NotFound'\nimport ServerError from './pages/ServerError'\nimport Passcode from './pages/Passcode'\n\nfunction ScrollToTop() {\n  const { pathname } = useLocation()\n  useEffect(() => {\n    window.scrollTo(0, 0)\n  }, [pathname])\n  return null\n}\n\nfunction ScrollToHash() {\n  const location = useLocation()\n  useEffect(() => {\n    if (!location.hash) {\n      return\n    }\n    const id = location.hash.replace('#', '')\n    const scroll = () => {\n      const el = document.getElementById(id)\n      if (el) {\n        el.scrollIntoView({ behavior: 'smooth', block: 'start' })\n      }\n    }\n    // Try immediately and once more on next tick to ensure target is mounted\n    scroll()\n    const t = setTimeout(scroll, 0)\n    return () => clearTimeout(t)\n  }, [location.hash])\n  return null\n}\n\nexport default function App() {\n  const { user, logout } = useAuth()\n  const { startLoading, stopLoading } = useLoading()\n  const [menuOpen, setMenuOpen] = useState(false)\n  const [communityOpen, setCommunityOpen] = useState(false)\n  const location = useLocation()\n  \n  useEffect(() => {\n    setLoadingCallbacks(startLoading, stopLoading)\n    // Signal to pre-renderer that the page is ready\n    const timer = setTimeout(() => {\n      document.dispatchEvent(new Event('render-event'))\n    }, 100);\n    return () => clearTimeout(timer);\n  }, [startLoading, stopLoading, location.pathname])\n\n  const isApp = location.pathname.startsWith('/app')\n  const showHeader = !isApp && !['/login', '/register', '/forgot-password', '/reset-password'].includes(location.pathname)\n\n  // const [currentYear, setCurrentYear] = useState<number | null>(null);\n  //\n  // useEffect(() => {\n  //   setCurrentYear(new Date().getFullYear());\n  // }, []);\n\n  return (\n    <div className=\"min-h-full w-full flex flex-col bg-slate-950 text-slate-200\">\n      <ProgressBar />\n      {showHeader && (\n      <header className=\"border-b border-slate-800 bg-slate-900/80 backdrop-blur fixed w-full top-0 z-50\">\n        <div className=\"container flex items-center justify-between py-4 relative\">\n          <Link to=\"/\" className=\"flex items-center gap-3 text-lg font-bold text-white hover:opacity-90 transition-opacity\">\n            <span className=\"relative flex h-3 w-3\">\n               <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75\"></span>\n               <span className=\"relative inline-flex rounded-full h-3 w-3 bg-indigo-500\"></span>\n            </span>\n            Port Buddy\n          </Link>\n          <nav className=\"flex items-center gap-4 lg:gap-8 text-sm font-medium\">\n            <div className=\"hidden lg:flex items-center gap-8\">\n              <a \n                href=\"https://github.com/amak-tech/port-buddy\" \n                target=\"_blank\" \n                rel=\"noopener noreferrer\"\n                className=\"text-slate-400 hover:text-white transition-colors flex items-center gap-1.5\"\n              >\n                <svg className=\"w-4 h-4 fill-current\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                  <path d=\"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\"/>\n                </svg>\n                GitHub\n              </a>\n              <Link to=\"/install\" className=\"text-slate-400 hover:text-white transition-colors\" aria-label=\"Installation instructions\">Installation</Link>\n              <Link to=\"/docs\" className=\"text-slate-400 hover:text-white transition-colors\" aria-label=\"Documentation\">Docs</Link>\n              \n              <div className=\"relative group\">\n                <button \n                  className=\"flex items-center gap-1 text-slate-400 hover:text-white transition-colors\"\n                  onMouseEnter={() => setCommunityOpen(true)}\n                  onMouseLeave={() => setCommunityOpen(false)}\n                >\n                  Community\n                  <ChevronDownIcon className={`w-3.5 h-3.5 transition-transform duration-200 ${communityOpen ? 'rotate-180' : ''}`} />\n                </button>\n                \n                {communityOpen && (\n                  <div \n                    className=\"absolute left-0 mt-0 w-48 pt-2 z-50\"\n                    onMouseEnter={() => setCommunityOpen(true)}\n                    onMouseLeave={() => setCommunityOpen(false)}\n                  >\n                    <div className=\"bg-slate-900 border border-slate-800 rounded-xl shadow-2xl p-1.5 overflow-hidden\">\n                      <a \n                        href=\"https://discord.gg/PaEzzjmvSH\" \n                        target=\"_blank\" \n                        rel=\"noopener noreferrer\"\n                        className=\"flex items-center gap-3 px-3 py-2 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition-colors\"\n                      >\n                        <svg className=\"w-4 h-4 fill-current\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                          <path d=\"M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z\"/>\n                        </svg>\n                        Discord\n                      </a>\n                      <a \n                        href=\"https://t.me/portbuddy\" \n                        target=\"_blank\" \n                        rel=\"noopener noreferrer\"\n                        className=\"flex items-center gap-3 px-3 py-2 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition-colors\"\n                      >\n                        <svg className=\"w-4 h-4 fill-current\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                          <path d=\"M11.944 0C5.346 0 0 5.346 0 11.944s5.346 11.944 11.944 11.944 11.944-5.346 11.944-11.944S18.542 0 11.944 0zm5.203 7.847c-.16 1.497-.859 5.635-1.215 7.541-.15.807-.444 1.077-.731 1.104-.623.056-1.097-.413-1.701-.81-.944-.621-1.477-1.008-2.391-1.61-.952-.621-.295-.964.218-1.503.134-.14 2.463-2.259 2.508-2.449.006-.024.01-.115-.042-.162-.052-.047-.13-.031-.186-.019-.08.017-1.353.858-3.819 2.525-.361.248-.688.37-.98.361-.322-.01-.942-.185-1.403-.335-.565-.184-1.013-.281-.974-.593.02-.162.244-.33.673-.505 2.636-1.148 4.393-1.907 5.271-2.277 2.51-.83 3.03-.974 3.37-.98.075-.001.244.018.354.108.093.075.118.177.127.248.009.072.02.213.01.35z\"/>\n                        </svg>\n                        Telegram\n                      </a>\n                    </div>\n                  </div>\n                )}\n              </div>\n\n              <Link to=\"/#pricing\" className=\"text-slate-400 hover:text-white transition-colors\" aria-label=\"View pricing\">Pricing</Link>\n            </div>\n            \n            <div className=\"relative\">\n              <button\n                className=\"lg:hidden w-10 h-10 inline-flex items-center justify-center rounded-lg border border-slate-700 text-slate-400 hover:text-white hover:bg-slate-800 transition-colors\"\n                aria-label=\"Open menu\"\n                aria-expanded={menuOpen}\n                onClick={() => setMenuOpen((v) => !v)}\n              >\n                <span className=\"sr-only\">Menu</span>\n                {menuOpen ? <XMarkIcon className=\"h-6 w-6\" /> : <Bars3Icon className=\"h-6 w-6\" />}\n              </button>\n\n              {!user && (\n                <Link \n                  to=\"/login\" \n                  className=\"hidden lg:inline-block bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2 rounded-lg transition-all shadow-lg shadow-indigo-500/20\"\n                >\n                  Login\n                </Link>\n              )}\n\n              {user && (\n                <button\n                  className=\"hidden lg:inline-flex w-10 h-10 items-center justify-center rounded-lg border border-slate-700 text-slate-400 hover:text-white hover:bg-slate-800 transition-colors\"\n                  aria-label=\"Open menu\"\n                  aria-expanded={menuOpen}\n                  onClick={() => setMenuOpen((v) => !v)}\n                >\n                  <span className=\"sr-only\">Menu</span>\n                  {menuOpen ? <XMarkIcon className=\"h-6 w-6\" /> : <Bars3Icon className=\"h-6 w-6\" />}\n                </button>\n              )}\n\n              {menuOpen && (\n                <div className=\"absolute right-0 mt-3 w-64 rounded-xl border border-slate-800 bg-slate-900 shadow-2xl p-2 z-50\">\n                  <div className=\"lg:hidden\">\n                    <Link to=\"/install\" className=\"block px-4 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition-colors\" onClick={() => setMenuOpen(false)}>Installation</Link>\n                    <Link to=\"/docs\" className=\"block px-4 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition-colors\" onClick={() => setMenuOpen(false)}>Documentation</Link>\n                    <div className=\"h-px bg-slate-800 my-1 mx-2\"></div>\n                    <div className=\"px-4 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider\">Community</div>\n                    <a href=\"https://discord.gg/PaEzzjmvSH\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"flex items-center gap-3 px-4 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition-colors\" onClick={() => setMenuOpen(false)}>\n                      <svg className=\"w-4 h-4 fill-current text-slate-400\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z\"/>\n                      </svg>\n                      Discord\n                    </a>\n                    <a href=\"https://t.me/portbuddy\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"flex items-center gap-3 px-4 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition-colors\" onClick={() => setMenuOpen(false)}>\n                      <svg className=\"w-4 h-4 fill-current text-slate-400\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M11.944 0C5.346 0 0 5.346 0 11.944s5.346 11.944 11.944 11.944 11.944-5.346 11.944-11.944S18.542 0 11.944 0zm5.203 7.847c-.16 1.497-.859 5.635-1.215 7.541-.15.807-.444 1.077-.731 1.104-.623.056-1.097-.413-1.701-.81-.944-.621-1.477-1.008-2.391-1.61-.952-.621-.295-.964.218-1.503.134-.14 2.463-2.259 2.508-2.449.006-.024.01-.115-.042-.162-.052-.047-.13-.031-.186-.019-.08.017-1.353.858-3.819 2.525-.361.248-.688.37-.98.361-.322-.01-.942-.185-1.403-.335-.565-.184-1.013-.281-.974-.593.02-.162.244-.33.673-.505 2.636-1.148 4.393-1.907 5.271-2.277 2.51-.83 3.03-.974 3.37-.98.075-.001.244.018.354.108.093.075.118.177.127.248.009.072.02.213.01.35z\"/>\n                      </svg>\n                      Telegram\n                    </a>\n                    <div className=\"h-px bg-slate-800 my-1 mx-2\"></div>\n                    <Link to=\"/#pricing\" className=\"block px-4 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition-colors\" onClick={() => setMenuOpen(false)}>Pricing</Link>\n                    <div className=\"h-px bg-slate-800 my-2 mx-2\"></div>\n                  </div>\n                  \n                  {user ? (\n                    <>\n                      <Link to=\"/app\" className=\"block px-4 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition-colors\" onClick={() => setMenuOpen(false)}>Dashboard</Link>\n                      <Link to=\"/app/settings\" className=\"block px-4 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition-colors\" onClick={() => setMenuOpen(false)}>Settings</Link>\n                      <Link to=\"/app/billing\" className=\"block px-4 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition-colors\" onClick={() => setMenuOpen(false)}>Billing</Link>\n                      <div className=\"h-px bg-slate-800 my-2 mx-2\"></div>\n                      <button className=\"block w-full text-left px-4 py-2.5 rounded-lg text-red-400 hover:text-red-300 hover:bg-red-500/10 transition-colors\" onClick={() => { setMenuOpen(false); void logout() }}>Logout</button>\n                    </>\n                  ) : (\n                    <Link to=\"/login\" className=\"block px-4 py-2.5 rounded-lg text-indigo-400 font-medium hover:bg-indigo-500/10 transition-colors\" onClick={() => setMenuOpen(false)}>Login</Link>\n                  )}\n                </div>\n              )}\n            </div>\n          </nav>\n        </div>\n      </header>\n      )}\n\n      <main className={`flex-1 w-full ${showHeader ? 'pt-[73px]' : ''}`}>\n        <Routes>\n          <Route path=\"/\" element={<Landing/>} />\n          <Route path=\"/index\" element={<Navigate to=\"/\" replace />} />\n          <Route path=\"/install\" element={<Installation/>} />\n          <Route path=\"/docs\" element={<DocsLayout/>}>\n            <Route index element={<DocsOverview/>} />\n            <Route path=\"guides/minecraft-server\" element={<MinecraftGuide/>} />\n            <Route path=\"guides/hytale-server\" element={<HytaleGuide/>} />\n          </Route>\n          <Route path=\"/login\" element={<Login/>} />\n          <Route path=\"/register\" element={<Register/>} />\n          <Route path=\"/forgot-password\" element={<ForgotPassword/>} />\n          <Route path=\"/reset-password\" element={<ResetPassword/>} />\n          <Route path=\"/auth/callback\" element={<Login/>} />\n          <Route path=\"/accept-invite\" element={<AcceptInvite/>} />\n          <Route path=\"/passcode\" element={<Passcode/>} />\n          <Route path=\"/terms\" element={<Terms/>} />\n          <Route path=\"/privacy\" element={<Privacy/>} />\n          <Route path=\"/contacts\" element={<Contacts/>} />\n          {/* App area with sidebar layout */}\n          <Route path=\"/app\" element={<ProtectedRoute><AppLayout/></ProtectedRoute>}>\n            <Route index element={<Tunnels/>} />\n            <Route path=\"tokens\" element={<Tokens/>} />\n            <Route path=\"domains\" element={<Domains/>} />\n            <Route path=\"ports\" element={<Ports/>} />\n            <Route path=\"team\" element={<Team/>} />\n            <Route path=\"billing\" element={<ProtectedRoute role=\"ACCOUNT_ADMIN\"><Billing/></ProtectedRoute>} />\n            <Route path=\"billing/success\" element={<BillingSuccess/>} />\n            <Route path=\"billing/cancel\" element={<BillingCancel/>} />\n            <Route path=\"settings\" element={<ProtectedRoute role=\"ACCOUNT_ADMIN\"><Settings/></ProtectedRoute>} />\n            <Route path=\"profile\" element={<Profile/>} />\n            <Route path=\"admin\" element={<ProtectedRoute role=\"ADMIN\"><AdminPanel/></ProtectedRoute>} />\n            <Route path=\"admin/accounts\" element={<ProtectedRoute role=\"ADMIN\"><AdminAccounts/></ProtectedRoute>} />\n            <Route path=\"admin/users\" element={<ProtectedRoute role=\"ADMIN\"><AdminUsers/></ProtectedRoute>} />\n            <Route path=\"admin/tunnels\" element={<ProtectedRoute role=\"ADMIN\"><AdminTunnels/></ProtectedRoute>} />\n            {/* Unknown app routes redirect to dashboard */}\n            <Route path=\"*\" element={<Navigate to=\"/app\" replace />} />\n          </Route>\n          {/* Backward-compat for old links */}\n          <Route path=\"/app/subscription\" element={<Navigate to=\"/app/billing\" replace />} />\n          {/* Global 404 */}\n          <Route path=\"/500\" element={<ServerError/>} />\n          <Route path=\"*\" element={<NotFound/>} />\n        </Routes>\n        <ScrollToTop />\n        <ScrollToHash />\n        <Outlet />\n      </main>\n\n      {showHeader && (\n      <footer className=\"border-t border-slate-800 py-12 bg-slate-900 mt-auto\">\n        <div className=\"container flex flex-col md:flex-row items-center justify-between gap-6\">\n          <div className=\"flex items-center gap-2 text-slate-400 text-sm\">\n             <span>© 2026 Port Buddy. All rights reserved.</span>\n          </div>\n          <div className=\"flex flex-wrap justify-center gap-x-8 gap-y-4 text-sm font-medium\">\n            <a \n              href=\"https://github.com/amak-tech/port-buddy\" \n              target=\"_blank\" \n              rel=\"noopener noreferrer\"\n              className=\"text-slate-400 hover:text-indigo-400 transition-colors flex items-center gap-1.5\"\n            >\n              <svg className=\"w-4 h-4 fill-current\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                <path d=\"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\"/>\n              </svg>\n              GitHub\n            </a>\n            <a href=\"/#pricing\" className=\"text-slate-400 hover:text-indigo-400 transition-colors\">Pricing</a>\n            <div className=\"flex items-center gap-4\">\n              <a \n                href=\"https://discord.gg/PaEzzjmvSH\" \n                target=\"_blank\" \n                rel=\"noopener noreferrer\"\n                className=\"text-slate-400 hover:text-[#5865F2] transition-colors\"\n                title=\"Discord\"\n              >\n                <svg className=\"w-5 h-5 fill-current\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                  <path d=\"M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z\"/>\n                </svg>\n              </a>\n              <a \n                href=\"https://t.me/portbuddy\" \n                target=\"_blank\" \n                rel=\"noopener noreferrer\"\n                className=\"text-slate-400 hover:text-[#24A1DE] transition-colors\"\n                title=\"Telegram\"\n              >\n                <svg className=\"w-5 h-5 fill-current\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                  <path d=\"M11.944 0C5.346 0 0 5.346 0 11.944s5.346 11.944 11.944 11.944 11.944-5.346 11.944-11.944S18.542 0 11.944 0zm5.203 7.847c-.16 1.497-.859 5.635-1.215 7.541-.15.807-.444 1.077-.731 1.104-.623.056-1.097-.413-1.701-.81-.944-.621-1.477-1.008-2.391-1.61-.952-.621-.295-.964.218-1.503.134-.14 2.463-2.259 2.508-2.449.006-.024.01-.115-.042-.162-.052-.047-.13-.031-.186-.019-.08.017-1.353.858-3.819 2.525-.361.248-.688.37-.98.361-.322-.01-.942-.185-1.403-.335-.565-.184-1.013-.281-.974-.593.02-.162.244-.33.673-.505 2.636-1.148 4.393-1.907 5.271-2.277 2.51-.83 3.03-.974 3.37-.98.075-.001.244.018.354.108.093.075.118.177.127.248.009.072.02.213.01.35z\"/>\n                </svg>\n              </a>\n            </div>\n            <a href=\"/#use-cases\" className=\"text-slate-400 hover:text-indigo-400 transition-colors\">Use Cases</a>\n            <Link to=\"/docs\" className=\"text-slate-400 hover:text-indigo-400 transition-colors\">Documentation</Link>\n            <Link to=\"/contacts\" className=\"text-slate-400 hover:text-indigo-400 transition-colors\">Contacts</Link>\n            <Link to=\"/terms\" className=\"text-slate-400 hover:text-indigo-400 transition-colors\">Terms</Link>\n            <Link to=\"/privacy\" className=\"text-slate-400 hover:text-indigo-400 transition-colors\">Privacy</Link>\n          </div>\n        </div>\n      </footer>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/auth/AuthContext.tsx",
    "content": "import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'\nimport { jwtDecode } from 'jwt-decode'\nimport { API_BASE, apiJson, getToken } from '../lib/api'\n\nexport type User = {\n    id: string\n    accountId: string\n    email: string\n    name?: string\n    avatarUrl?: string\n    roles?: string[]\n    plan?: 'pro' | 'team'\n    accountName?: string\n    extraTunnels?: number\n    baseTunnels?: number\n    activeTunnels?: number\n    subscriptionStatus?: string\n    stripeCustomerId?: string\n    blocked?: boolean\n}\n\nconst ACCOUNT_NAME_CLAIM = 'aname'\nconst ACCOUNT_ID_CLAIM = 'aid'\n\ntype AuthState = {\n    user: User | null\n    loading: boolean\n    loginWithGoogle: () => void\n    loginWithGithub: () => void\n    loginWithEmail: (email: string, pass: string) => Promise<void>\n    register: (email: string, pass: string, name?: string) => Promise<void>\n    logout: () => Promise<void>\n    refresh: () => Promise<void>\n    switchAccount: (accountId: string) => Promise<void>\n}\n\nconst AuthContext = createContext<AuthState | undefined>(undefined)\n\nconst APP_ORIGIN = window.location.origin\nconst OAUTH_REDIRECT_URI = `${APP_ORIGIN}/auth/callback`\n\nfunction storeTokenFromUrlIfPresent(): string | null {\n    // Only capture token if we are on the auth callback path or similar\n    // to avoid stealing tokens from other pages (like /accept-invite?token=...)\n    if (!window.location.pathname.startsWith('/auth/callback') && !window.location.pathname.startsWith('/login')) {\n        return localStorage.getItem('pb_token')\n    }\n\n    // Support either hash or query param token from backend callback, e.g. /auth/callback#token=... or ?token=...\n    const hash = new URLSearchParams(window.location.hash.replace(/^#/, ''))\n    const query = new URLSearchParams(window.location.search)\n    const token = hash.get('token') || query.get('token')\n    if (token) {\n        localStorage.setItem('pb_token', token)\n        // Clean URL\n        const url = new URL(window.location.href)\n        url.hash = ''\n        url.searchParams.delete('token')\n        window.history.replaceState({}, '', url.toString())\n        return token\n    }\n    return localStorage.getItem('pb_token')\n}\n\nexport function AuthProvider({ children }: { children: React.ReactNode }) {\n    const [user, setUser] = useState<User | null>(null)\n    const [loading, setLoading] = useState(true)\n\n    const refresh = useCallback(async () => {\n        setLoading(true)\n\n        const token = getToken()\n        if (!token) {\n            setUser(null)\n            setLoading(false)\n            return\n        }\n\n        const decoded: any = jwtDecode(token)\n        const currentUserId = decoded.uid\n        const currentAccountName = decoded[ACCOUNT_NAME_CLAIM]\n        const currentAccountId = decoded[ACCOUNT_ID_CLAIM]\n\n        try {\n            const details = await apiJson<{\n                user: { id: string, email: string, firstName?: string, lastName?: string, avatarUrl?: string, roles?: string[] }\n                account?: { name?: string, plan?: string, extraTunnels?: number, baseTunnels?: number, activeTunnels?: number, subscriptionStatus?: string, stripeCustomerId?: string, blocked?: boolean }\n            }>('/api/users/me/details', undefined, { skipRedirectOn401: true })\n\n            const firstName = details?.user?.firstName?.trim() || ''\n            const lastName = details?.user?.lastName?.trim() || ''\n            const name = [firstName, lastName].filter(Boolean).join(' ') || undefined\n\n            // Map server response to SPA User shape\n            const mapped: User = {\n                id: details.user.id,\n                accountId: currentAccountId,\n                email: details.user.email,\n                name,\n                avatarUrl: details.user.avatarUrl || undefined,\n                roles: details.user.roles,\n                plan: details.account?.plan?.toLowerCase() as any,\n                accountName: details.account?.name || currentAccountName,\n                extraTunnels: details.account?.extraTunnels,\n                baseTunnels: details.account?.baseTunnels,\n                activeTunnels: details.account?.activeTunnels,\n                subscriptionStatus: details.account?.subscriptionStatus,\n                stripeCustomerId: details.account?.stripeCustomerId,\n                blocked: details.account?.blocked,\n            }\n            setUser(mapped)\n        } catch (e: any) {\n            if (e.status === 401) {\n                try {\n                    localStorage.removeItem('pb_token')\n                } catch (_) {\n                    // ignore\n                }\n            }\n            setUser(null)\n        } finally {\n            setLoading(false)\n        }\n    }, [])\n\n    useEffect(() => {\n        // On first load, capture token if backend sent it in URL and then fetch profile\n        storeTokenFromUrlIfPresent()\n        void refresh()\n    }, [refresh])\n\n    const loginWithGoogle = useCallback(() => {\n        const redirect = encodeURIComponent(OAUTH_REDIRECT_URI)\n        // Typical Spring Security OAuth2 endpoint\n        const url = `${API_BASE}/oauth2/authorization/google?redirect_uri=${redirect}`\n        window.location.href = url\n    }, [])\n    \n    const loginWithGithub = useCallback(() => {\n        const redirect = encodeURIComponent(OAUTH_REDIRECT_URI)\n        // Typical Spring Security OAuth2 endpoint\n        const url = `${API_BASE}/oauth2/authorization/github?redirect_uri=${redirect}`\n        window.location.href = url\n    }, [])\n\n    const loginWithEmail = useCallback(async (email: string, pass: string) => {\n        const res = await apiJson<{ accessToken: string, tokenType: string }>('/api/auth/login', {\n            method: 'POST',\n            body: JSON.stringify({ email, password: pass })\n        }, { skipAuth: true })\n        localStorage.setItem('pb_token', res.accessToken)\n        await refresh()\n    }, [refresh])\n\n    const register = useCallback(async (email: string, pass: string, name?: string) => {\n        const res = await apiJson<{ success: boolean, message?: string }>('/api/auth/register', {\n            method: 'POST',\n            body: JSON.stringify({ email, password: pass, name })\n        }, { skipAuth: true })\n        \n        if (!res.success) {\n            throw new Error(res.message || 'Registration failed')\n        }\n        \n        await loginWithEmail(email, pass)\n    }, [loginWithEmail])\n\n    const logout = useCallback(async () => {\n        // Stateless logout: just drop the JWT from localStorage client-side\n        try {\n            localStorage.removeItem('pb_token')\n        } catch (_) {\n            // ignore storage errors\n        }\n        setUser(null)\n        // Redirect to landing page after logout\n        window.location.assign('/')\n    }, [])\n\n    const switchAccount = useCallback(async (accountId: string) => {\n        try {\n            const res = await apiJson<{ token: string }>(`/api/users/me/accounts/${accountId}/switch`, {\n                method: 'POST'\n            })\n            localStorage.setItem('pb_token', res.token)\n            await refresh()\n        } catch (e) {\n            console.error('Failed to switch account', e)\n            throw e\n        }\n    }, [refresh])\n\n    const value = useMemo<AuthState>(() => ({ \n        user, \n        loading, \n        loginWithGoogle, \n        loginWithGithub,\n        loginWithEmail, \n        register,\n        logout, \n        refresh,\n        switchAccount\n    }), [user, loading, loginWithGoogle, loginWithGithub, loginWithEmail, register, logout, refresh, switchAccount])\n\n    return (\n        <AuthContext.Provider value={value}>\n            {children}\n        </AuthContext.Provider>\n    )\n}\n\nexport function useAuth(): AuthState {\n    const ctx = useContext(AuthContext)\n    if (!ctx) throw new Error('useAuth must be used within AuthProvider')\n    return ctx\n}\n"
  },
  {
    "path": "web/src/components/AppLayout.tsx",
    "content": "import { Link, NavLink, Outlet } from 'react-router-dom'\nimport { ComponentType, SVGProps, useEffect, useState } from 'react'\nimport { useAuth } from '../auth/AuthContext'\nimport { PageHeaderProvider, usePageHeader } from './PageHeader'\nimport { apiJson } from '../lib/api'\nimport {\n  AcademicCapIcon,\n  ArrowsRightLeftIcon,\n  ChevronUpDownIcon,\n  Cog8ToothIcon,\n  GlobeAltIcon,\n  LinkIcon,\n  LockClosedIcon,\n  WalletIcon,\n  UserGroupIcon,\n  PowerIcon,\n  ShieldCheckIcon,\n  XMarkIcon,\n  Bars3Icon,\n} from '@heroicons/react/24/outline'\n\ntype UserAccount = {\n  accountId: string\n  accountName: string\n  plan: string\n  roles: string[]\n  lastUsedAt: string\n}\n\nexport default function AppLayout() {\n  const { user, logout, switchAccount } = useAuth()\n  const [accounts, setAccounts] = useState<UserAccount[]>([])\n  const [showAccountSwitcher, setShowAccountSwitcher] = useState(false)\n  const [isSidebarOpen, setIsSidebarOpen] = useState(false)\n\n  useEffect(() => {\n    if (user) {\n      void apiJson<UserAccount[]>('/api/users/me/accounts').then(setAccounts)\n    }\n  }, [user])\n\n  const otherAccounts = accounts.filter(a => a.accountId !== user?.accountId)\n\n  return (\n    <div className=\"min-h-screen w-full flex bg-primary-950\">\n      {/* Mobile Sidebar Overlay */}\n      {isSidebarOpen && (\n        <div \n          className=\"fixed inset-0 z-40 bg-primary-950/60 backdrop-blur-sm lg:hidden\"\n          onClick={() => setIsSidebarOpen(false)}\n        />\n      )}\n\n      {/* Sidebar */}\n      <aside className={`fixed top-0 left-0 h-screen w-64 border-r border-white/5 glass z-[60] transition-transform duration-300 lg:translate-x-0 ${\n        isSidebarOpen ? 'translate-x-0' : '-translate-x-full'\n      }`}>\n        <div className=\"h-full flex flex-col\">\n          {/* Top app title (fixed at top) */}\n          <div className=\"sticky top-0 z-10 border-b border-white/5 px-6 py-6 flex items-center justify-between\">\n            <Link to=\"/\" className=\"flex items-center gap-3 text-xl font-black text-white hover:opacity-90 transition-opacity tracking-tighter\">\n              <span className=\"relative flex h-3 w-3\">\n                 <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75\"></span>\n                 <span className=\"relative inline-flex rounded-full h-3 w-3 bg-indigo-400\"></span>\n              </span>\n              Port Buddy\n            </Link>\n            <button \n              className=\"lg:hidden p-2 -mr-2 text-slate-400 hover:text-white\"\n              onClick={() => setIsSidebarOpen(false)}\n            >\n              <XMarkIcon className=\"h-6 w-6\" />\n            </button>\n          </div>\n\n          {/* Account Switcher */}\n          <div className=\"px-4 py-4 border-b border-white/5 relative\">\n            <button\n              disabled={otherAccounts.length === 0}\n              onClick={() => setShowAccountSwitcher(!showAccountSwitcher)}\n              className={`w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl glass text-white transition-all border border-white/5 ${\n                otherAccounts.length > 0 ? 'hover:bg-white/5 cursor-pointer' : 'cursor-default'\n              }`}\n            >\n              <div className=\"flex flex-col items-start min-w-0\">\n                <span className=\"text-[10px] font-bold text-slate-500 uppercase tracking-widest\">Account</span>\n                <span className=\"text-sm font-bold truncate w-full text-left\">\n                  {user?.accountName || 'Select Account'}\n                </span>\n              </div>\n              {otherAccounts.length > 0 && <ChevronUpDownIcon className=\"h-5 w-5 text-slate-400 shrink-0\" />}\n            </button>\n\n            {showAccountSwitcher && (\n              <div className=\"absolute top-full left-4 right-4 mt-1 z-50 bg-slate-800 border border-slate-700 rounded-lg shadow-xl overflow-hidden py-1\">\n                {otherAccounts.map(account => (\n                  <button\n                    key={account.accountId}\n                    onClick={() => {\n                      void switchAccount(account.accountId)\n                      setShowAccountSwitcher(false)\n                    }}\n                    className=\"w-full text-left px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 hover:text-white transition-colors\"\n                  >\n                    {account.accountName}\n                  </button>\n                ))}\n              </div>\n            )}\n          </div>\n\n          {/* Nav list (scrollable middle) */}\n          <nav className=\"flex-1 overflow-y-auto px-4 py-6 space-y-1\">\n            <SideLink to=\"/app\" end label=\"Tunnels\" Icon={ArrowsRightLeftIcon} onClick={() => setIsSidebarOpen(false)} />\n            <SideLink to=\"/app/tokens\" label=\"Access Tokens\" Icon={LockClosedIcon} onClick={() => setIsSidebarOpen(false)} />\n            <SideLink to=\"/app/domains\" label=\"Domains\" Icon={GlobeAltIcon} onClick={() => setIsSidebarOpen(false)} />\n            <SideLink to=\"/app/ports\" label=\"Port Reservations\" Icon={LinkIcon} onClick={() => setIsSidebarOpen(false)} />\n            <SideLink to=\"/app/team\" label=\"Team\" Icon={UserGroupIcon} onClick={() => setIsSidebarOpen(false)} />\n            {(user?.roles?.includes('ACCOUNT_ADMIN')) && (\n              <SideLink to=\"/app/billing\" label=\"Billing\" Icon={WalletIcon} onClick={() => setIsSidebarOpen(false)} />\n            )}\n            {user?.roles?.includes('ACCOUNT_ADMIN') && (\n              <SideLink to=\"/app/settings\" label=\"Settings\" Icon={Cog8ToothIcon} onClick={() => setIsSidebarOpen(false)} />\n            )}\n            {user?.roles?.includes('ADMIN') && (\n              <SideLink to=\"/app/admin\" label=\"Admin Panel\" Icon={ShieldCheckIcon} onClick={() => setIsSidebarOpen(false)} />\n            )}\n          </nav>\n\n          {/* Bottom block (fixed at bottom) */}\n          <div className=\"sticky bottom-0 z-10 border-t border-white/5 bg-primary-950 px-6 py-6\">\n            <div className=\"flex items-center justify-between gap-3 mb-6\">\n              <Link to=\"/docs\" className=\"text-slate-400 hover:text-white text-sm inline-flex items-center gap-2 transition-colors\">\n                <AcademicCapIcon className=\"h-5 w-5\" aria-hidden=\"true\" />\n                <span className=\"font-bold uppercase tracking-widest text-[10px]\">Documentation</span>\n              </Link>\n            </div>\n            <div className=\"flex items-center justify-between gap-3\">\n              <Link to=\"/app/profile\" className=\"flex items-center gap-3 min-w-0 hover:opacity-80 transition-opacity\" onClick={() => setIsSidebarOpen(false)}>\n                {user?.avatarUrl ? (\n                  <img src={user.avatarUrl} alt=\"avatar\" className=\"w-10 h-10 rounded-xl border border-white/10\" />\n                ) : (\n                  <div className=\"w-10 h-10 rounded-xl bg-gradient-to-br from-jb-blue to-jb-purple flex items-center justify-center text-white text-sm font-black shadow-lg shadow-jb-blue/20\">\n                    {user?.name?.[0] || user?.email?.[0] || '?'}\n                  </div>\n                )}\n                <div className=\"truncate\">\n                  <div className=\"text-sm font-bold text-white truncate\">{user?.name || user?.email || 'Unknown user'}</div>\n                  <div className=\"text-slate-500 text-[10px] font-mono truncate\">{user?.email}</div>\n                </div>\n              </Link>\n              <button\n                type=\"button\"\n                aria-label=\"Logout\"\n                title=\"Logout\"\n                onClick={() => void logout()}\n                className=\"p-2.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-all\"\n              >\n                <PowerIcon className=\"h-5 w-5\" aria-hidden=\"true\" />\n              </button>\n            </div>\n          </div>\n        </div>\n      </aside>\n\n      {/* Main content */}\n      <section className=\"flex-1 w-full min-w-0 flex flex-col min-h-0 lg:ml-64 bg-primary-950\">\n        <PageHeaderProvider>\n          {/* Page Header (sticky at top) */}\n          <div className=\"sticky top-0 z-10 border-b border-white/5 bg-primary-950/80 backdrop-blur px-6 lg:px-10 py-6 flex items-center gap-6\">\n            <button\n              className=\"lg:hidden p-2 -ml-2 text-slate-400 hover:text-white transition-colors\"\n              onClick={() => setIsSidebarOpen(true)}\n              aria-label=\"Open sidebar\"\n            >\n              <Bars3Icon className=\"h-6 w-6\" />\n            </button>\n            <HeaderTitle />\n          </div>\n          {/* Page body */}\n          <div className=\"px-6 lg:px-10 py-10 flex-1 overflow-y-auto\" data-scroll-root>\n            {user?.blocked && (\n              <div className=\"mb-8 rounded-xl bg-red-500/10 border border-red-500/20 p-6 flex items-start gap-4\">\n                <div className=\"p-3 rounded-lg bg-red-500/10 text-red-400\">\n                   <LockClosedIcon className=\"h-6 w-6\" />\n                </div>\n                <div>\n                  <h3 className=\"text-lg font-bold text-red-200\">Account Blocked</h3>\n                  <p className=\"mt-1 text-slate-300\">\n                    Your account is currently blocked. Access to services is restricted. \n                    Please contact support for assistance.\n                  </p>\n                </div>\n              </div>\n            )}\n            <Outlet />\n          </div>\n        </PageHeaderProvider>\n      </section>\n    </div>\n  )\n}\n\ntype IconType = ComponentType<SVGProps<SVGSVGElement>>\n\nfunction SideLink({ to, label, end = false, Icon, onClick }: { to: string, label: string, end?: boolean, Icon?: IconType, onClick?: () => void }) {\n  return (\n    <NavLink\n      to={to}\n      end={end}\n      onClick={onClick}\n      className={({ isActive }) => \n        `flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-200 ${\n          isActive \n            ? 'bg-jb-blue/10 text-jb-blue font-bold shadow-[inset_4px_0_0_0_#33ccff]' \n            : 'text-slate-400 hover:text-slate-200 hover:bg-white/5'\n        }`\n      }\n    >\n      {Icon ? <Icon className=\"h-5 w-5\" aria-hidden=\"true\" /> : null}\n      <span className=\"text-sm tracking-tight\">{label}</span>\n    </NavLink>\n  )\n}\n\nfunction HeaderTitle() {\n  const { title } = usePageHeader()\n  return (\n    <div className=\"text-xl font-black text-white truncate tracking-tighter uppercase\">{title}</div>\n  )\n}\n"
  },
  {
    "path": "web/src/components/CodeBlock.tsx",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\nexport default function CodeBlock({ code }: { code: string }) {\n  return (\n    <div className=\"bg-slate-950 border border-slate-800 rounded-lg p-4 font-mono text-sm text-slate-300 overflow-x-auto\">\n      <pre>{code}</pre>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/components/LoadingContext.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport React, { createContext, useContext, useState, useCallback } from 'react';\n\ninterface LoadingContextType {\n  isLoading: boolean;\n  startLoading: () => void;\n  stopLoading: () => void;\n}\n\nconst LoadingContext = createContext<LoadingContextType | undefined>(undefined);\n\nexport const LoadingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {\n  const [activeRequests, setActiveRequests] = useState(0);\n\n  const startLoading = useCallback(() => {\n    setActiveRequests((prev) => prev + 1);\n  }, []);\n\n  const stopLoading = useCallback(() => {\n    setActiveRequests((prev) => Math.max(0, prev - 1));\n  }, []);\n\n  const isLoading = activeRequests > 0;\n\n  return (\n    <LoadingContext.Provider value={{ isLoading, startLoading, stopLoading }}>\n      {children}\n    </LoadingContext.Provider>\n  );\n};\n\nexport const useLoading = () => {\n  const context = useContext(LoadingContext);\n  if (!context) {\n    throw new Error('useLoading must be used within a LoadingProvider');\n  }\n  return context;\n};\n"
  },
  {
    "path": "web/src/components/Modal.tsx",
    "content": "import { useEffect, useRef } from 'react'\nimport { createPortal } from 'react-dom'\nimport { XMarkIcon } from '@heroicons/react/24/outline'\n\ninterface ModalProps {\n  isOpen: boolean\n  onClose: () => void\n  title: string\n  children: React.ReactNode\n}\n\nexport function Modal({ isOpen, onClose, title, children }: ModalProps) {\n  const overlayRef = useRef<HTMLDivElement>(null)\n\n  useEffect(() => {\n    const handleEscape = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose()\n    }\n\n    if (isOpen) {\n      document.addEventListener('keydown', handleEscape)\n      document.body.style.overflow = 'hidden'\n    }\n\n    return () => {\n      document.removeEventListener('keydown', handleEscape)\n      document.body.style.overflow = 'unset'\n    }\n  }, [isOpen, onClose])\n\n  if (!isOpen) return null\n\n  const handleOverlayClick = (e: React.MouseEvent) => {\n    if (e.target === overlayRef.current) {\n      onClose()\n    }\n  }\n\n  return createPortal(\n    <div \n      className=\"fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200\"\n      onClick={handleOverlayClick}\n      ref={overlayRef}\n    >\n      <div className=\"bg-slate-900 border border-slate-800 rounded-xl shadow-2xl max-w-md w-full overflow-hidden animate-in zoom-in-95 duration-200\">\n        <div className=\"flex items-center justify-between p-4 border-b border-slate-800\">\n          <h3 className=\"text-lg font-semibold text-white\">{title}</h3>\n          <button \n            onClick={onClose}\n            className=\"text-slate-400 hover:text-white transition-colors rounded-lg p-1 hover:bg-slate-800\"\n          >\n            <XMarkIcon className=\"w-5 h-5\" />\n          </button>\n        </div>\n        <div className=\"p-6\">\n          {children}\n        </div>\n      </div>\n    </div>,\n    document.body\n  )\n}\n\ninterface ConfirmModalProps {\n  isOpen: boolean\n  onClose: () => void\n  onConfirm: () => void\n  title: string\n  message: string\n  confirmText?: string\n  cancelText?: string\n  isDangerous?: boolean\n}\n\nexport function ConfirmModal({ \n  isOpen, \n  onClose, \n  onConfirm, \n  title, \n  message, \n  confirmText = 'Confirm', \n  cancelText = 'Cancel',\n  isDangerous = false\n}: ConfirmModalProps) {\n  return (\n    <Modal isOpen={isOpen} onClose={onClose} title={title}>\n      <p className=\"text-slate-300 mb-6\">\n        {message}\n      </p>\n      <div className=\"flex items-center justify-end gap-3\">\n        <button\n          onClick={onClose}\n          className=\"px-4 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800 rounded-lg transition-colors\"\n        >\n          {cancelText}\n        </button>\n        <button\n          onClick={() => {\n            onConfirm()\n            onClose()\n          }}\n          className={`px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors ${\n            isDangerous \n              ? 'bg-red-600 hover:bg-red-700' \n              : 'bg-indigo-600 hover:bg-indigo-700'\n          }`}\n        >\n          {confirmText}\n        </button>\n      </div>\n    </Modal>\n  )\n}\n\ninterface AlertModalProps {\n  isOpen: boolean\n  onClose: () => void\n  title: string\n  message: string\n}\n\nexport function AlertModal({ isOpen, onClose, title, message }: AlertModalProps) {\n  return (\n    <Modal isOpen={isOpen} onClose={onClose} title={title}>\n      <p className=\"text-slate-300 mb-6\">\n        {message}\n      </p>\n      <div className=\"flex items-center justify-end\">\n        <button\n          onClick={onClose}\n          className=\"px-4 py-2 text-sm font-medium text-white bg-slate-800 hover:bg-slate-700 rounded-lg transition-colors\"\n        >\n          OK\n        </button>\n      </div>\n    </Modal>\n  )\n}\n"
  },
  {
    "path": "web/src/components/PageHeader.tsx",
    "content": "import { createContext, PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'\n\ntype PageHeaderContextValue = {\n    title: string\n    setTitle: (title: string) => void\n}\n\nconst PageHeaderContext = createContext<PageHeaderContextValue | undefined>(undefined)\n\nexport function PageHeaderProvider({ children }: PropsWithChildren) {\n    const [title, setTitle] = useState<string>('')\n    const value = useMemo(() => ({ title, setTitle }), [title])\n\n    useEffect(() => {\n        document.title = title ? `${title} | Port Buddy` : 'Port Buddy'\n    }, [title])\n\n    return (\n        <PageHeaderContext.Provider value={value}>\n            {children}\n        </PageHeaderContext.Provider>\n    )\n}\n\nexport function usePageHeader() {\n    const ctx = useContext(PageHeaderContext)\n    if (!ctx) throw new Error('usePageHeader must be used within PageHeaderProvider')\n    return ctx\n}\n\nexport function usePageTitle(title: string) {\n    const { setTitle } = usePageHeader()\n    useEffect(() => {\n        setTitle(title)\n    }, [setTitle, title])\n}\n"
  },
  {
    "path": "web/src/components/PlanComparison.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport { CheckIcon, XMarkIcon } from '@heroicons/react/24/outline'\nimport { Link } from 'react-router-dom'\n\nconst comparisonData = [\n  { feature: 'HTTP Tunnels', pro: true, team: true },\n  { feature: 'TCP Tunnels', pro: true, team: true },\n  { feature: 'UDP Tunnels', pro: true, team: true },\n  { feature: 'SSL for HTTP', pro: true, team: true },\n  { feature: 'Static Subdomains', pro: true, team: true },\n  { feature: 'Custom Domains', pro: true, team: true },\n  { feature: 'Private Tunnels', pro: true, team: true },\n  { feature: 'Web Socket Support', pro: true, team: true },\n  { feature: 'Free Tunnels', pro: '1 tunnel', team: '10 tunnels' },\n  { feature: 'Extra Tunnels', pro: '$1/mo each', team: '$1/mo each' },\n  { feature: 'Team Members', pro: false, team: true },\n  { feature: 'SSO', pro: false, team: 'Coming soon' },\n  { feature: 'Support', pro: 'Community', team: 'Priority' },\n]\n\nexport default function PlanComparison() {\n  return (\n    <div className=\"mt-12 mb-16\">\n      \n      {/* Pricing Cards */}\n      <div className=\"grid md:grid-cols-2 gap-8 max-w-4xl mx-auto mb-24\">\n        {/* Pro Plan */}\n        <div className=\"relative p-8 rounded-3xl bg-white/[0.02] border border-white/5 flex flex-col hover:border-white/10 transition-colors\">\n          <div className=\"mb-8\">\n            <h3 className=\"text-lg font-medium text-slate-400 mb-2\">Pro</h3>\n            <div className=\"flex items-baseline gap-1\">\n              <span className=\"text-4xl font-bold text-white\">$0</span>\n              <span className=\"text-slate-500\">/month</span>\n            </div>\n            <p className=\"text-slate-400 text-sm mt-4 leading-relaxed\">\n              Perfect for hobbyists and individual developers working on side projects.\n            </p>\n          </div>\n          <div className=\"space-y-4 mb-8 flex-1\">\n            <div className=\"flex gap-3 text-sm text-slate-300\">\n              <CheckIcon className=\"w-5 h-5 text-indigo-400 flex-shrink-0\" />\n              <span>1 free tunnel included</span>\n            </div>\n            <div className=\"flex gap-3 text-sm text-slate-300\">\n              <CheckIcon className=\"w-5 h-5 text-indigo-400 flex-shrink-0\" />\n              <span>Unlimited custom domains</span>\n            </div>\n            <div className=\"flex gap-3 text-sm text-slate-300\">\n              <CheckIcon className=\"w-5 h-5 text-indigo-400 flex-shrink-0\" />\n              <span>HTTP, TCP & UDP support</span>\n            </div>\n             <div className=\"flex gap-3 text-sm text-slate-300\">\n              <CheckIcon className=\"w-5 h-5 text-indigo-400 flex-shrink-0\" />\n              <span>$1/mo per extra tunnel</span>\n            </div>\n          </div>\n          <Link to=\"/login\" className=\"btn w-full justify-center glass hover:bg-white/10\">\n            Get Started\n          </Link>\n        </div>\n\n        {/* Team Plan */}\n        <div className=\"relative p-8 rounded-3xl bg-gradient-to-b from-indigo-500/10 to-purple-500/5 border border-indigo-500/20 flex flex-col shadow-[0_0_50px_-20px_rgba(99,102,241,0.3)]\">\n          <div className=\"absolute -top-4 left-1/2 -translate-x-1/2 px-3 py-1 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full text-[10px] font-bold uppercase tracking-wider text-white shadow-lg\">\n            Most Popular\n          </div>\n          <div className=\"mb-8\">\n            <h3 className=\"text-lg font-medium text-indigo-300 mb-2\">Team</h3>\n            <div className=\"flex items-baseline gap-1\">\n              <span className=\"text-4xl font-bold text-white\">$10</span>\n              <span className=\"text-slate-500\">/month</span>\n            </div>\n             <p className=\"text-indigo-200/60 text-sm mt-4 leading-relaxed\">\n              For growing teams that need more resources and collaboration features.\n            </p>\n          </div>\n          <div className=\"space-y-4 mb-8 flex-1\">\n             <div className=\"flex gap-3 text-sm text-slate-300\">\n              <CheckIcon className=\"w-5 h-5 text-indigo-400 flex-shrink-0\" />\n              <span><strong className=\"text-white\">10 free tunnels</strong> included</span>\n            </div>\n            <div className=\"flex gap-3 text-sm text-slate-300\">\n              <CheckIcon className=\"w-5 h-5 text-indigo-400 flex-shrink-0\" />\n              <span>Team member management</span>\n            </div>\n            <div className=\"flex gap-3 text-sm text-slate-300\">\n              <CheckIcon className=\"w-5 h-5 text-indigo-400 flex-shrink-0\" />\n              <span>Priority email support</span>\n            </div>\n             <div className=\"flex gap-3 text-sm text-slate-300\">\n              <CheckIcon className=\"w-5 h-5 text-indigo-400 flex-shrink-0\" />\n              <span>$1/mo per extra tunnel</span>\n            </div>\n          </div>\n          <Link to=\"/login\" className=\"btn btn-primary w-full justify-center\">\n            Upgrade to Team\n          </Link>\n        </div>\n      </div>\n\n      <div className=\"text-center mb-16\">\n        <h2 className=\"text-4xl font-black text-white mb-4 tracking-tight\">Detailed Comparison</h2>\n        <p className=\"text-slate-400 text-lg\">Choose the plan that fits your development needs.</p>\n      </div>\n      \n      <div className=\"overflow-x-auto glass rounded-3xl border border-white/5 p-4 md:p-8\">\n        <table className=\"w-full text-left border-collapse\">\n          <thead>\n            <tr className=\"border-b border-white/5\">\n              <th className=\"py-8 px-6 text-slate-500 font-bold uppercase tracking-widest text-xs\">Feature</th>\n              <th className=\"py-8 px-6 text-white font-black text-center w-1/4 text-2xl tracking-tighter\">Pro</th>\n              <th className=\"py-8 px-6 text-jb-blue font-black text-center w-1/4 text-2xl tracking-tighter\">Team</th>\n            </tr>\n          </thead>\n          <tbody className=\"divide-y divide-white/[0.02]\">\n            {comparisonData.map((item, idx) => (\n              <tr key={idx} className=\"group hover:bg-white/[0.02] transition-colors\">\n                <td className=\"py-5 px-6 text-slate-300 font-medium group-hover:text-white transition-colors\">{item.feature}</td>\n                <td className=\"py-5 px-6 text-center\">\n                  {typeof item.pro === 'boolean' ? (\n                    item.pro ? <CheckIcon className=\"w-6 h-6 text-green-500 mx-auto\" /> : <XMarkIcon className=\"w-6 h-6 text-slate-700 mx-auto\" />\n                  ) : (\n                    <span className=\"text-sm text-slate-400 font-mono\">{item.pro}</span>\n                  )}\n                </td>\n                <td className=\"py-5 px-6 text-center\">\n                  {typeof item.team === 'boolean' ? (\n                    item.team ? <CheckIcon className=\"w-6 h-6 text-jb-blue mx-auto\" /> : <XMarkIcon className=\"w-6 h-6 text-slate-700 mx-auto\" />\n                  ) : (\n                    <span className=\"text-sm text-jb-blue font-bold font-mono\">{item.team}</span>\n                  )}\n                </td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/components/ProgressBar.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport React, { useEffect } from 'react';\nimport { useLoading } from './LoadingContext';\n\nconst ProgressBar: React.FC = () => {\n  const { isLoading } = useLoading();\n\n  if (!isLoading) return null;\n\n  return (\n    <div className=\"fixed top-0 left-0 right-0 z-[9999] h-1 overflow-hidden bg-transparent\">\n      <div className=\"h-full bg-indigo-500 animate-progress origin-left\"></div>\n    </div>\n  );\n};\n\nexport default ProgressBar;\n"
  },
  {
    "path": "web/src/components/ProtectedRoute.tsx",
    "content": "import React from 'react'\nimport { Navigate, useLocation } from 'react-router-dom'\nimport { useAuth } from '../auth/AuthContext'\n\nexport default function ProtectedRoute({ children, role }: { children: React.ReactNode, role?: string }) {\n    const { user, loading } = useAuth()\n    const location = useLocation()\n\n    if (loading) {\n        return (\n            <div className=\"min-h-screen flex bg-slate-950\">\n                {/* Sidebar shell */}\n                <aside className=\"fixed top-0 left-0 h-screen w-64 border-r border-slate-800 bg-slate-900\">\n                    <div className=\"h-full flex flex-col\">\n                        <div className=\"border-b border-slate-800 px-6 py-5\">\n                            <div className=\"flex items-center gap-3 text-lg font-bold text-white/50\">\n                                <span className=\"h-3 w-3 rounded-full bg-slate-700\"></span>\n                                Port Buddy\n                            </div>\n                        </div>\n                    </div>\n                </aside>\n                {/* Main shell */}\n                <section className=\"flex-1 lg:ml-64 bg-slate-950 flex flex-col\">\n                    <div className=\"h-16 border-b border-slate-800\"></div>\n                    <div className=\"p-8 flex-1\">\n                        <div className=\"max-w-4xl h-64 bg-slate-900/50 rounded-2xl animate-pulse\"></div>\n                    </div>\n                </section>\n            </div>\n        )\n    }\n    if (!user) {\n        return <Navigate to=\"/login\" replace state={{ from: location }} />\n    }\n    if (role && !user.roles?.includes(role)) {\n        return <Navigate to=\"/app\" replace />\n    }\n    return <>{children}</>\n}\n"
  },
  {
    "path": "web/src/index.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n:root {\n  color-scheme: dark;\n}\n\nhtml, body, #root { height: 100%; }\nbody {\n    margin: 0;\n    font-family: \"JetBrains Mono\", ui-monospace, SFMono-Regular, Menlo, monospace;\n    font-optical-sizing: auto;\n    font-weight: 300;\n    font-style: normal;\n    background: #0b1020;\n    color: #e2e8f0;\n    background-image: \n        radial-gradient(at 0% 0%, rgba(204, 51, 255, 0.05) 0px, transparent 50%),\n        radial-gradient(at 100% 100%, rgba(34, 211, 238, 0.05) 0px, transparent 50%);\n    background-attachment: fixed;\n}\n\na { color: #67e8f9; text-decoration: none; transition: color 0.2s; }\na:hover { color: #22d3ee; text-decoration: none; }\n\n.container { @apply max-w-7xl mx-auto px-6; }\n.btn { @apply inline-flex items-center gap-2 bg-slate-800 border border-slate-700 px-6 py-2.5 rounded-lg hover:bg-slate-700 hover:border-slate-600 transition-all font-medium; }\n.btn-primary { @apply bg-indigo-600 border-indigo-500 text-white hover:bg-indigo-500 shadow-lg shadow-indigo-500/20; }\n.badge { @apply text-[10px] uppercase tracking-wider font-bold border border-slate-700 bg-slate-800/50 rounded-full px-2.5 py-0.5 text-slate-400; }\n\n.glass {\n    @apply bg-slate-900/40 backdrop-blur-md border border-white/5;\n}\n\n.text-gradient {\n    @apply text-transparent bg-clip-text bg-gradient-to-r from-jb-blue via-jb-purple to-jb-pink;\n}\n\n.jb-border {\n    position: relative;\n}\n\n.jb-border::after {\n    content: '';\n    position: absolute;\n    bottom: -1px;\n    left: 0;\n    right: 0;\n    height: 1px;\n    background: linear-gradient(90deg, transparent, #cc33ff, #ff3399, #ff9933, transparent);\n    opacity: 0.5;\n}\n"
  },
  {
    "path": "web/src/lib/api.ts",
    "content": "// Centralized API client that attaches Authorization: Bearer <JWT>\n// and avoids sending cookies. Works in dev and prod using VITE_API_BASE.\n\nlet startLoadingCallback: (() => void) | null = null;\nlet stopLoadingCallback: (() => void) | null = null;\n\nexport function setLoadingCallbacks(start: () => void, stop: () => void) {\n    startLoadingCallback = start;\n    stopLoadingCallback = stop;\n}\n\nfunction startLoading() {\n    if (startLoadingCallback) startLoadingCallback();\n}\n\nfunction stopLoading() {\n    if (stopLoadingCallback) stopLoadingCallback();\n}\n\nexport const API_BASE: string = (() => {\n    const env = (import.meta as any).env?.VITE_API_BASE?.toString()\n    if (env) return env\n    if (window.location.hostname === 'localhost' && window.location.port === '5173') {\n        return 'http://localhost:8080'\n    }\n    return '' // same-origin in production\n})()\n\nexport function getToken(): string | null {\n    try {\n        return localStorage.getItem('pb_token')\n    } catch {\n        return null\n    }\n}\n\nfunction redirectToLogin(withFrom: boolean = true): void {\n    try {\n        localStorage.removeItem('pb_token')\n    } catch {\n        // ignore\n    }\n\n    const { pathname, search, hash } = window.location\n    const here = `${pathname}${search}${hash}`\n    const onLogin = pathname.startsWith('/login') || pathname.startsWith('/auth/callback')\n\n    // Prevent multi-trigger redirects\n    const flag = '__pbRedirectingToLogin'\n    if ((window as any)[flag]) return\n    ;(window as any)[flag] = true\n\n    // If we are already on a login-related page, do not navigate again to avoid reload loops\n    if (onLogin) {\n        return\n    }\n\n    const to = !withFrom\n        ? '/login'\n        : `/login?from=${encodeURIComponent(here)}`\n    // Use assign to create a fresh navigation (clears any stale protected UI)\n    window.location.assign(to)\n}\n\nfunction withAuth(init?: RequestInit, skipToken: boolean = false): RequestInit {\n    const token = !skipToken ? getToken() : null\n    const headers: Record<string, string> = {\n        ...(init?.headers as Record<string, string> | undefined),\n    }\n    if (token) {\n        headers['Authorization'] = `Bearer ${token}`\n    }\n    return {\n        ...init,\n        // Explicitly avoid sending cookies for stateless JWT API\n        credentials: 'omit',\n        headers,\n    }\n}\n\nexport async function apiJson<T = any>(path: string, init?: RequestInit, options?: { skipRedirectOn401?: boolean, skipAuth?: boolean }): Promise<T> {\n    startLoading()\n    try {\n        const res = await fetch(`${API_BASE}${path}`, withAuth({\n            headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },\n            ...init,\n        }, options?.skipAuth))\n        if (res.status === 401) {\n            if (!options?.skipRedirectOn401) {\n                redirectToLogin(true)\n            }\n            // Throw to stop any further processing by callers\n            const err: any = new Error('Unauthorized')\n            err.status = 401\n            throw err\n        }\n        if (!res.ok) {\n            let errorMessage = `HTTP ${res.status}`\n            try {\n                const text = await res.text()\n                if (text) {\n                    try {\n                        const data = JSON.parse(text)\n                        if (data && typeof data === 'object') {\n                            if (data.detail) {\n                                errorMessage = data.detail\n                            } else if (data.title) {\n                                errorMessage = data.title\n                            } else if (data.message) {\n                                errorMessage = data.message\n                            } else {\n                                errorMessage = text\n                            }\n                        } else {\n                            errorMessage = text\n                        }\n                    } catch {\n                        errorMessage = text\n                    }\n                }\n            } catch {\n                // ignore\n            }\n\n            const err: any = new Error(errorMessage)\n            err.status = res.status\n            throw err\n        }\n        // 204 No Content has no body\n        if (res.status === 204) return undefined as unknown as T\n        return res.json() as Promise<T>\n    } finally {\n        stopLoading()\n    }\n}\n\nexport async function apiRaw(path: string, init?: RequestInit): Promise<Response> {\n    startLoading()\n    try {\n        const res = await fetch(`${API_BASE}${path}`, withAuth(init))\n        if (res.status === 401) {\n            redirectToLogin(true)\n            // Throw to ensure callers do not proceed under unauthorized state\n            const err: any = new Error('Unauthorized')\n            err.status = 401\n            throw err\n        }\n        return res\n    } finally {\n        stopLoading()\n    }\n}\n"
  },
  {
    "path": "web/src/lib/utils.ts",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\nexport function formatDateTime(dateString: string): string {\n  const date = new Date(dateString)\n  if (isNaN(date.getTime())) return dateString\n\n  const dd = String(date.getDate()).padStart(2, '0')\n  const MM = String(date.getMonth() + 1).padStart(2, '0')\n  const yy = String(date.getFullYear()).slice(-2)\n  const hh = String(date.getHours()).padStart(2, '0')\n  const mm = String(date.getMinutes()).padStart(2, '0')\n\n  return `${dd}.${MM}.${yy} ${hh}:${mm}`\n}\n"
  },
  {
    "path": "web/src/main.tsx",
    "content": "import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport { BrowserRouter } from 'react-router-dom'\nimport App from './App'\nimport './index.css'\nimport { AuthProvider } from './auth/AuthContext'\nimport { LoadingProvider } from './components/LoadingContext'\n\nconst rootElement = document.getElementById('root')!;\nconst app = (\n    <LoadingProvider>\n      <AuthProvider>\n        <BrowserRouter>\n          <App />\n        </BrowserRouter>\n      </AuthProvider>\n    </LoadingProvider>\n);\n\nconst prerenderedRoute = rootElement.getAttribute('data-prerendered-route');\nconst currentRoute = window.location.pathname.replace(/\\/$/, '') || '/';\nconst shouldHydrate = rootElement.hasChildNodes() && (\n  prerenderedRoute === currentRoute || \n  (currentRoute === '/' && prerenderedRoute === '/index') ||\n  (currentRoute === '/index' && prerenderedRoute === '/')\n);\n\nif (shouldHydrate) {\n  ReactDOM.hydrateRoot(rootElement, app);\n} else {\n  ReactDOM.createRoot(rootElement).render(app);\n}\n"
  },
  {
    "path": "web/src/pages/AcceptInvite.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport { useEffect, useState } from 'react'\nimport { useNavigate, useSearchParams } from 'react-router-dom'\nimport { apiJson } from '../lib/api'\nimport { useAuth } from '../auth/AuthContext'\nimport { CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline'\n\nexport default function AcceptInvite() {\n  const [searchParams] = useSearchParams()\n  const token = searchParams.get('token')\n  const { user, loading, refresh } = useAuth()\n  const navigate = useNavigate()\n  \n  const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'unauthenticated'>('loading')\n  const [error, setError] = useState<string | null>(null)\n\n  useEffect(() => {\n    if (loading) {\n      return\n    }\n\n    if (!token) {\n      setStatus('error')\n      setError('Missing invitation token.')\n      return\n    }\n\n    if (!user) {\n      setStatus('unauthenticated')\n      return\n    }\n\n    void handleAccept()\n  }, [token, user, loading])\n\n  async function handleAccept() {\n    setStatus('loading')\n    try {\n      await apiJson(`/api/team/accept?token=${token}`, { method: 'POST' })\n      setStatus('success')\n      await refresh()\n      setTimeout(() => navigate('/app/team'), 3000)\n    } catch (err: any) {\n      setStatus('error')\n      setError(err.message || 'Failed to accept invitation.')\n    }\n  }\n\n  if (status === 'unauthenticated') {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-slate-950 px-4\">\n        <div className=\"max-w-md w-full bg-slate-900 border border-slate-800 rounded-2xl p-8 text-center\">\n          <h2 className=\"text-2xl font-bold text-white mb-4\">Login required</h2>\n          <p className=\"text-slate-400 mb-8\">\n            You must be logged in to accept a team invitation.\n          </p>\n          <button\n            onClick={() => navigate(`/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`)}\n            className=\"w-full py-3 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl font-bold transition-all\"\n          >\n            Login to Accept\n          </button>\n        </div>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center bg-slate-950 px-4\">\n      <div className=\"max-w-md w-full bg-slate-900 border border-slate-800 rounded-2xl p-8 text-center shadow-2xl\">\n        {status === 'loading' && (\n          <div className=\"flex flex-col items-center\">\n            <div className=\"w-12 h-12 border-4 border-indigo-500/30 border-t-indigo-500 rounded-full animate-spin mb-4\"></div>\n            <h2 className=\"text-xl font-bold text-white\">Accepting invitation...</h2>\n          </div>\n        )}\n\n        {status === 'success' && (\n          <div className=\"flex flex-col items-center\">\n            <CheckCircleIcon className=\"w-16 h-16 text-emerald-500 mb-4\" />\n            <h2 className=\"text-2xl font-bold text-white mb-2\">Welcome to the team!</h2>\n            <p className=\"text-slate-400\">\n              You've successfully joined the team. Redirecting you to the dashboard...\n            </p>\n          </div>\n        )}\n\n        {status === 'error' && (\n          <div className=\"flex flex-col items-center\">\n            <XCircleIcon className=\"w-16 h-16 text-red-500 mb-4\" />\n            <h2 className=\"text-2xl font-bold text-white mb-2\">Invitation failed</h2>\n            <p className=\"text-red-400 mb-8\">{error}</p>\n            <button\n              onClick={() => navigate('/app')}\n              className=\"w-full py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-xl font-bold transition-all\"\n            >\n              Go to Dashboard\n            </button>\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/Contacts.tsx",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\nexport default function Contacts() {\n  return (\n    <div className=\"min-h-screen flex flex-col\">\n      <div className=\"flex-1 relative pt-12 md:pt-32 pb-20\">\n        <div className=\"absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-900/10 via-slate-900/0 to-slate-900/0 pointer-events-none\" />\n        \n        <div className=\"container max-w-3xl relative z-10\">\n          <h1 className=\"text-4xl font-bold text-white mb-8\">Contact Us</h1>\n          \n          <div className=\"prose prose-invert max-w-none text-slate-300 space-y-12\">\n            <p className=\"text-lg\">\n              Have questions, need assistance, or want to report an issue? We're here to help. \n              Reach out to us via the appropriate email address below.\n            </p>\n\n            <div className=\"grid gap-8\">\n              <section className=\"bg-slate-900/50 border border-slate-800 rounded-2xl p-6 hover:border-indigo-500/50 transition-colors\">\n                <h2 className=\"text-2xl font-semibold text-white mb-3 flex items-center gap-2\">\n                  Support\n                </h2>\n                <p className=\"mb-4\">\n                  For any technical questions, issues with your account, subscription inquiries, or general help using Port Buddy.\n                </p>\n                <a \n                  href=\"mailto:support@portbuddy.dev\" \n                  className=\"text-indigo-400 hover:text-indigo-300 font-medium text-lg\"\n                >\n                  support@portbuddy.dev\n                </a>\n              </section>\n\n              <section className=\"bg-slate-900/50 border border-slate-800 rounded-2xl p-6 hover:border-red-500/50 transition-colors\">\n                <h2 className=\"text-2xl font-semibold text-white mb-3 flex items-center gap-2\">\n                  Abuse\n                </h2>\n                <p className=\"mb-4\">\n                  To report any misuse of our service, phishing attempts, or content that violates our terms of service.\n                </p>\n                <a \n                  href=\"mailto:abuse@portbuddy.dev\" \n                  className=\"text-indigo-400 hover:text-indigo-300 font-medium text-lg\"\n                >\n                  abuse@portbuddy.dev\n                </a>\n              </section>\n            </div>\n\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">Other Ways to Connect</h2>\n              <p>\n                You can also find us on our community channels:\n              </p>\n              <ul className=\"list-disc pl-6 space-y-2\">\n                <li>\n                  <a href=\"https://discord.gg/PaEzzjmvSH\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-indigo-400 hover:underline\">Discord</a> - Join our community for real-time help and discussions.\n                </li>\n                <li>\n                  <a href=\"https://t.me/portbuddy\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-indigo-400 hover:underline\">Telegram</a> - Follow us for updates and news.\n                </li>\n                <li>\n                  <a href=\"https://github.com/amak-tech/port-buddy\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-indigo-400 hover:underline\">GitHub</a> - Report bugs or contribute to the project.\n                </li>\n              </ul>\n            </section>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/ForgotPassword.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport { useState } from 'react'\nimport { Link } from 'react-router-dom'\nimport { ArrowLeftIcon } from '@heroicons/react/24/outline'\nimport { apiJson } from '../lib/api'\n\nexport default function ForgotPassword() {\n  const [email, setEmail] = useState('')\n  const [submitting, setSubmitting] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n  const [success, setSuccess] = useState(false)\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault()\n    setError(null)\n    setSubmitting(true)\n    try {\n      await apiJson('/api/auth/password-reset/request', {\n        method: 'POST',\n        body: JSON.stringify({ email })\n      }, { skipAuth: true })\n      setSuccess(true)\n    } catch (err: any) {\n      setError(err.message || 'Failed to request password reset')\n    } finally {\n      setSubmitting(false)\n    }\n  }\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center relative overflow-hidden\">\n      {/* Background gradients */}\n      <div className=\"absolute inset-0 bg-slate-950\"></div>\n      <div className=\"absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-indigo-900/20 via-slate-900/0 to-slate-900/0 pointer-events-none\" />\n      \n      <div className=\"w-full max-w-md p-6 relative z-10\">\n        <div className=\"bg-slate-900/50 border border-slate-800 rounded-2xl p-8 shadow-2xl backdrop-blur-sm\">\n          <div className=\"text-center mb-8\">\n            <Link to=\"/\" className=\"inline-block mb-6\">\n              <div className=\"flex items-center justify-center gap-2 text-xl font-bold text-white\">\n                <span className=\"relative flex h-3 w-3\">\n                   <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75\"></span>\n                   <span className=\"relative inline-flex rounded-full h-3 w-3 bg-indigo-500\"></span>\n                </span>\n                Port Buddy\n              </div>\n            </Link>\n            <h1 className=\"text-2xl font-bold text-white mb-2\">Reset Password</h1>\n            <p className=\"text-slate-400 text-sm\">\n              Enter your email to receive a reset link.\n            </p>\n          </div>\n\n          {success ? (\n            <div className=\"text-center space-y-6\">\n               <div className=\"bg-green-500/10 border border-green-500/50 text-green-400 p-4 rounded-lg\">\n                  A link to reset password is sent to your email.\n               </div>\n               <Link to=\"/login\" className=\"block w-full bg-indigo-600 hover:bg-indigo-500 text-white font-medium py-2.5 px-4 rounded-lg transition-all\">\n                 Back to Login\n               </Link>\n            </div>\n          ) : (\n            <form onSubmit={handleSubmit} className=\"space-y-4 animate-in slide-in-from-top-2 fade-in duration-200\">\n              {error && (\n                <div className=\"bg-red-500/10 border border-red-500/50 text-red-400 text-sm p-3 rounded-lg text-center\">\n                  {error}\n                </div>\n              )}\n              <div>\n                <label className=\"block text-xs font-medium text-slate-400 mb-1\">Email</label>\n                <input\n                  type=\"email\"\n                  value={email}\n                  onChange={(e) => setEmail(e.target.value)}\n                  required\n                  className=\"w-full bg-slate-950/50 border border-slate-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all\"\n                  placeholder=\"name@example.com\"\n                />\n              </div>\n              <button\n                type=\"submit\"\n                disabled={submitting}\n                className=\"w-full bg-indigo-600 hover:bg-indigo-500 text-white font-medium py-2.5 px-4 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-indigo-500/20\"\n              >\n                Send reset link\n              </button>\n            </form>\n          )}\n\n          <div className=\"mt-8 pt-6 border-t border-slate-800 text-center\">\n             <Link to=\"/login\" className=\"inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors group\">\n              <ArrowLeftIcon className=\"w-4 h-4 group-hover:-translate-x-1 transition-transform\" />\n              Back to Login\n            </Link>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/Installation.tsx",
    "content": "import { useState } from 'react'\nimport { Link } from 'react-router-dom'\nimport { \n  CommandLineIcon,\n  ClipboardDocumentIcon, \n  CheckIcon,\n  ComputerDesktopIcon\n} from '@heroicons/react/24/outline'\n\nexport default function Installation() {\n  const [activeTab, setActiveTab] = useState<'macos' | 'linux' | 'windows' | 'docker'>('macos')\n\n  return (\n    <div className=\"min-h-screen flex flex-col\">\n      <div className=\"flex-1 relative pt-12 pb-12 md:pb-20\">\n        {/* Background gradients */}\n        <div className=\"absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-900/20 via-slate-900/0 to-slate-900/0 pointer-events-none\" />\n        \n        <div className=\"container max-w-4xl relative z-10\">\n          <div className=\"text-center mb-12\">\n            <div className=\"inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 mb-6\">\n              <CommandLineIcon className=\"w-8 h-8\" />\n            </div>\n            <h1 className=\"text-4xl font-bold text-white mb-4\">Install Port Buddy CLI</h1>\n            <p className=\"text-slate-400 text-lg max-w-2xl mx-auto\">\n              Get up and running in seconds. Our CLI is a single static binary with zero dependencies.\n            </p>\n          </div>\n\n          <div className=\"bg-slate-900/50 border border-slate-800 rounded-2xl overflow-hidden shadow-2xl\">\n            {/* Tab Navigation */}\n            <div className=\"flex border-b border-slate-800 overflow-x-auto\">\n              <TabButton \n                isActive={activeTab === 'macos'} \n                onClick={() => setActiveTab('macos')} \n                label=\"macOS\"\n              />\n              <TabButton \n                isActive={activeTab === 'linux'} \n                onClick={() => setActiveTab('linux')} \n                label=\"Linux\"\n              />\n              <TabButton \n                isActive={activeTab === 'windows'} \n                onClick={() => setActiveTab('windows')} \n                label=\"Windows\"\n              />\n              <TabButton \n                isActive={activeTab === 'docker'} \n                onClick={() => setActiveTab('docker')} \n                label=\"Docker\"\n              />\n            </div>\n\n            {/* Content Area */}\n            <div className=\"p-4 md:p-8 min-h-[400px]\">\n              {activeTab === 'macos' && (\n                <div className=\"space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500\">\n                  <Step \n                    title=\"Install via Homebrew\"\n                    description=\"The easiest way to install on macOS.\"\n                  >\n                    <CodeBlock code={`brew install amak-tech/tap/portbuddy`} />\n                  </Step>\n                  <Step \n                    title=\"Expose your local node.js app\"\n                    description=\"This command will create a secured HTTP tunnel for localhost:3000\"\n                  >\n                    <CodeBlock code=\"portbuddy 3000\" />\n                  </Step>\n                </div>\n              )}\n\n              {activeTab === 'linux' && (\n                <div className=\"space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500\">\n                  <Step\n                      title=\"Install via Script\"\n                      description=\"The fastest way to install Port Buddy on Linux. Automatically detects your architecture.\"\n                  >\n                    <CodeBlock code={`curl -sSL https://portbuddy.dev/install.sh | sudo bash`} />\n                  </Step>\n                  <Step \n                    title=\"Install via Homebrew\"\n                    description=\"Recommended for Linux users who use Homebrew.\"\n                  >\n                    <CodeBlock code={`brew install amak-tech/tap/portbuddy`} />\n                  </Step>\n                  <Step\n                    title=\"Share access to your local PostgreSQL DB\"\n                    description=\"This command will create a TCP tunnel for localhost:5432\"\n                  >\n                    <CodeBlock code=\"portbuddy tcp 5432\" />\n                  </Step>\n                  <div className=\"pt-2\">\n                    <p className=\"text-slate-400\">\n                      Need to run in background? <Link to=\"/docs#run-as-service\" className=\"text-indigo-400 hover:text-indigo-300 transition-colors\">Run as a Service guide &rarr;</Link>\n                    </p>\n                  </div>\n                </div>\n              )}\n\n              {activeTab === 'windows' && (\n                <div className=\"space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500\">\n                  <Step \n                    title=\"Install via PowerShell\"\n                    description=\"The fastest way to install Port Buddy on Windows. This script will download the executable and add it to your PATH.\"\n                  >\n                    <CodeBlock code={`iwr https://portbuddy.dev/install.ps1 -useb | iex`} />\n                  </Step>\n                  <Step \n                    title=\"Direct Download\"\n                    description=\"Download the executable manually and add it to your PATH.\"\n                  >\n                    <div className=\"flex flex-col gap-4\">\n                      <a \n                        href=\"https://github.com/amak-tech/port-buddy/releases/latest\" \n                        className=\"inline-flex items-center justify-center px-6 py-3 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors w-full md:w-auto\"\n                      >\n                        Download latest version\n                      </a>\n                    </div>\n                  </Step>\n                  <Step \n                    title=\"Quick Start\"\n                    description=\"Link the CLI to your account and expose your first port.\"\n                  >\n                    <CodeBlock code=\"portbuddy init YOUR_API_TOKEN\" />\n                  </Step>\n                  <div className=\"pt-2\">\n                    <p className=\"text-slate-400\">\n                      Need to run in background? <Link to=\"/docs#run-as-service\" className=\"text-indigo-400 hover:text-indigo-300 transition-colors\">Run as a Service guide &rarr;</Link>\n                    </p>\n                  </div>\n                </div>\n              )}\n\n              {activeTab === 'docker' && (\n                <div className=\"space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500\">\n                  <Step \n                    title=\"Pull Docker Image\"\n                    description=\"Get the official Port Buddy image from Docker Hub.\"\n                  >\n                    <CodeBlock code=\"docker pull portbuddy/portbuddy\" />\n                  </Step>\n                  <Step \n                    title=\"Authentication with Docker\"\n                    description=\"To use Port Buddy in Docker, mount your token file. The CLI expects it at /root/.port-buddy/token.\"\n                  >\n                    <div className=\"space-y-4\">\n                      <p className=\"text-slate-400 text-sm\">First, authenticate on your host machine:</p>\n                      <CodeBlock code=\"portbuddy init YOUR_API_TOKEN\" />\n                      <p className=\"text-slate-400 text-sm\">Then, run with the token mounted:</p>\n                      <CodeBlock code={`docker run -it --rm \\\\\n  -v ~/.port-buddy/token:/root/.port-buddy/token \\\\\n  --network host \\\\\n  portbuddy/portbuddy 3000`} />\n                    </div>\n                  </Step>\n                  <Step \n                    title=\"Mounting a Token File Directly\"\n                    description=\"If you have your API token in a file, you can mount it directly.\"\n                  >\n                    <CodeBlock code={`docker run -it --rm \\\\\n  -v $(pwd)/my_token.txt:/root/.port-buddy/token \\\\\n  --network host \\\\\n  portbuddy/portbuddy 3000`} />\n                  </Step>\n                </div>\n              )}\n            </div>\n          </div>\n\n          <div className=\"mt-12 grid md:grid-cols-2 gap-6\">\n            <PlanLimitCard \n              plan=\"Pro Plan\"\n              limit=\"1 free tunnel\"\n              description=\"Perfect for individual developers. $1/mo for each additional concurrent tunnel.\"\n              isPro\n            />\n            <PlanLimitCard \n              plan=\"Team Plan\"\n              limit=\"10 free tunnels\"\n              description=\"Built for teams and power users. $1/mo for each additional concurrent tunnel.\"\n            />\n          </div>\n\n          <div className=\"mt-12 grid md:grid-cols-3 gap-6\">\n            <InfoCard \n              title=\"Auto-Updates\" \n              description=\"The CLI automatically checks for updates and notifies you when a new version is available.\"\n            />\n            <InfoCard \n              title=\"Cross-Platform\" \n              description=\"Native binaries for macOS (Intel/Apple Silicon), Linux (x64/ARM), and Windows.\"\n            />\n            <InfoCard \n              title=\"Zero Config\" \n              description=\"Smart defaults mean you rarely need to touch a config file. It just works.\"\n            />\n          </div>\n\n        </div>\n      </div>\n    </div>\n  )\n}\n\nfunction TabButton({ isActive, onClick, label }: { isActive: boolean, onClick: () => void, label: string }) {\n  return (\n    <button\n      onClick={onClick}\n      className={`flex-1 px-6 py-4 text-sm font-medium transition-all relative ${\n        isActive \n          ? 'text-white bg-slate-800/50' \n          : 'text-slate-400 hover:text-white hover:bg-slate-800/30'\n      }`}\n    >\n      {label}\n      {isActive && (\n        <div className=\"absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-500\"></div>\n      )}\n    </button>\n  )\n}\n\nfunction Step({ title, description, children }: { title: string, description: string, children: React.ReactNode }) {\n  return (\n    <div>\n      <h3 className=\"text-lg font-semibold text-white mb-1\">{title}</h3>\n      <p className=\"text-slate-400 text-sm mb-4\">{description}</p>\n      {children}\n    </div>\n  )\n}\n\nfunction CodeBlock({ code }: { code: string }) {\n  const [copied, setCopied] = useState(false)\n\n  const copy = () => {\n    navigator.clipboard.writeText(code)\n    setCopied(true)\n    setTimeout(() => setCopied(false), 2000)\n  }\n\n  return (\n    <div className=\"relative group\">\n      <div className=\"absolute -inset-0.5 bg-gradient-to-r from-indigo-500/20 to-purple-500/20 rounded-lg blur opacity-0 group-hover:opacity-100 transition duration-500\"></div>\n      <div className=\"relative bg-slate-950 border border-slate-800 rounded-lg p-4 font-mono text-sm text-slate-300 overflow-x-auto\">\n        <pre>{code}</pre>\n        <button \n          onClick={copy}\n          className=\"absolute top-3 right-3 p-2 rounded-md bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-700 transition-all opacity-0 group-hover:opacity-100 focus:opacity-100\"\n          title=\"Copy to clipboard\"\n        >\n          {copied ? <CheckIcon className=\"w-4 h-4 text-green-400\" /> : <ClipboardDocumentIcon className=\"w-4 h-4\" />}\n        </button>\n      </div>\n    </div>\n  )\n}\n\nfunction InfoCard({ title, description }: { title: string, description: string }) {\n  return (\n    <div className=\"bg-slate-900/30 border border-slate-800 rounded-xl p-6\">\n      <h4 className=\"text-white font-medium mb-2 flex items-center gap-2\">\n        <ComputerDesktopIcon className=\"w-5 h-5 text-indigo-500\" />\n        {title}\n      </h4>\n      <p className=\"text-slate-400 text-sm leading-relaxed\">\n        {description}\n      </p>\n    </div>\n  )\n}\n\nfunction PlanLimitCard({ plan, limit, description, isPro }: { plan: string, limit: string, description: string, isPro?: boolean }) {\n  return (\n    <div className={`relative p-6 rounded-2xl border ${isPro ? 'border-indigo-500/30 bg-indigo-500/5' : 'border-slate-800 bg-slate-900/30'}`}>\n      <div className=\"flex justify-between items-start mb-4\">\n        <div>\n          <h4 className=\"text-lg font-bold text-white mb-1\">{plan}</h4>\n          <div className=\"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-500/10 text-indigo-400 border border-indigo-500/20\">\n            {limit}\n          </div>\n        </div>\n      </div>\n      <p className=\"text-slate-400 text-sm leading-relaxed\">\n        {description}\n      </p>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/Landing.tsx",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\nimport { Link } from 'react-router-dom'\nimport {\n  CommandLineIcon,\n  GlobeAltIcon,\n  ShieldCheckIcon,\n  ServerIcon,\n  BoltIcon,\n  LockClosedIcon,\n  CodeBracketIcon,\n  ArrowRightIcon,\n  CheckIcon,\n  UserIcon,\n  CloudIcon,\n  ComputerDesktopIcon,\n  ChatBubbleLeftRightIcon,\n  CpuChipIcon\n} from '@heroicons/react/24/outline'\nimport React, { useState, useEffect } from 'react'\nimport PlanComparison from '../components/PlanComparison'\n\n// --- Helper Components ---\n\nfunction FeatureCard({ icon, title, description }: { icon: React.ReactNode, title: string, description: string }) {\n  return (\n    <div className=\"p-8 rounded-2xl bg-white/5 border border-white/5 hover:border-white/10 hover:bg-white/[0.07] transition-all duration-300 group\">\n      <div className=\"mb-6 p-3 bg-slate-900 rounded-lg w-fit group-hover:scale-110 transition-transform duration-300 border border-white/5\">\n        {icon}\n      </div>\n      <h3 className=\"text-xl font-bold text-white mb-3 group-hover:text-indigo-300 transition-colors\">{title}</h3>\n      <p className=\"text-slate-400 leading-relaxed text-sm\">{description}</p>\n    </div>\n  )\n}\n\nfunction Step({ number, title, description }: { number: string, title: string, description: string }) {\n  return (\n    <div className=\"relative z-10 flex flex-col items-center text-center group\">\n      <div className=\"w-16 h-16 rounded-2xl bg-slate-900 border border-slate-700 flex items-center justify-center text-xl font-bold text-white mb-6 shadow-xl group-hover:border-indigo-500/50 group-hover:shadow-[0_0_30px_rgba(99,102,241,0.3)] transition-all duration-300\">\n        <span className=\"text-transparent bg-clip-text bg-gradient-to-br from-white to-slate-500\">{number}</span>\n      </div>\n      <h3 className=\"text-xl font-bold text-white mb-3\">{title}</h3>\n      <p className=\"text-slate-400 text-sm max-w-[200px] leading-relaxed\">{description}</p>\n    </div>\n  )\n}\n\nfunction StatCard({ label, value, icon }: { label: string, value: string, icon: React.ReactNode }) {\n  return (\n    <div className=\"flex flex-col items-center p-6 rounded-2xl bg-slate-900/50 border border-white/5 backdrop-blur-sm\">\n      <div className=\"mb-3 text-slate-500\">{icon}</div>\n      <div className=\"text-3xl md:text-4xl font-bold text-white mb-1 tracking-tight\">{value}</div>\n      <div className=\"text-sm font-medium text-slate-400 uppercase tracking-widest\">{label}</div>\n    </div>\n  )\n}\n\nfunction TestimonialCard({ quote, author, role }: { quote: string, author: string, role: string }) {\n  return (\n    <div className=\"p-8 rounded-2xl bg-gradient-to-b from-white/10 to-white/5 border border-white/10 backdrop-blur-sm flex flex-col h-full\">\n      <div className=\"mb-6\">\n        {[1, 2, 3, 4, 5].map((star) => (\n          <span key={star} className=\"text-yellow-400 text-lg\">★</span>\n        ))}\n      </div>\n      <p className=\"text-slate-300 text-lg mb-6 leading-relaxed flex-grow\">\"{quote}\"</p>\n      <div className=\"flex items-center gap-4 mt-auto\">\n        <div className=\"w-10 h-10 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-400 font-bold border border-indigo-500/30\">\n          {author.charAt(0)}\n        </div>\n        <div>\n          <div className=\"text-white font-bold text-sm\">{author}</div>\n          <div className=\"text-slate-500 text-xs uppercase tracking-wider\">{role}</div>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nfunction FaqItem({ question, answer }: { question: string, answer: string }) {\n  const [isOpen, setIsOpen] = useState(false)\n  return (\n    <div className=\"border-b border-white/10 last:border-0\">\n      <button \n        className=\"w-full flex items-center justify-between py-6 text-left focus:outline-none group\"\n        onClick={() => setIsOpen(!isOpen)}\n      >\n        <span className=\"text-lg font-medium text-slate-200 group-hover:text-white transition-colors\">{question}</span>\n        <span className={`ml-6 flex-shrink-0 transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`}>\n          <ChevronDownIcon className=\"w-5 h-5 text-slate-500 group-hover:text-indigo-400\" />\n        </span>\n      </button>\n      <div className={`overflow-hidden transition-all duration-300 ease-in-out ${isOpen ? 'max-h-96 opacity-100 pb-6' : 'max-h-0 opacity-0'}`}>\n        <p className=\"text-slate-400 leading-relaxed pr-12\">{answer}</p>\n      </div>\n    </div>\n  )\n}\n\nfunction ChevronDownIcon({ className }: { className?: string }) {\n  return (\n    <svg className={className} fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={2} stroke=\"currentColor\">\n      <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M19.5 8.25l-7.5 7.5-7.5-7.5\" />\n    </svg>\n  )\n}\n\nfunction TypewriterText() {\n  const words = [\"Localhost\", \"Databases\", \"Webhooks\", \"APIs\", \"Game Servers\", \"Everything\"]\n  const [index, setIndex] = useState(0)\n  const [subIndex, setSubIndex] = useState(0)\n  const [reverse, setReverse] = useState(false)\n  const [blink, setBlink] = useState(true)\n\n  // Blinking cursor\n  useEffect(() => {\n    const timeout2 = setTimeout(() => setBlink((prev) => !prev), 500)\n    return () => clearTimeout(timeout2)\n  }, [blink])\n\n  // Typewriter logic\n  useEffect(() => {\n    if (subIndex === words[index].length + 1 && !reverse) {\n      setTimeout(() => setReverse(true), 1000)\n      return\n    }\n\n    if (subIndex === 0 && reverse) {\n      setReverse(false)\n      setIndex((prev) => (prev + 1) % words.length)\n      return\n    }\n\n    const timeout = setTimeout(() => {\n      setSubIndex((prev) => prev + (reverse ? -1 : 1))\n    }, reverse ? 75 : 150)\n\n    return () => clearTimeout(timeout)\n  }, [subIndex, index, reverse, words])\n\n  return (\n    <span>\n      {words[index].substring(0, subIndex)}\n      <span className={`${blink ? \"opacity-100\" : \"opacity-0\"} text-jb-blue ml-1`}>|</span>\n    </span>\n  )\n}\n\n// --- Main Component ---\n\nexport default function Landing() {\n  return (\n    <div className=\"flex flex-col gap-24 md:gap-32 pb-24\">\n      {/* Hero Section */}\n      <section className=\"relative pt-20 pb-10 overflow-hidden\">\n        {/* Background Decorative Elements */}\n        <div className=\"absolute top-0 left-1/2 -translate-x-1/2 w-full h-[800px] bg-mesh-gradient opacity-40 pointer-events-none\" />\n        <div className=\"absolute top-[10%] left-[10%] w-72 h-72 bg-jb-purple/20 rounded-full blur-[100px] pointer-events-none\" />\n        <div className=\"absolute top-[20%] right-[10%] w-96 h-96 bg-jb-blue/20 rounded-full blur-[120px] pointer-events-none\" />\n        \n        <div className=\"container relative z-10\">\n          <div className=\"text-center max-w-5xl mx-auto\">\n\n\n            <h1 className=\"text-6xl md:text-8xl font-black tracking-tighter text-white mb-8 leading-[1.1]\">\n              Secure Tunnels for <br/>\n              <TypewriterText/>\n            </h1>\n\n            <p className=\"text-xl md:text-2xl text-slate-400 mb-12 leading-relaxed max-w-3xl mx-auto font-light\">\n              Expose your local server to the internet in seconds. <br/>\n              Production-ready security, developer-friendly experience.\n            </p>\n\n            <div className=\"flex flex-col sm:flex-row items-center justify-center gap-6\">\n              <Link\n                to=\"/install\"\n                className=\"btn btn-primary w-full sm:w-auto text-lg py-4 px-10 shadow-[0_0_40px_-10px_rgba(99,102,241,0.5)] hover:shadow-[0_0_60px_-10px_rgba(99,102,241,0.6)]\"\n              >\n                <CommandLineIcon className=\"w-6 h-6\"/>\n                Install CLI\n              </Link>\n              <Link\n                to=\"/login\"\n                className=\"btn w-full sm:w-auto text-lg py-4 px-10 glass hover:bg-white/5 border border-white/10\"\n              >\n                Get Started\n                <ArrowRightIcon className=\"w-5 h-5\"/>\n              </Link>\n            </div>\n\n            <div className=\"mt-12 flex items-center justify-center gap-8 text-sm font-medium\">\n              <div className=\"flex items-center gap-2 text-slate-400\">\n                <CheckIcon className=\"w-5 h-5 text-green-400\"/>\n                <span>No credit card required</span>\n              </div>\n              <div className=\"flex items-center gap-2 text-slate-400\">\n                <CheckIcon className=\"w-5 h-5 text-green-400\"/>\n                <span>Free tier available</span>\n              </div>\n            </div>\n          </div>\n\n          {/* Terminal Preview */}\n          <div className=\"mt-24 mx-auto max-w-4xl glass rounded-xl border border-white/10 shadow-[0_20px_80px_rgba(0,0,0,0.6)] overflow-hidden transform hover:scale-[1.01] transition-transform duration-500 group\">\n            <div className=\"flex items-center justify-between px-6 py-4 bg-[#0F1117] border-b border-white/5\">\n              <div className=\"flex gap-2\">\n                <div className=\"w-3 h-3 rounded-full bg-red-500/80\"></div>\n                <div className=\"w-3 h-3 rounded-full bg-yellow-500/80\"></div>\n                <div className=\"w-3 h-3 rounded-full bg-green-500/80\"></div>\n              </div>\n              <div className=\"text-xs text-slate-500 font-mono tracking-widest uppercase opacity-50 group-hover:opacity-100 transition-opacity\">user@machine:~</div>\n              <div className=\"w-10\"></div> {/* Spacer for center alignment */}\n            </div>\n            <div className=\"p-8 font-mono text-sm md:text-base overflow-x-auto leading-relaxed bg-[#0F1117]/95 backdrop-blur\">\n              <div className=\"flex items-center gap-3 text-slate-400 mb-6\">\n                <span className=\"text-jb-blue font-bold\">➜</span>\n                <span className=\"text-jb-purple font-bold\">~</span>\n                <span className=\"text-white\">portbuddy 3000</span>\n              </div>\n              \n              <div className=\"space-y-3\">\n                <div className=\"flex gap-10\">\n                  <span className=\"text-slate-500 w-24\">Port Buddy</span>\n                  <span className=\"text-jb-blue font-bold\">HTTP mode</span>\n                </div>\n                <div className=\"flex gap-10\">\n                  <span className=\"text-slate-500 w-24\">Status</span>\n                  <span className=\"px-2 py-0.5 bg-green-500/10 text-green-400 rounded text-xs font-bold uppercase tracking-wider border border-green-500/20\">Online</span>\n                </div>\n                <div className=\"flex gap-10\">\n                  <span className=\"text-slate-500 w-24\">Forwarding</span>\n                  <div className=\"flex flex-col gap-1\">\n                    <span className=\"text-slate-400\">Local:  <span className=\"text-white hover:underline cursor-pointer\">http://localhost:3000</span></span>\n                    <span className=\"text-slate-400\">Public: <span className=\"text-jb-pink hover:underline cursor-pointer font-bold\">https://app.portbuddy.dev</span></span>\n                  </div>\n                </div>\n                \n                <div className=\"border-t border-white/5 my-6\"></div>\n                \n                <div className=\"space-y-2 opacity-80\">\n                  <div className=\"flex gap-4 text-xs md:text-sm\">\n                    <span className=\"text-slate-600\">14:32:01</span>\n                    <span className=\"text-green-400 font-bold w-12\">200 OK</span>\n                    <span className=\"text-white\">GET /api/users</span>\n                    <span className=\"ml-auto text-slate-500\">12ms</span>\n                  </div>\n                  <div className=\"flex gap-4 text-xs md:text-sm\">\n                    <span className=\"text-slate-600\">14:32:05</span>\n                    <span className=\"text-green-400 font-bold w-12\">201 OK</span>\n                    <span className=\"text-white\">POST /api/webhooks/stripe</span>\n                    <span className=\"ml-auto text-slate-500\">45ms</span>\n                  </div>\n                   <div className=\"flex gap-4 text-xs md:text-sm\">\n                    <span className=\"text-slate-600\">14:32:12</span>\n                    <span className=\"text-yellow-400 font-bold w-12\">401</span>\n                    <span className=\"text-white\">GET /admin/settings</span>\n                    <span className=\"ml-auto text-slate-500\">8ms</span>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </section>\n\n      {/* Stats Section (Trust Builder) */}\n      {/*<section className=\"container\">*/}\n      {/*  <div className=\"grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-8\">*/}\n      {/*    <StatCard */}\n      {/*      icon={<GlobeAltIcon className=\"w-6 h-6\" />}*/}\n      {/*      value=\"50+\"*/}\n      {/*      label=\"Regions\"*/}\n      {/*    />*/}\n      {/*     <StatCard */}\n      {/*      icon={<CpuChipIcon className=\"w-6 h-6\" />}*/}\n      {/*      value=\"99.9%\"*/}\n      {/*      label=\"Uptime\"*/}\n      {/*    />*/}\n      {/*     <StatCard */}\n      {/*      icon={<UserIcon className=\"w-6 h-6\" />}*/}\n      {/*      value=\"10k+\"*/}\n      {/*      label=\"Users\"*/}\n      {/*    />*/}\n      {/*     <StatCard */}\n      {/*      icon={<ShieldCheckIcon className=\"w-6 h-6\" />}*/}\n      {/*      value=\"AES-256\"*/}\n      {/*      label=\"Encryption\"*/}\n      {/*    />*/}\n      {/*  </div>*/}\n      {/*</section>*/}\n\n      {/* Features Grid */}\n      <section id=\"features\" className=\"container\">\n        <div className=\"text-center mb-16\">\n          <h2 className=\"text-3xl md:text-5xl font-black text-white mb-6\">\n            Everything you need for <br/>\n            <span className=\"text-indigo-400\">local development</span>\n          </h2>\n          <p className=\"text-slate-400 max-w-2xl mx-auto text-lg\">\n            Port Buddy is packed with features to help you develop, test, and demo your applications faster without compromising on security.\n          </p>\n        </div>\n\n        <div className=\"grid md:grid-cols-2 lg:grid-cols-3 gap-6\">\n          <FeatureCard \n            icon={<GlobeAltIcon className=\"w-6 h-6 text-indigo-400\" />}\n            title=\"Custom Domains\"\n            description=\"Bring your own domain name. We automatically provision and manage SSL certificates for you.\"\n          />\n          <FeatureCard \n            icon={<ServerIcon className=\"w-6 h-6 text-cyan-400\" />}\n            title=\"TCP & UDP Tunnels\"\n            description=\"Expose any TCP or UDP service. Databases, SSH, RDP, game servers, IoT protocols, and more.\"\n          />\n          <FeatureCard \n            icon={<ShieldCheckIcon className=\"w-6 h-6 text-green-400\" />}\n            title=\"Secure by Default\"\n            description=\"Automatic HTTPS for all HTTP tunnels. End-to-end encryption keeps your data safe.\"\n          />\n          <FeatureCard \n            icon={<BoltIcon className=\"w-6 h-6 text-yellow-400\" />}\n            title=\"WebSockets Support\"\n            description=\"Full support for WebSockets. Perfect for real-time applications, chat apps, and game servers.\"\n          />\n          <FeatureCard \n            icon={<CommandLineIcon className=\"w-6 h-6 text-purple-400\" />}\n            title=\"Static Subdomains\"\n            description=\"Reserve your own subdomains on our platform. Keep your URLs consistent across restarts.\"\n          />\n          <FeatureCard \n            icon={<LockClosedIcon className=\"w-6 h-6 text-red-400\" />}\n            title=\"Private Tunnels\"\n            description=\"Protect your tunnels with basic auth or IP allowlisting. Control who can access your local apps.\"\n          />\n        </div>\n      </section>\n\n      {/* Architecture Section */}\n      <section id=\"architecture\" className=\"container\">\n        <div className=\"relative glass rounded-3xl border border-white/5 p-8 md:p-20 overflow-hidden\">\n          {/* Animated Background */}\n          <div className=\"absolute top-0 left-0 w-full h-full bg-jb-blue/5 opacity-30 pointer-events-none\" />\n          \n          <div className=\"text-center mb-16 relative z-10\">\n            <h2 className=\"text-3xl md:text-4xl font-bold text-white mb-4\">\n              How it works\n            </h2>\n            <p className=\"text-slate-400 max-w-2xl mx-auto\">\n              A high-performance edge network that routes traffic securely to your machine.\n            </p>\n          </div>\n\n          <div className=\"relative flex flex-col lg:flex-row items-center justify-between gap-12 lg:gap-8 z-10\">\n            {/* Public Client */}\n            <div className=\"flex flex-col items-center text-center w-full lg:w-1/4 group\">\n              <div className=\"w-24 h-24 rounded-3xl bg-slate-800 flex items-center justify-center text-slate-300 mb-6 border border-white/10 shadow-2xl group-hover:-translate-y-2 transition-transform duration-300\">\n                <UserIcon className=\"w-10 h-10\" />\n              </div>\n              <h3 className=\"text-lg font-bold text-white mb-2\">Public Visitor</h3>\n              <p className=\"text-sm text-slate-400 leading-relaxed\">Accesses your app via <br/> <span className=\"text-jb-pink font-mono\">*.portbuddy.dev</span></p>\n            </div>\n\n            {/* Arrow 1 */}\n            <div className=\"hidden lg:flex flex-col items-center justify-center flex-1 px-4\">\n               <div className=\"w-full h-0.5 bg-slate-700 relative overflow-hidden\">\n                 <div className=\"absolute top-0 bottom-0 w-20 bg-gradient-to-r from-transparent via-jb-blue to-transparent animate-flow\" />\n               </div>\n               <span className=\"text-[10px] text-slate-500 mt-4 uppercase tracking-widest font-bold\">HTTPS/TCP</span>\n            </div>\n\n            {/* Port Buddy Cloud */}\n            <div className=\"flex flex-col items-center text-center w-full lg:w-1/4 p-8 rounded-3xl bg-[#0F1117] border border-jb-blue/30 shadow-[0_0_50px_rgba(51,204,255,0.1)] relative z-20 group\">\n              <div className=\"absolute -top-3 -right-3 px-3 py-1 bg-jb-blue text-white text-[10px] font-bold rounded-full uppercase tracking-tighter shadow-lg\">Edge Node</div>\n              <div className=\"w-24 h-24 rounded-3xl bg-jb-blue/10 flex items-center justify-center text-jb-blue mb-6 border border-jb-blue/20 shadow-inner group-hover:-translate-y-2 transition-transform duration-300\">\n                <CloudIcon className=\"w-12 h-12\" />\n              </div>\n              <h3 className=\"text-lg font-bold text-white mb-2\">Port Buddy Cloud</h3>\n              <p className=\"text-sm text-slate-400 leading-relaxed\">Auth, SSL Termination & <br/> Request Routing</p>\n            </div>\n\n            {/* Arrow 2 (The Tunnel) */}\n            <div className=\"hidden lg:flex flex-col items-center justify-center flex-1 px-4\">\n               <div className=\"w-full h-1 bg-slate-800 relative rounded-full overflow-hidden\">\n                 <div className=\"absolute inset-0 bg-gradient-to-r from-jb-blue/20 to-jb-purple/20 animate-pulse\" />\n                 <div className=\"absolute top-0 bottom-0 w-20 bg-gradient-to-r from-transparent via-white to-transparent animate-flow\" style={{animationDelay: '0.5s'}} />\n               </div>\n               <div className=\"flex items-center gap-1 mt-4\">\n                 <LockClosedIcon className=\"w-3 h-3 text-green-400\" />\n                 <span className=\"text-[10px] text-slate-500 uppercase tracking-widest font-bold\">Secure Tunnel</span>\n               </div>\n            </div>\n\n            {/* Local Environment */}\n            <div className=\"flex flex-col items-center text-center w-full lg:w-1/4 p-8 rounded-3xl bg-white/[0.02] border border-white/10 shadow-xl group\">\n              <div className=\"w-24 h-24 rounded-3xl bg-slate-800 flex items-center justify-center text-jb-purple mb-6 border border-white/10 shadow-2xl group-hover:-translate-y-2 transition-transform duration-300\">\n                <ComputerDesktopIcon className=\"w-10 h-10\" />\n              </div>\n              <h3 className=\"text-lg font-bold text-white mb-2\">Your Machine</h3>\n              <div className=\"flex flex-col gap-2 mt-2\">\n                 <div className=\"px-3 py-1 bg-white/5 rounded text-[10px] font-mono text-jb-purple border border-white/5\">Port Buddy CLI</div>\n                 <div className=\"px-3 py-1 bg-white/5 rounded text-[10px] font-mono text-slate-400 border border-white/5\">Localhost:3000</div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </section>\n\n      {/* Testimonials (Trust Builder) */}\n      <section className=\"container\">\n        <h2 className=\"text-3xl md:text-4xl font-bold text-center text-white mb-16\">\n          Loved by developers\n        </h2>\n        <div className=\"grid md:grid-cols-3 gap-6\">\n          <TestimonialCard \n            quote=\"Finally, a tunneling tool that is simple, fast, and doesn't break. I use it daily for webhook testing.\"\n            author=\"Sarah Jenkins\"\n            role=\"Senior Backend Engineer\"\n          />\n          <TestimonialCard \n            quote=\"The custom domain feature is a lifesaver. Being able to show clients a consistent URL during demos is huge.\"\n            author=\"David Chen\"\n            role=\"Freelance Developer\"\n          />\n          <TestimonialCard \n            quote=\"I switched from ngrok because of the pricing, but stayed for the speed. Port Buddy is blazing fast.\"\n            author=\"Michael Rossi\"\n            role=\"CTO @ StartupX\"\n          />\n        </div>\n      </section>\n\n      {/* Use Cases */}\n      <section id=\"use-cases\" className=\"container bg-slate-900/50 py-16 rounded-3xl border border-white/5 overflow-hidden\">\n        <div className=\"grid lg:grid-cols-2 gap-16 items-center px-4 md:px-12\">\n          <div>\n            <h2 className=\"text-3xl md:text-4xl font-bold text-white mb-6\">\n              Built for modern workflows\n            </h2>\n            <p className=\"text-slate-400 mb-8 text-lg leading-relaxed\">\n              From webhooks to client demos, Port Buddy streamlines your development workflow. Stop deploying just to test a small change.\n            </p>\n            \n            <div className=\"space-y-6\">\n              <div className=\"flex gap-4\">\n                <div className=\"p-3 rounded-lg bg-indigo-500/10 border border-indigo-500/20 h-fit\">\n                   <ChatBubbleLeftRightIcon className=\"w-6 h-6 text-indigo-400\" />\n                </div>\n                <div>\n                  <h3 className=\"text-white font-bold mb-1\">Test Webhooks</h3>\n                  <p className=\"text-slate-400 text-sm\">Receive webhooks from Stripe, GitHub, or Twilio directly to your localhost.</p>\n                </div>\n              </div>\n              <div className=\"flex gap-4\">\n                <div className=\"p-3 rounded-lg bg-pink-500/10 border border-pink-500/20 h-fit\">\n                   <GlobeAltIcon className=\"w-6 h-6 text-pink-400\" />\n                </div>\n                <div>\n                  <h3 className=\"text-white font-bold mb-1\">Demo to Clients</h3>\n                  <p className=\"text-slate-400 text-sm\">Share your work in progress with clients or colleagues instantly.</p>\n                </div>\n              </div>\n              <div className=\"flex gap-4\">\n                <div className=\"p-3 rounded-lg bg-cyan-500/10 border border-cyan-500/20 h-fit\">\n                   <CodeBracketIcon className=\"w-6 h-6 text-cyan-400\" />\n                </div>\n                <div>\n                  <h3 className=\"text-white font-bold mb-1\">Mobile Testing</h3>\n                  <p className=\"text-slate-400 text-sm\">Test your responsive designs on real mobile devices via public URL.</p>\n                </div>\n              </div>\n            </div>\n          </div>\n          \n          <div className=\"relative min-w-0\">\n             <div className=\"absolute inset-0 bg-gradient-to-br from-indigo-500/20 to-purple-500/20 blur-[60px] rounded-full pointer-events-none\" />\n             <div className=\"relative bg-slate-950 border border-slate-800 rounded-xl p-6 shadow-2xl\">\n               <div className=\"flex items-center gap-2 mb-4 border-b border-slate-800 pb-4\">\n                 <div className=\"w-3 h-3 rounded-full bg-slate-700\"></div>\n                 <div className=\"w-3 h-3 rounded-full bg-slate-700\"></div>\n                 <div className=\"flex-1 text-center text-xs text-slate-500\">webhook-handler.js</div>\n               </div>\n               <pre className=\"font-mono text-sm text-slate-300 overflow-x-auto\">\n                 <code>\n{`app.post('/webhook', (req, res) => {\n  const event = req.body;\n  \n  // Handle the event locally\n  console.log('Received event:', event.type);\n  \n  if (event.type === 'payment_succeeded') {\n    fulfillOrder(event.data);\n  }\n\n  res.json({received: true});\n});`}\n                 </code>\n               </pre>\n             </div>\n          </div>\n        </div>\n      </section>\n\n      {/* Pricing */}\n      <section id=\"pricing\" className=\"container\">\n        <div className=\"text-center mb-16\">\n          <h2 className=\"text-3xl md:text-5xl font-black text-white mb-6\">\n            Simple, transparent pricing\n          </h2>\n          <p className=\"text-slate-400 text-lg\">\n            Start for free, upgrade as you grow.\n          </p>\n        </div>\n        <PlanComparison />\n      </section>\n\n      {/* FAQ */}\n      <section className=\"container max-w-3xl\">\n        <h2 className=\"text-3xl font-bold text-center text-white mb-12\">Frequently Asked Questions</h2>\n        <div className=\"space-y-2\">\n          <FaqItem \n            question=\"Is Port Buddy secure?\" \n            answer=\"Yes. All traffic is encrypted end-to-end. We use industry-standard TLS encryption for tunnels. For private tunnels, you can enforce Basic Auth or IP Allowlisting.\" \n          />\n          <FaqItem \n            question=\"How does it differ from ngrok?\" \n            answer=\"Port Buddy is designed to be a simpler, more affordable alternative with a focus on developer experience. We offer features like static subdomains and custom domains at a much lower price point.\" \n          />\n          <FaqItem \n            question=\"Can I use my own domain?\" \n            answer=\"Absolutely. You can connect your own domain name (e.g., tunnel.yourcompany.com) and we will automatically handle SSL certificates for you.\" \n          />\n          <FaqItem \n            question=\"Do you support TCP tunnels?\" \n            answer=\"Yes, TCP and UDP tunnels are supported. This is perfect for exposing databases, game servers, or RDP/SSH connections.\" \n          />\n        </div>\n      </section>\n\n      {/* Final CTA */}\n      <section className=\"container py-12\">\n        <div className=\"relative rounded-3xl overflow-hidden p-12 md:p-24 text-center\">\n          <div className=\"absolute inset-0 bg-gradient-to-br from-indigo-900/50 to-purple-900/50 z-0\" />\n          <div className=\"absolute inset-0 bg-mesh-gradient opacity-30 z-0\" />\n          \n          <div className=\"relative z-10 max-w-3xl mx-auto\">\n            <h2 className=\"text-4xl md:text-5xl font-black text-white mb-8 tracking-tight\">\n              Ready to code from anywhere?\n            </h2>\n            <p className=\"text-xl text-slate-300 mb-10\">\n              Join thousands of developers who trust Port Buddy for their local development.\n            </p>\n            <div className=\"flex flex-col sm:flex-row items-center justify-center gap-4\">\n              <Link \n                to=\"/install\" \n                className=\"btn btn-primary text-lg py-4 px-12 w-full sm:w-auto\"\n              >\n                Get Started for Free\n              </Link>\n              <Link \n                to=\"/docs\" \n                className=\"btn glass text-lg py-4 px-12 w-full sm:w-auto hover:bg-white/10\"\n              >\n                Read Documentation\n              </Link>\n            </div>\n          </div>\n        </div>\n      </section>\n    </div>\n  )\n}"
  },
  {
    "path": "web/src/pages/Login.tsx",
    "content": "import { useEffect, useState } from 'react'\nimport { Link, useLocation, useNavigate } from 'react-router-dom'\nimport { useAuth } from '../auth/AuthContext'\nimport { ArrowLeftIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'\n\nexport default function Login() {\n  const { user, loading, loginWithGoogle, loginWithGithub, loginWithEmail } = useAuth()\n  const navigate = useNavigate()\n  const location = useLocation() as any\n  \n  const [showEmail, setShowEmail] = useState(false)\n  const [email, setEmail] = useState('')\n  const [password, setPassword] = useState('')\n  const [submitting, setSubmitting] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n\n  useEffect(() => {\n    // After refresh completes and user exists, redirect to intended page or /app.\n    if (!loading && user) {\n      const params = new URLSearchParams(location?.search || '')\n      const fromQuery = params.get('from')\n      const fromState = location?.state?.from?.pathname\n      const fromStorage = localStorage.getItem('pb_login_from')\n      \n      const to = (fromQuery && typeof fromQuery === 'string') ? fromQuery : (fromState || fromStorage || '/app')\n      \n      // Clean up storage\n      localStorage.removeItem('pb_login_from')\n      \n      navigate(to, { replace: true })\n    }\n  }, [user, loading, navigate, location])\n\n  const handleGoogleLogin = () => {\n    // Save current 'from' to localStorage because OAuth redirect will lose React state\n    const from = location?.state?.from?.pathname\n    if (from) {\n      localStorage.setItem('pb_login_from', from)\n    }\n    loginWithGoogle()\n  }\n  \n  const handleGithubLogin = () => {\n    // Save current 'from' to localStorage because OAuth redirect will lose React state\n    const from = location?.state?.from?.pathname\n    if (from) {\n      localStorage.setItem('pb_login_from', from)\n    }\n    loginWithGithub()\n  }\n\n  const handleEmailLogin = async (e: React.FormEvent) => {\n    e.preventDefault()\n    setError(null)\n    setSubmitting(true)\n    try {\n      await loginWithEmail(email, password)\n    } catch (err: any) {\n      setError(err.message || 'Login failed')\n    } finally {\n      setSubmitting(false)\n    }\n  }\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center relative overflow-hidden\">\n      {/* Background gradients */}\n      <div className=\"absolute inset-0 bg-primary-950\"></div>\n      <div className=\"absolute inset-0 bg-mesh-gradient opacity-30 pointer-events-none\" />\n      <div className=\"absolute top-[20%] left-[20%] w-64 h-64 bg-jb-purple/10 rounded-full blur-[120px] pointer-events-none\" />\n      <div className=\"absolute bottom-[20%] right-[20%] w-96 h-96 bg-jb-blue/10 rounded-full blur-[120px] pointer-events-none\" />\n      \n      <div className=\"w-full max-w-md p-6 relative z-10\">\n        <div className=\"glass border border-white/5 rounded-3xl p-10 shadow-[0_20px_50px_rgba(0,0,0,0.5)]\">\n          <div className=\"text-center mb-10\">\n            <Link to=\"/\" className=\"inline-block mb-8 group\">\n              <div className=\"flex items-center justify-center gap-3 text-2xl font-black text-white tracking-tighter uppercase transition-transform group-hover:scale-105\">\n                <span className=\"relative flex h-4 w-4\">\n                   <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75\"></span>\n                   <span className=\"relative inline-flex rounded-full h-4 w-4 bg-indigo-400 shadow-[0_0_10px_rgba(51,204,255,0.5)]\"></span>\n                </span>\n                Port Buddy\n              </div>\n            </Link>\n            <h1 className=\"text-3xl font-black text-white mb-3 tracking-tight\">Welcome back</h1>\n            <p className=\"text-slate-400 text-sm font-medium\">\n              Manage your tunnels and domains.\n            </p>\n          </div>\n\n          <div className=\"space-y-5\">\n            <button \n              onClick={handleGoogleLogin} \n              className=\"w-full flex items-center justify-center gap-4 bg-white hover:bg-slate-100 text-slate-900 font-bold py-4 px-6 rounded-2xl transition-all transform hover:-translate-y-1 active:scale-[0.98] shadow-lg\"\n              aria-label=\"Sign in with Google\"\n            >\n              <svg className=\"w-6 h-6\" viewBox=\"0 0 24 24\">\n                <path\n                  d=\"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z\"\n                  fill=\"#4285F4\"\n                />\n                <path\n                  d=\"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z\"\n                  fill=\"#34A853\"\n                />\n                <path\n                  d=\"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z\"\n                  fill=\"#FBBC05\"\n                />\n                <path\n                  d=\"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z\"\n                  fill=\"#EA4335\"\n                />\n              </svg>\n              Continue with Google\n            </button>\n\n            <button \n              onClick={handleGithubLogin} \n              className=\"w-full flex items-center justify-center gap-4 bg-slate-800 hover:bg-slate-700 text-white font-bold py-4 px-6 rounded-2xl transition-all transform hover:-translate-y-1 active:scale-[0.98] shadow-lg border border-white/5\"\n              aria-label=\"Sign in with GitHub\"\n            >\n              <svg className=\"w-6 h-6 fill-current\" viewBox=\"0 0 24 24\">\n                <path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.041-1.416-4.041-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\" />\n              </svg>\n              Continue with GitHub\n            </button>\n\n            <div className=\"relative py-4\">\n              <div className=\"absolute inset-0 flex items-center\">\n                <div className=\"w-full border-t border-white/5\"></div>\n              </div>\n              <div className=\"relative flex justify-center text-xs\">\n                <span className=\"px-4 bg-primary-950 text-slate-500 font-bold uppercase tracking-widest\">Or continue with</span>\n              </div>\n            </div>\n\n            <button\n              onClick={() => setShowEmail(!showEmail)}\n              className=\"w-full flex items-center justify-center gap-2 text-slate-400 hover:text-white transition-colors text-sm font-bold\"\n            >\n              {showEmail ? 'Hide email login' : 'Log in with email'}\n              {showEmail ? <ChevronUpIcon className=\"w-4 h-4\" /> : <ChevronDownIcon className=\"w-4 h-4\" />}\n            </button>\n\n            {showEmail && (\n              <form onSubmit={handleEmailLogin} className=\"space-y-5 pt-4 animate-in slide-in-from-top-2 fade-in duration-300\">\n                {error && (\n                  <div className=\"bg-red-500/10 border border-red-500/50 text-red-400 text-sm p-4 rounded-2xl text-center font-medium\">\n                    {error}\n                  </div>\n                )}\n                <div>\n                  <label className=\"block text-xs font-black text-slate-500 mb-2 uppercase tracking-widest\">Email</label>\n                  <input\n                    type=\"email\"\n                    value={email}\n                    onChange={(e) => setEmail(e.target.value)}\n                    required\n                    className=\"w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3.5 text-white focus:outline-none focus:ring-2 focus:ring-jb-blue/50 focus:border-jb-blue/50 transition-all font-mono\"\n                    placeholder=\"name@example.com\"\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-xs font-black text-slate-500 mb-2 uppercase tracking-widest\">Password</label>\n                  <input\n                    type=\"password\"\n                    value={password}\n                    onChange={(e) => setPassword(e.target.value)}\n                    required\n                    className=\"w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3.5 text-white focus:outline-none focus:ring-2 focus:ring-jb-blue/50 focus:border-jb-blue/50 transition-all font-mono\"\n                    placeholder=\"••••••••\"\n                  />\n                </div>\n                <button\n                  type=\"submit\"\n                  disabled={submitting}\n                  className=\"btn btn-primary w-full py-4 rounded-2xl text-lg font-black uppercase tracking-wider\"\n                >\n                  Sign in\n                </button>\n                <div className=\"text-center space-y-3\">\n                  <Link to=\"/forgot-password\"  className=\"block text-xs text-jb-blue hover:text-jb-blue/80 font-bold uppercase tracking-widest\">\n                    Forgot password?\n                  </Link>\n                  <p className=\"text-sm text-slate-400\">\n                    Don't have an account?{' '}\n                    <Link to=\"/register\" className=\"text-jb-blue hover:text-jb-blue/80 font-bold transition-colors\">\n                      Sign up\n                    </Link>\n                  </p>\n                </div>\n              </form>\n            )}\n          </div>\n\n          <div className=\"mt-10 pt-8 border-t border-white/5 text-center\">\n            <p className=\"text-xs text-slate-500 mb-6 leading-relaxed\">\n              By continuing, you agree to our{' '}\n              <Link to=\"/terms\" className=\"text-slate-300 hover:text-white transition-colors underline decoration-white/10 underline-offset-4\">Terms of Service</Link>\n              {' '}and{' '}\n              <Link to=\"/privacy\" className=\"text-slate-300 hover:text-white transition-colors underline decoration-white/10 underline-offset-4\">Privacy Policy</Link>.\n            </p>\n            \n            <Link to=\"/\" className=\"inline-flex items-center gap-3 text-sm font-bold text-slate-400 hover:text-white transition-colors group\">\n              <ArrowLeftIcon className=\"w-5 h-5 group-hover:-translate-x-1 transition-transform\" />\n              Back to home\n            </Link>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/NotFound.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport { Link } from 'react-router-dom'\nimport {\n  ArrowRightIcon,\n  HomeIcon,\n  MagnifyingGlassIcon\n} from '@heroicons/react/24/outline'\nimport React from 'react'\n\nexport default function NotFound() {\n  return (\n    <div className=\"flex flex-col gap-16 pb-24\">\n      <section className=\"relative pt-12 md:pt-36\">\n        <div className=\"absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-900/20 via-slate-900/0 to-slate-900/0 pointer-events-none\" />\n<div className=\"container relative max-w-4xl text-center\">\n          <div className=\"inline-flex items-center gap-2 px-3 py-1 rounded-full bg-indigo-500/10 border border-indigo-500/20 text-indigo-300 text-xs font-medium mb-6\">\n            <span className=\"relative flex h-2 w-2\">\n              <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75\"></span>\n              <span className=\"relative inline-flex rounded-full h-2 w-2 bg-indigo-500\"></span>\n            </span>\n            404 — Not Found\n          </div>\n\n          <h1 className=\"text-4xl md:text-6xl font-extrabold tracking-tight text-white mb-6 leading-tight\">\n            Oops, this tunnel seems closed\n          </h1>\n\n          <p className=\"text-lg md:text-xl text-slate-400 mb-10 leading-relaxed max-w-2xl mx-auto\">\n            The page you’re trying to reach doesn’t exist or has moved. Double‑check the URL, or head back to a safe place.\n          </p>\n\n          <div className=\"flex flex-col sm:flex-row items-center justify-center gap-4\">\n            <Link\n              to=\"/\"\n              className=\"w-full sm:w-auto px-6 py-3 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg font-semibold transition-all flex items-center justify-center gap-2 shadow-lg shadow-indigo-500/20\"\n            >\n              <HomeIcon className=\"w-5 h-5\" />\n              Go to Home\n            </Link>\n            <Link\n              to=\"/install\"\n              className=\"w-full sm:w-auto px-6 py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-lg font-semibold transition-all flex items-center justify-center gap-2 border border-slate-700\"\n            >\n              Installation Guide\n              <ArrowRightIcon className=\"w-4 h-4\" />\n            </Link>\n          </div>\n\n          <div className=\"mt-14 mx-auto max-w-3xl bg-slate-900 rounded-xl border border-slate-800 shadow-2xl overflow-hidden\">\n            <div className=\"flex items-center px-4 py-3 bg-slate-800/50 border-b border-slate-800 gap-2\">\n              <div className=\"w-3 h-3 rounded-full bg-red-500/20 border border-red-500/50\"></div>\n              <div className=\"w-3 h-3 rounded-full bg-yellow-500/20 border border-yellow-500/50\"></div>\n              <div className=\"w-3 h-3 rounded-full bg-green-500/20 border border-green-500/50\"></div>\n              <div className=\"ml-2 text-xs text-slate-500 font-mono\">request — 404</div>\n            </div>\n            <div className=\"p-6 font-mono text-sm md:text-base overflow-x-auto text-left\">\n              <div className=\"flex items-center gap-2 text-slate-400 mb-3\">\n                <span className=\"text-red-400\">GET</span>\n                <span className=\"text-slate-300\">/unknown</span>\n                <span className=\"text-slate-600\">→</span>\n                <span className=\"text-slate-500\">404 Not Found</span>\n              </div>\n              <div className=\"rounded-lg bg-slate-950/60 border border-slate-800 p-4 text-slate-300\">\n                <div className=\"flex items-center gap-2 mb-2\">\n                  <MagnifyingGlassIcon className=\"w-4 h-4 text-slate-500\" />\n                  <span className=\"text-slate-400\">Hints</span>\n                </div>\n                <ul className=\"list-disc pl-5 space-y-1 text-slate-400\">\n                  <li>Check the URL spelling.</li>\n                  <li>Use the navigation above to find what you need.</li>\n                  <li>Start with our Installation guide to expose a local port.</li>\n                </ul>\n              </div>\n            </div>\n          </div>\n        </div>\n      </section>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/Passcode.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport { FormEvent, useMemo, useState } from 'react'\nimport { useSearchParams } from 'react-router-dom'\n\nexport default function Passcode() {\n  const [sp] = useSearchParams()\n  const [passcode, setPasscode] = useState('')\n  const targetDomain = sp.get('target_domain') || ''\n\n  const protocol = useMemo(() => window.location.protocol, [])\n\n  const onSubmit = (e: FormEvent) => {\n    e.preventDefault()\n    if (!targetDomain || !passcode) return\n    const url = `${protocol}//${targetDomain}/?passcode=${encodeURIComponent(passcode)}`\n    window.location.href = url\n  }\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center bg-slate-950 px-4\">\n      <div className=\"w-full max-w-md bg-slate-900/70 backdrop-blur border border-slate-800 rounded-2xl p-6 shadow-xl\">\n        <h1 className=\"text-xl font-semibold text-white mb-2\">Enter passcode</h1>\n        <p className=\"text-slate-400 text-sm mb-6\">\n          This tunnel is protected. Enter the passcode to proceed to\n          {targetDomain ? ' ' : ''}\n          {targetDomain && (\n            <span className=\"text-slate-200 font-mono\">{targetDomain}</span>\n          )}.\n        </p>\n        <form onSubmit={onSubmit} className=\"flex flex-col gap-4\">\n          <div>\n            <label className=\"block text-sm text-slate-300 mb-1\" htmlFor=\"passcode\">Passcode</label>\n            <input\n              id=\"passcode\"\n              type=\"password\"\n              value={passcode}\n              onChange={(e) => setPasscode(e.target.value)}\n              className=\"w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500\"\n              placeholder=\"Enter passcode\"\n              autoFocus\n            />\n          </div>\n          <button\n            type=\"submit\"\n            disabled={!passcode || !targetDomain}\n            className=\"bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 py-2 rounded-lg transition-colors\"\n          >\n            Continue\n          </button>\n        </form>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/Privacy.tsx",
    "content": "/*\n * Copyright (c) 2026 AMAK Inc. All rights reserved.\n */\n\nexport default function Privacy() {\n  return (\n    <div className=\"min-h-screen flex flex-col\">\n      <div className=\"flex-1 relative pt-12 md:pt-32 pb-20\">\n        <div className=\"absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-900/10 via-slate-900/0 to-slate-900/0 pointer-events-none\" />\n        \n        <div className=\"container max-w-3xl relative z-10\">\n          <h1 className=\"text-4xl font-bold text-white mb-8\">Privacy Policy</h1>\n          \n          <div className=\"prose prose-invert max-w-none text-slate-300 space-y-6\">\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">1. Information We Collect</h2>\n              <p>\n                We collect information you provide directly to us when you create an account, such as your name, email address, and authentication details from Google or GitHub.\n              </p>\n              <p>\n                We also collect technical data related to your use of our service, including IP addresses, browser type, and usage statistics of the tunnels you create.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">2. How We Use Your Information</h2>\n              <p>\n                We use the information we collect to:\n              </p>\n              <ul className=\"list-disc pl-6 space-y-2\">\n                <li>Provide, maintain, and improve our services.</li>\n                <li>Process transactions and manage your subscription.</li>\n                <li>Communicate with you about updates, security alerts, and support.</li>\n                <li>Monitor and analyze trends, usage, and activities.</li>\n              </ul>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">3. Data Sharing and Disclosure</h2>\n              <p>\n                We do not share your personal information with third parties except as described in this policy:\n              </p>\n              <ul className=\"list-disc pl-6 space-y-2\">\n                <li>With your consent or at your direction.</li>\n                <li>With vendors and service providers who perform services for us (e.g., payment processing).</li>\n                <li>To comply with legal obligations.</li>\n                <li>To protect the rights and safety of Port Buddy and our users.</li>\n              </ul>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">4. Data Security</h2>\n              <p>\n                We take reasonable measures to protect your personal information from loss, theft, misuse, and unauthorized access. However, no internet transmission is 100% secure.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">5. Your Choices</h2>\n              <p>\n                You can access, update, or delete your account information at any time through your account settings. You may also contact us to request data deletion.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">6. Cookies</h2>\n              <p>\n                We use cookies and similar technologies to enhance your experience and collect usage data. You can manage cookie preferences through your browser settings.\n              </p>\n            </section>\n\n            <footer className=\"pt-8 border-t border-slate-800 text-sm text-slate-500\">\n              Last updated: January 8, 2026\n            </footer>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/Register.tsx",
    "content": "/*\n * Copyright (c) 2026 AMAK Inc. All rights reserved.\n */\n\nimport { useEffect, useState } from 'react'\nimport { Link, useLocation, useNavigate } from 'react-router-dom'\nimport { useAuth } from '../auth/AuthContext'\nimport { ArrowLeftIcon } from '@heroicons/react/24/outline'\n\nexport default function Register() {\n  const { loading, user, loginWithGoogle, loginWithGithub, register } = useAuth()\n  const navigate = useNavigate()\n  const location = useLocation() as any\n  \n  const [name, setName] = useState('')\n  const [email, setEmail] = useState('')\n  const [password, setPassword] = useState('')\n  const [submitting, setSubmitting] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n\n  useEffect(() => {\n    if (!loading && user) {\n      const params = new URLSearchParams(location?.search || '')\n      const fromQuery = params.get('from')\n      const fromState = location?.state?.from?.pathname\n      \n      const to = (fromQuery && typeof fromQuery === 'string') ? fromQuery : (fromState || '/app')\n      navigate(to, { replace: true })\n    }\n  }, [user, loading, navigate, location])\n\n  const handleRegister = async (e: React.FormEvent) => {\n    e.preventDefault()\n    setError(null)\n    setSubmitting(true)\n    try {\n      await register(email, password, name)\n    } catch (err: any) {\n      setError(err.message || 'Registration failed')\n    } finally {\n      setSubmitting(false)\n    }\n  }\n\n  const handleGoogleLogin = () => {\n    loginWithGoogle()\n  }\n\n  const handleGithubLogin = () => {\n    loginWithGithub()\n  }\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center relative overflow-hidden\">\n      {/* Background gradients */}\n      <div className=\"absolute inset-0 bg-slate-950\"></div>\n      <div className=\"absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-indigo-900/20 via-slate-900/0 to-slate-900/0 pointer-events-none\" />\n      \n      <div className=\"w-full max-w-md p-6 relative z-10\">\n        <div className=\"bg-slate-900/50 border border-slate-800 rounded-2xl p-8 shadow-2xl backdrop-blur-sm\">\n          <div className=\"text-center mb-8\">\n            <Link to=\"/\" className=\"inline-block mb-6\">\n              <div className=\"flex items-center justify-center gap-2 text-xl font-bold text-white\">\n                <span className=\"relative flex h-3 w-3\">\n                   <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75\"></span>\n                   <span className=\"relative inline-flex rounded-full h-3 w-3 bg-indigo-500\"></span>\n                </span>\n                Port Buddy\n              </div>\n            </Link>\n            <h1 className=\"text-2xl font-bold text-white mb-2\">Create an account</h1>\n            <p className=\"text-slate-400 text-sm\">\n              Start sharing your local ports securely.\n            </p>\n          </div>\n\n          <form onSubmit={handleRegister} className=\"space-y-4\">\n            {error && (\n              <div className=\"bg-red-500/10 border border-red-500/50 text-red-400 text-sm p-3 rounded-lg text-center\">\n                {error}\n              </div>\n            )}\n            <div>\n              <label className=\"block text-xs font-medium text-slate-400 mb-1\">Full Name (optional)</label>\n              <input\n                type=\"text\"\n                value={name}\n                onChange={(e) => setName(e.target.value)}\n                className=\"w-full bg-slate-950/50 border border-slate-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all\"\n                placeholder=\"John Doe\"\n              />\n            </div>\n            <div>\n              <label className=\"block text-xs font-medium text-slate-400 mb-1\">Email</label>\n              <input\n                type=\"email\"\n                value={email}\n                onChange={(e) => setEmail(e.target.value)}\n                required\n                className=\"w-full bg-slate-950/50 border border-slate-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all\"\n                placeholder=\"name@example.com\"\n              />\n            </div>\n            <div>\n              <label className=\"block text-xs font-medium text-slate-400 mb-1\">Password</label>\n              <input\n                type=\"password\"\n                value={password}\n                onChange={(e) => setPassword(e.target.value)}\n                required\n                className=\"w-full bg-slate-950/50 border border-slate-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all\"\n                placeholder=\"••••••••\"\n              />\n            </div>\n            <button\n              type=\"submit\"\n              disabled={submitting}\n              className=\"w-full bg-indigo-600 hover:bg-indigo-500 text-white font-medium py-2.5 px-4 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-indigo-500/20\"\n            >\n              Sign up\n            </button>\n          </form>\n\n          <div className=\"relative py-6\">\n            <div className=\"absolute inset-0 flex items-center\">\n              <div className=\"w-full border-t border-slate-800\"></div>\n            </div>\n            <div className=\"relative flex justify-center text-sm\">\n              <span className=\"px-2 bg-[#0f172a] text-slate-500\">Or register with</span>\n            </div>\n          </div>\n\n          <div className=\"grid grid-cols-2 gap-4\">\n            <button \n              onClick={handleGoogleLogin} \n              className=\"flex items-center justify-center gap-2 bg-white hover:bg-slate-100 text-slate-900 font-medium py-2.5 px-4 rounded-lg transition-all transform hover:scale-[1.02] active:scale-[0.98]\"\n            >\n              <svg className=\"w-4 h-4\" viewBox=\"0 0 24 24\">\n                <path\n                  d=\"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z\"\n                  fill=\"#4285F4\"\n                />\n                <path\n                  d=\"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z\"\n                  fill=\"#34A853\"\n                />\n                <path\n                  d=\"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z\"\n                  fill=\"#FBBC05\"\n                />\n                <path\n                  d=\"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z\"\n                  fill=\"#EA4335\"\n                />\n              </svg>\n              Google\n            </button>\n            <button \n              onClick={handleGithubLogin} \n              className=\"flex items-center justify-center gap-2 bg-slate-800 hover:bg-slate-700 text-white font-medium py-2.5 px-4 rounded-lg transition-all transform hover:scale-[1.02] active:scale-[0.98]\"\n            >\n              <svg className=\"w-4 h-4 fill-current\" viewBox=\"0 0 24 24\">\n                <path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.041-1.416-4.041-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\" />\n              </svg>\n              GitHub\n            </button>\n          </div>\n\n          <div className=\"mt-6 text-center\">\n            <p className=\"text-sm text-slate-400\">\n              Already have an account?{' '}\n              <Link to=\"/login\" className=\"text-indigo-400 hover:text-indigo-300 font-medium\">\n                Log in\n              </Link>\n            </p>\n          </div>\n\n          <div className=\"mt-8 pt-6 border-t border-slate-800 text-center\">\n            <p className=\"text-xs text-slate-500 mb-4\">\n              By continuing, you agree to our{' '}\n              <Link to=\"/terms\" className=\"text-indigo-400 hover:text-indigo-300 hover:underline\">Terms of Service</Link>\n              {' '}and{' '}\n              <Link to=\"/privacy\" className=\"text-indigo-400 hover:text-indigo-300 hover:underline\">Privacy Policy</Link>.\n            </p>\n            \n            <Link to=\"/\" className=\"inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors group\">\n              <ArrowLeftIcon className=\"w-4 h-4 group-hover:-translate-x-1 transition-transform\" />\n              Back to home\n            </Link>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/ResetPassword.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport { useEffect, useState } from 'react'\nimport { Link, useNavigate, useSearchParams } from 'react-router-dom'\nimport { ArrowLeftIcon } from '@heroicons/react/24/outline'\nimport { apiJson } from '../lib/api'\n\nexport default function ResetPassword() {\n  const [searchParams] = useSearchParams()\n  const token = searchParams.get('token')\n  const navigate = useNavigate()\n\n  const [password, setPassword] = useState('')\n  const [submitting, setSubmitting] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n  const [validToken, setValidToken] = useState<boolean | null>(null) // null = loading, true = valid, false = invalid\n\n  useEffect(() => {\n    if (!token) {\n      setValidToken(false)\n      return\n    }\n\n    apiJson(`/api/auth/password-reset/validate?token=${token}`, undefined, { skipAuth: true })\n      .then(() => setValidToken(true))\n      .catch(() => setValidToken(false))\n  }, [token])\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault()\n    setError(null)\n\n    if (password.length < 8) {\n        setError('Password must be at least 8 characters long')\n        return\n    }\n\n    setSubmitting(true)\n    try {\n      await apiJson('/api/auth/password-reset/confirm', {\n        method: 'POST',\n        body: JSON.stringify({ token, newPassword: password })\n      }, { skipAuth: true })\n      navigate('/login', { state: { message: 'Password reset successfully. Please login.' } })\n    } catch (err: any) {\n      setError(err.message || 'Failed to reset password')\n    } finally {\n      setSubmitting(false)\n    }\n  }\n\n  if (validToken === null) {\n      return (\n        <div className=\"min-h-screen flex items-center justify-center relative overflow-hidden\">\n          <div className=\"absolute inset-0 bg-slate-950\"></div>\n          <div className=\"absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-indigo-900/20 via-slate-900/0 to-slate-900/0 pointer-events-none\" />\n          <div className=\"w-full max-w-md p-6 relative z-10\">\n            <div className=\"bg-slate-900/50 border border-slate-800 rounded-2xl p-8 shadow-2xl backdrop-blur-sm h-64 animate-pulse\">\n            </div>\n          </div>\n        </div>\n      )\n  }\n\n  if (!validToken) {\n      return (\n        <div className=\"min-h-screen flex items-center justify-center relative overflow-hidden\">\n          <div className=\"absolute inset-0 bg-slate-950\"></div>\n          <div className=\"w-full max-w-md p-6 relative z-10\">\n            <div className=\"bg-slate-900/50 border border-slate-800 rounded-2xl p-8 shadow-2xl backdrop-blur-sm text-center\">\n               <h1 className=\"text-2xl font-bold text-red-500 mb-4\">Invalid or Expired Token</h1>\n               <p className=\"text-slate-400 mb-6\">This password reset link is invalid or has expired.</p>\n               <Link to=\"/forgot-password\" className=\"block w-full bg-indigo-600 hover:bg-indigo-500 text-white font-medium py-2.5 px-4 rounded-lg transition-all\">\n                 Request new link\n               </Link>\n            </div>\n          </div>\n        </div>\n      )\n  }\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center relative overflow-hidden\">\n      {/* Background gradients */}\n      <div className=\"absolute inset-0 bg-slate-950\"></div>\n      <div className=\"absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-indigo-900/20 via-slate-900/0 to-slate-900/0 pointer-events-none\" />\n      \n      <div className=\"w-full max-w-md p-6 relative z-10\">\n        <div className=\"bg-slate-900/50 border border-slate-800 rounded-2xl p-8 shadow-2xl backdrop-blur-sm\">\n          <div className=\"text-center mb-8\">\n            <Link to=\"/\" className=\"inline-block mb-6\">\n              <div className=\"flex items-center justify-center gap-2 text-xl font-bold text-white\">\n                <span className=\"relative flex h-3 w-3\">\n                   <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75\"></span>\n                   <span className=\"relative inline-flex rounded-full h-3 w-3 bg-indigo-500\"></span>\n                </span>\n                Port Buddy\n              </div>\n            </Link>\n            <h1 className=\"text-2xl font-bold text-white mb-2\">Set New Password</h1>\n            <p className=\"text-slate-400 text-sm\">\n              Create a secure password for your account.\n            </p>\n          </div>\n\n          <form onSubmit={handleSubmit} className=\"space-y-4 animate-in slide-in-from-top-2 fade-in duration-200\">\n            {error && (\n                <div className=\"bg-red-500/10 border border-red-500/50 text-red-400 text-sm p-3 rounded-lg text-center\">\n                    {error}\n                </div>\n            )}\n            <div>\n                <label className=\"block text-xs font-medium text-slate-400 mb-1\">New Password</label>\n                <input\n                    type=\"password\"\n                    value={password}\n                    onChange={(e) => setPassword(e.target.value)}\n                    required\n                    minLength={8}\n                    className=\"w-full bg-slate-950/50 border border-slate-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all\"\n                    placeholder=\"At least 8 characters\"\n                />\n                {password.length > 0 && password.length < 8 && (\n                     <p className=\"text-xs text-yellow-500 mt-1\">Password is too short (min 8 chars)</p>\n                )}\n            </div>\n            <button\n                type=\"submit\"\n                disabled={submitting}\n                className=\"w-full bg-indigo-600 hover:bg-indigo-500 text-white font-medium py-2.5 px-4 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-indigo-500/20\"\n              >\n                Set New Password\n              </button>\n          </form>\n\n          <div className=\"mt-8 pt-6 border-t border-slate-800 text-center\">\n             <Link to=\"/login\" className=\"inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors group\">\n              <ArrowLeftIcon className=\"w-4 h-4 group-hover:-translate-x-1 transition-transform\" />\n              Back to Login\n            </Link>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/ServerError.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport { Link, useSearchParams } from 'react-router-dom'\nimport { ArrowPathIcon, HomeIcon, WrenchScrewdriverIcon } from '@heroicons/react/24/outline'\nimport React, { useEffect, useState } from 'react'\n\nexport default function ServerError() {\n  const [searchParams] = useSearchParams()\n  const [retryUrl, setRetryUrl] = useState<string | null>(null)\n\n  // Extract and decode retry param once\n  useEffect(() => {\n    const raw = searchParams.get('retry')?.trim()\n    if (raw) {\n      try {\n        const decoded = decodeURIComponent(raw)\n        setRetryUrl(decoded)\n      } catch (_err) {\n        setRetryUrl(null)\n      }\n    }\n  // run once on mount\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  // Clean retry from address bar without navigation\n  useEffect(() => {\n    if (searchParams.has('retry')) {\n      const params = new URLSearchParams(searchParams as unknown as URLSearchParams)\n      params.delete('retry')\n      const queryString = params.toString()\n      const hash = window.location.hash || ''\n      const newUrl = window.location.pathname + (queryString ? `?${queryString}` : '') + hash\n      window.history.replaceState({}, document.title, newUrl)\n    }\n  }, [searchParams])\n  return (\n    <div className=\"flex flex-col gap-16 pb-24\">\n      <section className=\"relative pt-12 md:pt-36\">\n        <div className=\"absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-red-900/15 via-slate-900/0 to-slate-900/0 pointer-events-none\" />\n<div className=\"container relative max-w-4xl text-center\">\n          <div className=\"inline-flex items-center gap-2 px-3 py-1 rounded-full bg-red-500/10 border border-red-500/20 text-red-300 text-xs font-medium mb-6\">\n            <span className=\"relative flex h-2 w-2\">\n              <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75\"></span>\n              <span className=\"relative inline-flex rounded-full h-2 w-2 bg-red-500\"></span>\n            </span>\n            5xx — Server Error\n          </div>\n\n          <h1 className=\"text-4xl md:text-6xl font-extrabold tracking-tight text-white mb-6 leading-tight\">\n            Something went wrong on our side\n          </h1>\n\n          <p className=\"text-lg md:text-xl text-slate-400 mb-10 leading-relaxed max-w-2xl mx-auto\">\n            We are experiencing an internal issue. Please try again in a moment. If the problem persists, head back home or check your connection.\n          </p>\n\n          <div className=\"flex flex-col sm:flex-row items-center justify-center gap-4\">\n            <button\n              onClick={() => {\n                if (retryUrl && retryUrl.length > 0) {\n                  window.location.assign(retryUrl)\n                } else {\n                  window.location.reload()\n                }\n              }}\n              className=\"w-full sm:w-auto px-6 py-3 bg-red-600 hover:bg-red-500 text-white rounded-lg font-semibold transition-all flex items-center justify-center gap-2 shadow-lg shadow-red-500/20\"\n            >\n              <ArrowPathIcon className=\"w-5 h-5\" />\n              Try Again\n            </button>\n            <Link\n              to=\"/\"\n              className=\"w-full sm:w-auto px-6 py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-lg font-semibold transition-all flex items-center justify-center gap-2 border border-slate-700\"\n            >\n              <HomeIcon className=\"w-5 h-5\" />\n              Go to Home\n            </Link>\n          </div>\n\n          <div className=\"mt-14 mx-auto max-w-3xl bg-slate-900 rounded-xl border border-slate-800 shadow-2xl overflow-hidden\">\n            <div className=\"flex items-center px-4 py-3 bg-slate-800/50 border-b border-slate-800 gap-2\">\n              <div className=\"w-3 h-3 rounded-full bg-red-500/20 border border-red-500/50\"></div>\n              <div className=\"w-3 h-3 rounded-full bg-yellow-500/20 border border-yellow-500/50\"></div>\n              <div className=\"w-3 h-3 rounded-full bg-green-500/20 border border-green-500/50\"></div>\n              <div className=\"ml-2 text-xs text-slate-500 font-mono\">response — 5xx</div>\n            </div>\n            <div className=\"p-6 font-mono text-sm md:text-base overflow-x-auto text-left\">\n              <div className=\"flex items-center gap-2 text-slate-400 mb-3\">\n                <span className=\"text-red-400\">GET</span>\n                <span className=\"text-slate-300\">/some-endpoint</span>\n                <span className=\"text-slate-600\">→</span>\n                <span className=\"text-slate-500\">500 Internal Server Error</span>\n              </div>\n              <div className=\"rounded-lg bg-slate-950/60 border border-slate-800 p-4 text-slate-300\">\n                <div className=\"flex items-center gap-2 mb-2\">\n                  <WrenchScrewdriverIcon className=\"w-4 h-4 text-slate-500\" />\n                  <span className=\"text-slate-400\">You can try</span>\n                </div>\n                <ul className=\"list-disc pl-5 space-y-1 text-slate-400\">\n                  <li>Reload the page.</li>\n                  <li>Check your internet connection.</li>\n                  <li>Return to the homepage and try again.</li>\n                </ul>\n              </div>\n            </div>\n          </div>\n        </div>\n      </section>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/Terms.tsx",
    "content": "export default function Terms() {\n  return (\n    <div className=\"min-h-screen flex flex-col\">\n      <div className=\"flex-1 relative pt-12 md:pt-32 pb-20\">\n        <div className=\"absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-900/10 via-slate-900/0 to-slate-900/0 pointer-events-none\" />\n        \n        <div className=\"container max-w-3xl relative z-10\">\n          <h1 className=\"text-4xl font-bold text-white mb-8\">Terms and Conditions</h1>\n          \n          <div className=\"prose prose-invert max-w-none text-slate-300 space-y-6\">\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">1. Acceptance of Terms</h2>\n              <p>\n                By accessing or using Port Buddy, you agree to be bound by these Terms and Conditions. If you do not agree with any part of these terms, you may not use our services.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">2. Description of Service</h2>\n              <p>\n                Port Buddy provides a tool that allows you to share a port opened on your local host or private network to the public network. It is a proxy service that facilitates remote access to local development environments.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">3. User Accounts</h2>\n              <p>\n                To use certain features of Port Buddy, you must register for an account. You are responsible for maintaining the confidentiality of your account credentials and for all activities that occur under your account.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">4. Prohibited Use</h2>\n              <p>\n                You agree not to use Port Buddy for any illegal or unauthorized purpose. Prohibited activities include, but are not limited to:\n              </p>\n              <ul className=\"list-disc pl-6 space-y-2\">\n                <li>Sharing content that violates any laws or regulations.</li>\n                <li>Distributing malware or performing malicious attacks.</li>\n                <li>Attempting to circumvent any security features of the service.</li>\n                <li>Using the service for high-traffic production workloads beyond your subscription limits.</li>\n              </ul>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">5. Subscription and Billing</h2>\n              <p>\n                Port Buddy offers both free and paid subscription plans. By subscribing to a paid plan, you agree to pay the specified fees. Fees are non-refundable except as required by law.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">6. Limitation of Liability</h2>\n              <p>\n                Port Buddy is provided \"as is\" without any warranties. We shall not be liable for any indirect, incidental, or consequential damages arising out of your use of the service.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-semibold text-white mb-4\">7. Changes to Terms</h2>\n              <p>\n                We reserve the right to modify these terms at any time. We will notify users of any significant changes by posting the new terms on our website.\n              </p>\n            </section>\n\n            <footer className=\"pt-8 border-t border-slate-800 text-sm text-slate-500\">\n              Last updated: January 8, 2026\n            </footer>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/AdminAccounts.tsx",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\nimport { useEffect, useMemo, useState } from 'react'\nimport { Link } from 'react-router-dom'\nimport { EllipsisHorizontalIcon, LockClosedIcon, LockOpenIcon, CheckCircleIcon, XCircleIcon, ClipboardIcon } from '@heroicons/react/24/outline'\nimport { apiJson } from '../../lib/api'\nimport { usePageTitle } from '../../components/PageHeader'\nimport { formatDateTime } from '../../lib/utils'\nimport { ConfirmModal } from '../../components/Modal'\n\nexport type AdminAccountRow = {\n  accountId: string\n  name: string\n  plan: string\n  extraTunnels: number\n  activeTunnels: number\n  blocked: boolean\n  createdAt: string\n}\n\nexport default function AdminAccounts() {\n  usePageTitle('Admin • Accounts')\n\n  const [rows, setRows] = useState<AdminAccountRow[] | null>(null)\n  const [openMenuId, setOpenMenuId] = useState<string | null>(null)\n  const [search, setSearch] = useState<string>('')\n  const [confirmBlock, setConfirmBlock] = useState<AdminAccountRow | null>(null)\n\n  const refresh = (s?: string) => {\n    const qs = s && s.trim().length > 0 ? `?search=${encodeURIComponent(s.trim())}` : ''\n    void apiJson<AdminAccountRow[]>(`/api/admin/accounts${qs}`)\n      .then(setRows)\n      .catch(() => setRows([]))\n  }\n\n  useEffect(() => {\n    refresh()\n    const onDocClick = (e: MouseEvent) => {\n      const target = e.target as HTMLElement\n      if (!target.closest('[data-menu-root]')) setOpenMenuId(null)\n    }\n    document.addEventListener('click', onDocClick)\n    return () => document.removeEventListener('click', onDocClick)\n  }, [])\n\n  useEffect(() => {\n    const h = setTimeout(() => refresh(search), 300)\n    return () => clearTimeout(h)\n  }, [search])\n\n  const onToggleBlock = async (row: AdminAccountRow) => {\n    const path = row.blocked\n      ? `/api/admin/accounts/${row.accountId}/unblock`\n      : `/api/admin/accounts/${row.accountId}/block`\n    try {\n      await apiJson<void>(path, { method: 'POST' })\n      refresh()\n    } finally {\n      setOpenMenuId(null)\n    }\n  }\n\n  const copyToClipboard = (text: string) => {\n    void navigator.clipboard.writeText(text)\n    setOpenMenuId(null)\n  }\n\n  const data = useMemo(() => rows ?? [], [rows])\n\n  return (\n    <div className=\"flex flex-col max-w-6xl\">\n      <div className=\"flex items-center justify-between mb-6\">\n        <h2 className=\"text-xl font-semibold text-white\">Accounts</h2>\n        <Link to=\"/app/admin\" className=\"text-sm text-slate-400 hover:text-white\">Back to Admin</Link>\n      </div>\n\n      <div className=\"overflow-hidden rounded-xl border border-slate-800 bg-slate-900 shadow-xl\">\n        <div className=\"px-6 py-4 border-b border-slate-800 bg-slate-800/50 flex items-center justify-between gap-4\">\n          <div className=\"font-semibold text-white\">Accounts ({data.length})</div>\n          <div className=\"ml-auto\">\n            <input\n              type=\"text\"\n              value={search}\n              onChange={(e) => setSearch(e.target.value)}\n              placeholder=\"Search by name or ID...\"\n              className=\"w-72 rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500\"\n            />\n          </div>\n        </div>\n\n        <div className=\"overflow-x-auto\">\n          <table className=\"min-w-full text-sm\">\n            <thead className=\"bg-slate-800/50\">\n              <tr>\n                <th className=\"px-6 py-3 text-left font-semibold text-slate-300\">Account Name</th>\n                <th className=\"px-6 py-3 text-left font-semibold text-slate-300\">Plan</th>\n                <th className=\"px-6 py-3 text-left font-semibold text-slate-300\">Extra tunnels</th>\n                <th className=\"px-6 py-3 text-left font-semibold text-slate-300\">Active tunnels</th>\n                <th className=\"px-6 py-3 text-left font-semibold text-slate-300\">Blocked</th>\n                <th className=\"px-6 py-3 text-left font-semibold text-slate-300\">Created at</th>\n                <th className=\"px-6 py-3 text-right font-semibold text-slate-300\">Actions</th>\n              </tr>\n            </thead>\n            <tbody>\n              {data.length === 0 && (\n                <tr>\n                  <td colSpan={7} className=\"px-6 py-10 text-center text-slate-500\">No accounts</td>\n                </tr>\n              )}\n              {data.map((r) => (\n                <tr key={r.accountId} className=\"border-t border-slate-800 hover:bg-slate-800/30\">\n                  <td className=\"px-6 py-3 text-white\">{r.name}</td>\n                  <td className=\"px-6 py-3 text-slate-300\">{r.plan}</td>\n                  <td className=\"px-6 py-3 text-slate-300\">{r.extraTunnels}</td>\n                  <td className=\"px-6 py-3 text-slate-300\">{r.activeTunnels}</td>\n                  <td className=\"px-6 py-3\">\n                    {r.blocked ? (\n                      <div className=\"inline-flex items-center gap-1 text-red-400\"><XCircleIcon className=\"w-4 h-4\"/> <span>Yes</span></div>\n                    ) : (\n                      <div className=\"inline-flex items-center gap-1 text-emerald-400\"><CheckCircleIcon className=\"w-4 h-4\"/> <span>No</span></div>\n                    )}\n                  </td>\n                  <td className=\"px-6 py-3 text-slate-400\">{formatDateTime(r.createdAt)}</td>\n                  <td className=\"px-6 py-3 text-right\">\n                    <div className=\"relative inline-block text-left\" data-menu-root>\n                      <button\n                        className=\"inline-flex items-center justify-center w-8 h-8 rounded-lg border border-slate-700 text-slate-400 hover:text-white hover:bg-slate-800\"\n                        onClick={(e) => { e.stopPropagation(); setOpenMenuId((v) => v === r.accountId ? null : r.accountId) }}\n                        aria-label=\"Actions\"\n                      >\n                        <EllipsisHorizontalIcon className=\"w-5 h-5\" />\n                      </button>\n                      {openMenuId === r.accountId && (\n                        <div className=\"absolute right-0 mt-2 w-56 rounded-xl border border-slate-800 bg-slate-900 shadow-2xl p-1 z-20\">\n                          <button\n                            className=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-slate-300 hover:text-white hover:bg-slate-800\"\n                            onClick={() => copyToClipboard(r.accountId)}\n                          >\n                            <ClipboardIcon className=\"w-4 h-4\"/> Copy ID\n                          </button>\n                          <div className=\"my-1 border-t border-slate-800\" />\n                          <button\n                            className=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-slate-300 hover:text-white hover:bg-slate-800\"\n                            onClick={() => r.blocked ? onToggleBlock(r) : setConfirmBlock(r)}\n                          >\n                            {r.blocked ? (\n                              <><LockOpenIcon className=\"w-4 h-4\"/> Unblock account</>\n                            ) : (\n                              <><LockClosedIcon className=\"w-4 h-4\"/> Block account</>\n                            )}\n                          </button>\n                        </div>\n                      )}\n                    </div>\n                  </td>\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>\n      </div>\n      {confirmBlock && (\n        <ConfirmModal\n          isOpen={!!confirmBlock}\n          onClose={() => setConfirmBlock(null)}\n          onConfirm={() => onToggleBlock(confirmBlock)}\n          title=\"Block account?\"\n          message={`Are you sure you want to block account \\\"${confirmBlock.name}\\\". Users will lose access until it is unblocked.`}\n          confirmText=\"Block account\"\n          isDangerous\n        />\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/AdminPanel.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport { useEffect, useState } from 'react'\nimport { usePageTitle } from '../../components/PageHeader'\nimport { apiJson } from '../../lib/api'\nimport { Link } from 'react-router-dom'\n\ntype SystemStats = { totalUsers: number, activeTunnels: number, totalAccounts: number }\n type DailyStat = { date: string, newUsersCount: number, tunnelsCount: number, paymentEvents: number }\n\nexport default function AdminPanel() {\n  usePageTitle('Admin Control Center')\n\n  const [stats, setStats] = useState<SystemStats | null>(null)\n  const [daily, setDaily] = useState<DailyStat[] | null>(null)\n\n  useEffect(() => {\n    void Promise.all([\n      apiJson<SystemStats>('/api/admin/stats').catch(() => null),\n      apiJson<DailyStat[]>('/api/admin/stats/daily').catch(() => null),\n    ]).then(([s, d]) => {\n      setStats(s)\n      setDaily(d)\n    })\n  }, [])\n\n  const formatDay = (iso: string): string => {\n    const dt = new Date(iso)\n    const dd = dt.getDate().toString().padStart(2, '0')\n    const mm = (dt.getMonth() + 1).toString().padStart(2, '0')\n    const yy = dt.getFullYear().toString().slice(-2)\n    return `${dd}.${mm}.${yy}`\n  }\n\n  return (\n    <div className=\"flex flex-col max-w-6xl\">\n      <div className=\"grid grid-cols-1 md:grid-cols-3 gap-6\">\n        <Link to=\"/app/admin/accounts\" className=\"bg-slate-900 border border-slate-800 rounded-xl p-6 shadow-xl hover:bg-slate-800/50 transition-colors\">\n          <h3 className=\"text-lg font-semibold text-white mb-2\">Total Accounts</h3>\n          <p className=\"text-3xl font-bold text-amber-400\">{stats ? stats.totalAccounts : '---'}</p>\n        </Link>\n\n        <Link to=\"/app/admin/users\" className=\"bg-slate-900 border border-slate-800 rounded-xl p-6 shadow-xl hover:bg-slate-800/50 transition-colors\">\n          <h3 className=\"text-lg font-semibold text-white mb-2\">Total Users</h3>\n          <p className=\"text-3xl font-bold text-indigo-400\">{stats ? stats.totalUsers : '---'}</p>\n        </Link>\n\n        <Link to=\"/app/admin/tunnels\" className=\"bg-slate-900 border border-slate-800 rounded-xl p-6 shadow-xl hover:bg-slate-800/50 transition-colors\">\n          <h3 className=\"text-lg font-semibold text-white mb-2\">Active Tunnels</h3>\n          <p className=\"text-3xl font-bold text-emerald-400\">{stats ? stats.activeTunnels : '---'}</p>\n        </Link>\n      </div>\n\n      <div className=\"mt-8 bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-xl\">\n        <div className=\"px-6 py-4 border-b border-slate-800 bg-slate-800/50\">\n          <h3 className=\"font-semibold text-white\">Last 30 days stats</h3>\n        </div>\n        <div className=\"p-0 overflow-x-auto\">\n          <table className=\"min-w-full divide-y divide-slate-800\">\n            <thead className=\"bg-slate-800/40\">\n              <tr>\n                <th className=\"px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider\">Date</th>\n                <th className=\"px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider\">New users</th>\n                <th className=\"px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider\">Tunnels</th>\n                <th className=\"px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider\">Payment events</th>\n              </tr>\n            </thead>\n            <tbody className=\"bg-slate-900 divide-y divide-slate-800\">\n              {daily && daily.length > 0 ? (\n                daily.map((r, idx) => (\n                  <tr key={idx} className=\"hover:bg-slate-800/30\">\n                    <td className=\"px-6 py-3 whitespace-nowrap text-slate-200\">{formatDay(r.date)}</td>\n                    <td className=\"px-6 py-3 whitespace-nowrap text-slate-200\">{r.newUsersCount}</td>\n                    <td className=\"px-6 py-3 whitespace-nowrap text-slate-200\">{r.tunnelsCount}</td>\n                    <td className=\"px-6 py-3 whitespace-nowrap text-slate-200\">{r.paymentEvents}</td>\n                  </tr>\n                ))\n              ) : (\n                <tr>\n                  <td colSpan={4} className=\"px-6 py-12 text-center text-slate-500\">{daily === null ? 'Loading...' : 'No data'}</td>\n                </tr>\n              )}\n            </tbody>\n          </table>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/AdminTunnels.tsx",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\nimport { useEffect, useMemo, useState } from 'react'\nimport { Link } from 'react-router-dom'\nimport { EllipsisHorizontalIcon, GlobeAltIcon, ServerIcon, XCircleIcon, ClipboardIcon, ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'\nimport { apiJson } from '../../lib/api'\nimport { usePageTitle } from '../../components/PageHeader'\nimport { formatDateTime } from '../../lib/utils'\nimport { ConfirmModal } from '../../components/Modal'\n\nexport type AdminTunnelRow = {\n  id: string\n  type: 'HTTP' | 'TCP'\n  localAddress: string\n  publicAddress: string\n  lastActivity: string | null\n  userName: string\n  userId: string\n  accountId: string\n}\n\nexport default function AdminTunnels() {\n  usePageTitle('Admin • Active Tunnels')\n\n  const [rows, setRows] = useState<AdminTunnelRow[] | null>(null)\n  const [openMenuId, setOpenMenuId] = useState<string | null>(null)\n  const [search, setSearch] = useState<string>('')\n  const [confirmClose, setConfirmClose] = useState<AdminTunnelRow | null>(null)\n\n  const refresh = (s?: string) => {\n    const qs = s && s.trim().length > 0 ? `?search=${encodeURIComponent(s.trim())}` : ''\n    void apiJson<AdminTunnelRow[]>(`/api/admin/tunnels${qs}`)\n      .then(setRows)\n      .catch(() => setRows([]))\n  }\n\n  useEffect(() => {\n    refresh()\n    const onDocClick = (e: MouseEvent) => {\n      const target = e.target as HTMLElement\n      if (!target.closest('[data-menu-root]')) setOpenMenuId(null)\n    }\n    document.addEventListener('click', onDocClick)\n    return () => document.removeEventListener('click', onDocClick)\n  }, [])\n\n  useEffect(() => {\n    const h = setTimeout(() => refresh(search), 300)\n    return () => clearTimeout(h)\n  }, [search])\n\n  const onCloseTunnel = async (row: AdminTunnelRow) => {\n    try {\n      await apiJson<void>(`/api/admin/tunnels/${row.id}/close`, { method: 'POST' })\n      refresh()\n    } finally {\n      setOpenMenuId(null)\n      setConfirmClose(null)\n    }\n  }\n\n  const copyToClipboard = (text: string) => {\n    void navigator.clipboard.writeText(text)\n    setOpenMenuId(null)\n  }\n\n  const data = useMemo(() => rows ?? [], [rows])\n\n  return (\n    <div className=\"flex flex-col max-w-6xl\">\n      <div className=\"flex items-center justify-between mb-6\">\n        <h2 className=\"text-xl font-semibold text-white\">Active Tunnels</h2>\n        <Link to=\"/app/admin\" className=\"text-sm text-slate-400 hover:text-white\">Back to Admin</Link>\n      </div>\n\n      <div className=\"overflow-hidden rounded-xl border border-slate-800 bg-slate-900 shadow-xl\">\n        <div className=\"px-6 py-4 border-b border-slate-800 bg-slate-800/50 flex items-center justify-between gap-4\">\n          <div className=\"font-semibold text-white\">Active Tunnels ({data.length})</div>\n          <div className=\"ml-auto\">\n            <input\n              type=\"text\"\n              value={search}\n              onChange={(e) => setSearch(e.target.value)}\n              placeholder=\"Search by user or public address...\"\n              className=\"w-72 rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500\"\n            />\n          </div>\n        </div>\n\n        <div className=\"overflow-x-auto\">\n          <table className=\"min-w-full text-sm text-left\">\n            <thead className=\"bg-slate-800/50\">\n              <tr>\n                <th className=\"px-6 py-3 font-semibold text-slate-300\">Type</th>\n                <th className=\"px-6 py-3 font-semibold text-slate-300\">Local Address</th>\n                <th className=\"px-6 py-3 font-semibold text-slate-300\">Public Address</th>\n                <th className=\"px-6 py-3 font-semibold text-slate-300\">Last Activity</th>\n                <th className=\"px-6 py-3 font-semibold text-slate-300\">User Name</th>\n                <th className=\"px-6 py-3 text-right font-semibold text-slate-300\">Actions</th>\n              </tr>\n            </thead>\n            <tbody>\n              {data.length === 0 && (\n                <tr>\n                  <td colSpan={6} className=\"px-6 py-10 text-center text-slate-500\">No active tunnels found</td>\n                </tr>\n              )}\n              {data.map((r) => {\n                const isHttp = r.type === 'HTTP'\n                const publicUrl = isHttp && r.publicAddress.startsWith('http') ? r.publicAddress : null\n\n                return (\n                  <tr key={r.id} className=\"border-t border-slate-800 hover:bg-slate-800/30\">\n                    <td className=\"px-6 py-3\">\n                      <div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${\n                        isHttp \n                          ? 'bg-indigo-500/10 text-indigo-400 border-indigo-500/20' \n                          : 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20'\n                      }`}>\n                        {isHttp ? <GlobeAltIcon className=\"w-3 h-3\" /> : <ServerIcon className=\"w-3 h-3\" />}\n                        {r.type}\n                      </div>\n                    </td>\n                    <td className=\"px-6 py-3 text-slate-300 font-mono text-xs\">{r.localAddress}</td>\n                    <td className=\"px-6 py-3\">\n                      {publicUrl ? (\n                        <a href={publicUrl} target=\"_blank\" rel=\"noopener noreferrer\" className=\"flex items-center gap-1.5 text-indigo-400 hover:text-indigo-300 hover:underline font-mono text-xs break-all group\">\n                          {r.publicAddress}\n                          <ArrowTopRightOnSquareIcon className=\"w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity\" />\n                        </a>\n                      ) : (\n                        <span className=\"text-slate-400 font-mono text-xs break-all\">{r.publicAddress}</span>\n                      )}\n                    </td>\n                    <td className=\"px-6 py-3 text-slate-400 text-xs\">\n                      {r.lastActivity ? formatDateTime(r.lastActivity) : '-'}\n                    </td>\n                    <td className=\"px-6 py-3 text-white\">{r.userName || 'Unknown'}</td>\n                    <td className=\"px-6 py-3 text-right\">\n                      <div className=\"relative inline-block text-left\" data-menu-root>\n                        <button\n                          className=\"inline-flex items-center justify-center w-8 h-8 rounded-lg border border-slate-700 text-slate-400 hover:text-white hover:bg-slate-800\"\n                          onClick={(e) => { e.stopPropagation(); setOpenMenuId((v) => v === r.id ? null : r.id) }}\n                          aria-label=\"Actions\"\n                        >\n                          <EllipsisHorizontalIcon className=\"w-5 h-5\" />\n                        </button>\n                        {openMenuId === r.id && (\n                          <div className=\"absolute right-0 mt-2 w-56 rounded-xl border border-slate-800 bg-slate-900 shadow-2xl p-1 z-20\">\n                            <button\n                              className=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-slate-300 hover:text-white hover:bg-slate-800\"\n                              onClick={() => copyToClipboard(r.userId)}\n                            >\n                              <ClipboardIcon className=\"w-4 h-4\"/> Copy User ID\n                            </button>\n                            <button\n                              className=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-slate-300 hover:text-white hover:bg-slate-800\"\n                              onClick={() => copyToClipboard(r.accountId)}\n                            >\n                              <ClipboardIcon className=\"w-4 h-4\"/> Copy Account ID\n                            </button>\n                            <div className=\"my-1 border-t border-slate-800\" />\n                            <button\n                              className=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-red-400 hover:text-red-300 hover:bg-slate-800\"\n                              onClick={() => setConfirmClose(r)}\n                            >\n                              <XCircleIcon className=\"w-4 h-4\"/> Close tunnel\n                            </button>\n                          </div>\n                        )}\n                      </div>\n                    </td>\n                  </tr>\n                )\n              })}\n            </tbody>\n          </table>\n        </div>\n      </div>\n\n      {confirmClose && (\n        <ConfirmModal\n          isOpen={!!confirmClose}\n          onClose={() => setConfirmClose(null)}\n          onConfirm={() => onCloseTunnel(confirmClose)}\n          title=\"Close tunnel?\"\n          message={`Are you sure you want to close tunnel from \\\"${confirmClose.userName}\\\" exposing \\\"${confirmClose.localAddress}\\\"?`}\n          confirmText=\"Close tunnel\"\n          isDangerous\n        />\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/AdminUsers.tsx",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\nimport { useEffect, useMemo, useState } from 'react'\nimport { Link } from 'react-router-dom'\nimport { EllipsisHorizontalIcon, LockClosedIcon, LockOpenIcon, CheckCircleIcon, XCircleIcon, ClipboardIcon } from '@heroicons/react/24/outline'\nimport { apiJson } from '../../lib/api'\nimport { usePageTitle } from '../../components/PageHeader'\nimport { formatDateTime } from '../../lib/utils'\nimport { ConfirmModal } from '../../components/Modal'\n\nexport type AdminUserRow = {\n  id: string\n  accountId: string\n  name: string\n  email: string\n  activeTunnels: number\n  blocked: boolean\n  createdAt: string\n}\n\nexport default function AdminUsers() {\n  usePageTitle('Admin • Users')\n\n  const [rows, setRows] = useState<AdminUserRow[] | null>(null)\n  const [openMenuId, setOpenMenuId] = useState<string | null>(null)\n  const [search, setSearch] = useState<string>('')\n  const [confirmBlock, setConfirmBlock] = useState<AdminUserRow | null>(null)\n\n  const refresh = (s?: string) => {\n    const qs = s && s.trim().length > 0 ? `?search=${encodeURIComponent(s.trim())}` : ''\n    void apiJson<AdminUserRow[]>(`/api/admin/users${qs}`)\n      .then(setRows)\n      .catch(() => setRows([]))\n  }\n\n  useEffect(() => {\n    refresh()\n    const onDocClick = (e: MouseEvent) => {\n      const target = e.target as HTMLElement\n      if (!target.closest('[data-menu-root]')) setOpenMenuId(null)\n    }\n    document.addEventListener('click', onDocClick)\n    return () => document.removeEventListener('click', onDocClick)\n  }, [])\n\n  useEffect(() => {\n    const h = setTimeout(() => refresh(search), 300)\n    return () => clearTimeout(h)\n  }, [search])\n\n  const onToggleBlock = async (row: AdminUserRow) => {\n    const path = row.blocked\n      ? `/api/admin/accounts/${row.accountId}/unblock`\n      : `/api/admin/accounts/${row.accountId}/block`\n    try {\n      await apiJson<void>(path, { method: 'POST' })\n      refresh()\n    } finally {\n      setOpenMenuId(null)\n    }\n  }\n\n  const copyToClipboard = (text: string) => {\n    void navigator.clipboard.writeText(text)\n    setOpenMenuId(null)\n  }\n\n  const data = useMemo(() => rows ?? [], [rows])\n\n  return (\n    <div className=\"flex flex-col max-w-6xl\">\n      <div className=\"flex items-center justify-between mb-6\">\n        <h2 className=\"text-xl font-semibold text-white\">Users</h2>\n        <Link to=\"/app/admin\" className=\"text-sm text-slate-400 hover:text-white\">Back to Admin</Link>\n      </div>\n\n      <div className=\"overflow-hidden rounded-xl border border-slate-800 bg-slate-900 shadow-xl\">\n        <div className=\"px-6 py-4 border-b border-slate-800 bg-slate-800/50 flex items-center justify-between gap-4\">\n          <div className=\"font-semibold text-white\">Users ({data.length})</div>\n          <div className=\"ml-auto\">\n            <input\n              type=\"text\"\n              value={search}\n              onChange={(e) => setSearch(e.target.value)}\n              placeholder=\"Search by name, email or ID...\"\n              className=\"w-72 rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500\"\n            />\n          </div>\n        </div>\n\n        <div className=\"overflow-x-auto\">\n          <table className=\"min-w-full text-sm\">\n            <thead className=\"bg-slate-800/50\">\n              <tr>\n                <th className=\"px-6 py-3 text-left font-semibold text-slate-300\">User Name</th>\n                <th className=\"px-6 py-3 text-left font-semibold text-slate-300\">Email</th>\n                <th className=\"px-6 py-3 text-left font-semibold text-slate-300\">Active tunnels</th>\n                <th className=\"px-6 py-3 text-left font-semibold text-slate-300\">Created at</th>\n                <th className=\"px-6 py-3 text-right font-semibold text-slate-300\">Actions</th>\n              </tr>\n            </thead>\n            <tbody>\n              {data.length === 0 && (\n                <tr>\n                  <td colSpan={5} className=\"px-6 py-10 text-center text-slate-500\">No users</td>\n                </tr>\n              )}\n              {data.map((r) => (\n                <tr key={r.id} className=\"border-t border-slate-800 hover:bg-slate-800/30\">\n                  <td className=\"px-6 py-3 text-white\">{r.name}</td>\n                  <td className=\"px-6 py-3 text-slate-300\">{r.email}</td>\n                  <td className=\"px-6 py-3 text-slate-300\">{r.activeTunnels}</td>\n                  <td className=\"px-6 py-3 text-slate-400\">{formatDateTime(r.createdAt)}</td>\n                  <td className=\"px-6 py-3 text-right\">\n                    <div className=\"relative inline-block text-left\" data-menu-root>\n                      <button\n                        className=\"inline-flex items-center justify-center w-8 h-8 rounded-lg border border-slate-700 text-slate-400 hover:text-white hover:bg-slate-800\"\n                        onClick={(e) => { e.stopPropagation(); setOpenMenuId((v) => v === r.id ? null : r.id) }}\n                        aria-label=\"Actions\"\n                      >\n                        <EllipsisHorizontalIcon className=\"w-5 h-5\" />\n                      </button>\n                      {openMenuId === r.id && (\n                        <div className=\"absolute right-0 mt-2 w-56 rounded-xl border border-slate-800 bg-slate-900 shadow-2xl p-1 z-20\">\n                          <button\n                            className=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-slate-300 hover:text-white hover:bg-slate-800\"\n                            onClick={() => copyToClipboard(r.id)}\n                          >\n                            <ClipboardIcon className=\"w-4 h-4\"/> Copy ID\n                          </button>\n                          <button\n                            className=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-slate-300 hover:text-white hover:bg-slate-800\"\n                            onClick={() => copyToClipboard(r.accountId)}\n                          >\n                            <ClipboardIcon className=\"w-4 h-4\"/> Copy account ID\n                          </button>\n                          <div className=\"my-1 border-t border-slate-800\" />\n                          <button\n                            className=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-slate-300 hover:text-white hover:bg-slate-800\"\n                            onClick={() => r.blocked ? onToggleBlock(r) : setConfirmBlock(r)}\n                          >\n                            {r.blocked ? (\n                              <><LockOpenIcon className=\"w-4 h-4\"/> Unblock account</>\n                            ) : (\n                              <><LockClosedIcon className=\"w-4 h-4\"/> Block account</>\n                            )}\n                          </button>\n                        </div>\n                      )}\n                    </div>\n                  </td>\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>\n      </div>\n      {confirmBlock && (\n        <ConfirmModal\n          isOpen={!!confirmBlock}\n          onClose={() => setConfirmBlock(null)}\n          onConfirm={() => onToggleBlock(confirmBlock)}\n          title=\"Block account?\"\n          message={`Are you sure you want to block the account for user \\\"${confirmBlock.name}\\\". Users will lose access until it is unblocked.`}\n          confirmText=\"Block account\"\n          isDangerous\n        />\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/Billing.tsx",
    "content": "import { useState, useEffect } from 'react'\nimport { Link, useSearchParams } from 'react-router-dom'\nimport { useAuth } from '../../auth/AuthContext'\nimport { usePageTitle } from '../../components/PageHeader'\nimport { CheckIcon, ArrowLeftIcon, PlusIcon, MinusIcon } from '@heroicons/react/24/outline'\nimport { apiJson } from '../../lib/api'\nimport PlanComparison from '../../components/PlanComparison'\nimport { ConfirmModal } from '../../components/Modal'\n\nexport default function Billing() {\n  usePageTitle('Billing')\n  const { user, refresh } = useAuth()\n  const [searchParams, setSearchParams] = useSearchParams()\n  const [updating, setUpdating] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n  const [success, setSuccess] = useState(false)\n  const [pendingExtra, setPendingExtra] = useState<number | null>(null)\n  const [loading, setLoading] = useState(false)\n  const [confirmConfig, setConfirmConfig] = useState<{\n    isOpen: boolean,\n    title: string,\n    message: string,\n    onConfirm: () => void,\n    isDangerous?: boolean\n  }>({\n    isOpen: false,\n    title: '',\n    message: '',\n    onConfirm: () => {},\n    isDangerous: false\n  })\n\n  useEffect(() => {\n    if (searchParams.get('success')) {\n      setSuccess(true)\n      refresh()\n      // Remove query param\n      const newParams = new URLSearchParams(searchParams)\n      newParams.delete('success')\n      setSearchParams(newParams, { replace: true })\n    }\n    if (searchParams.get('canceled')) {\n      setError('Payment was canceled.')\n      const newParams = new URLSearchParams(searchParams)\n      newParams.delete('canceled')\n      setSearchParams(newParams, { replace: true })\n    }\n  }, [searchParams, refresh, setSearchParams])\n\n  const plans: { key: 'pro' | 'team', name: string, price: string, period?: string, description: string, features: string[] }[] = [\n    { \n      key: 'pro', \n      name: 'Pro', \n      price: '$0', \n      description: 'Everything you need for personal exposure.',\n      features: [\n        'HTTP, TCP, UDP tunnels',\n        'SSL for HTTP tunnels',\n        'Static subdomains',\n        'Custom domains',\n        'Private tunnels',\n        'Web socket support',\n        '1 free tunnel at a time',\n        '$1/mo per extra tunnel'\n      ] \n    },\n    { \n      key: 'team', \n      name: 'Team', \n      price: '$10', \n      period: '/mo',\n      description: 'For teams and collaborative projects.',\n      features: [\n        'Everything in Pro',\n        'Team members',\n        'SSO (Coming soon)',\n        'Priority support',\n        '10 free tunnels at a time',\n        '$1/mo per extra tunnel'\n      ] \n    },\n  ]\n\n  const currentPlanKey = user?.plan || 'pro'\n  const extraTunnels = user?.extraTunnels || 0\n  const baseTunnels = user?.baseTunnels || 1\n  const activeTunnels = user?.activeTunnels || 0\n  const subscriptionStatus = user?.subscriptionStatus\n  const effectiveExtra = pendingExtra !== null ? pendingExtra : extraTunnels\n\n  const planPrice = currentPlanKey === 'team' ? 10 : 0\n  const extraCost = effectiveExtra * 1\n  const totalMonthly = planPrice + extraCost\n  const increment = currentPlanKey === 'team' ? 5 : 1\n\n  const getLimitForPlan = (planKey: string) => {\n    return planKey === 'team' ? 10 : 1;\n  };\n\n  const handleUpdate = async () => {\n    if (pendingExtra === null || pendingExtra === extraTunnels) return\n\n    const newLimit = baseTunnels + pendingExtra;\n    if (pendingExtra < extraTunnels && activeTunnels > newLimit) {\n      setConfirmConfig({\n        isOpen: true,\n        title: 'Reduce Tunnel Limit',\n        message: `Reducing extra tunnels will lower your total limit to ${newLimit}. You currently have ${activeTunnels} active tunnels. Excess tunnels will be automatically closed. Do you want to proceed?`,\n        onConfirm: () => performUpdate(),\n        isDangerous: true\n      });\n      return;\n    }\n\n    performUpdate();\n  }\n\n  const performUpdate = async () => {\n    if (pendingExtra === null) return;\n\n    setError(null)\n    setUpdating(true)\n    setLoading(true)\n    try {\n      const response = await apiJson('/api/users/me/account/tunnels', {\n        method: 'PATCH',\n        body: JSON.stringify({ extraTunnels: pendingExtra })\n      })\n      \n      if (response.checkoutUrl) {\n        window.location.href = response.checkoutUrl\n        return\n      }\n\n      await refresh()\n      setPendingExtra(null)\n      setSuccess(true)\n    } catch (e: any) {\n      setError(e.message || 'Failed to update tunnels')\n    } finally {\n      setUpdating(false)\n      setLoading(false)\n    }\n  }\n\n  const changePending = (newExtra: number) => {\n    if (newExtra < 0) return\n    setPendingExtra(newExtra)\n  }\n\n  const handleUpgrade = async (planKey: string) => {\n    const isDowngrade = currentPlanKey === 'team' && planKey === 'pro';\n    const newLimit = getLimitForPlan(planKey) + (isDowngrade ? 0 : extraTunnels);\n    \n    if (isDowngrade) {\n        setConfirmConfig({\n            isOpen: true,\n            title: 'Downgrade to PRO',\n            message: 'Cancelling your TEAM subscription will reset your extra tunnels to 0 and downgrade your account to PRO. Do you want to proceed?',\n            onConfirm: () => checkTunnelLimitAndUpgrade(planKey, newLimit),\n            isDangerous: true\n        });\n        return;\n    } else if (subscriptionStatus === 'active') {\n        setConfirmConfig({\n            isOpen: true,\n            title: 'Switch Plan',\n            message: `Switching to ${planKey.toUpperCase()} will cancel your current subscription and reset extra tunnels to 0. You will be redirected to payment for the new plan. Do you want to proceed?`,\n            onConfirm: () => checkTunnelLimitAndUpgrade(planKey, newLimit),\n            isDangerous: false\n        });\n        return;\n    }\n\n    checkTunnelLimitAndUpgrade(planKey, newLimit);\n  }\n\n  const checkTunnelLimitAndUpgrade = (planKey: string, newLimit: number) => {\n    if (activeTunnels > newLimit) {\n        setConfirmConfig({\n            isOpen: true,\n            title: 'Reduce Tunnel Limit',\n            message: `This change will reduce your tunnel limit to ${newLimit}. You currently have ${activeTunnels} active tunnels. Excess tunnels will be automatically closed. Do you want to proceed?`,\n            onConfirm: () => performUpgrade(planKey),\n            isDangerous: true\n        });\n        return;\n    }\n\n    performUpgrade(planKey);\n  }\n\n  const performUpgrade = async (planKey: string) => {\n    setError(null)\n    setLoading(true)\n    try {\n      if (subscriptionStatus === 'active') {\n        await apiJson('/api/payments/cancel-subscription', {\n          method: 'POST'\n        })\n        await refresh()\n      }\n\n      if (planKey === 'pro') {\n          setSuccess(true)\n          return\n      }\n\n      const { url } = await apiJson('/api/payments/create-checkout-session', {\n        method: 'POST',\n        body: JSON.stringify({ plan: planKey.toUpperCase() })\n      })\n      window.location.href = url\n    } catch (e: any) {\n      setError(e.message || 'Failed to initiate checkout')\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  const handleManageBilling = async () => {\n    setError(null)\n    setLoading(true)\n    try {\n      const { url } = await apiJson('/api/payments/create-portal-session', {\n        method: 'POST'\n      })\n      window.location.href = url\n    } catch (e: any) {\n      setError(e.message || 'Failed to initiate portal session')\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  return (\n    <div className=\"max-w-6xl mx-auto\">\n      <div className=\"mb-8 flex flex-col md:flex-row md:items-end justify-between gap-4\">\n        <div>\n          <h2 className=\"text-2xl font-bold text-white\">Billing & Plans</h2>\n          <p className=\"text-slate-400 mt-1\">Choose the plan that fits your needs.</p>\n        </div>\n        {subscriptionStatus && (\n          <div className=\"flex items-center gap-2 bg-slate-900 border border-slate-800 px-4 py-2 rounded-xl\">\n            <span className=\"text-sm text-slate-400\">Subscription Status:</span>\n            <span className={`text-sm font-bold uppercase tracking-wider ${\n              subscriptionStatus === 'active' ? 'text-green-400' : \n              subscriptionStatus === 'past_due' ? 'text-yellow-400' : 'text-red-400'\n            }`}>\n              {subscriptionStatus.replace('_', ' ')}\n            </span>\n          </div>\n        )}\n      </div>\n\n      {success && (\n        <div className=\"mb-8 p-4 bg-green-900/30 border border-green-500/50 rounded-xl text-green-400 text-center\">\n          Success! Your subscription has been updated.\n        </div>\n      )}\n\n      <div className=\"grid lg:grid-cols-2 gap-8 max-w-4xl mx-auto lg:mx-0\">\n        {plans.map((p) => {\n           const isCurrent = p.key === currentPlanKey\n           const isPopular = p.key === 'team'\n\n           return (\n            <div \n              key={p.key} \n              className={`relative flex flex-col p-8 rounded-2xl border transition-all duration-300 ${\n                isPopular \n                  ? 'bg-slate-900/80 border-indigo-500 shadow-2xl shadow-indigo-500/10' \n                  : 'bg-slate-900/40 border-slate-800 hover:border-slate-700'\n              }`}\n            >\n              {isPopular && (\n                <div className=\"absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 px-4 py-1 bg-indigo-600 text-white text-xs font-bold uppercase tracking-wide rounded-full shadow-lg shadow-indigo-500/20\">\n                  Most Popular\n                </div>\n              )}\n\n              <div className=\"mb-6\">\n                <h3 className={`text-lg font-bold ${isPopular ? 'text-white' : 'text-slate-200'}`}>{p.name}</h3>\n                <div className=\"mt-2 flex items-baseline gap-1\">\n                  <span className=\"text-4xl font-bold text-white\">{p.price}</span>\n                  {p.period && <span className=\"text-slate-500\">{p.period}</span>}\n                </div>\n                <p className=\"text-slate-400 text-sm mt-2\">{p.description}</p>\n              </div>\n\n              <ul className=\"space-y-4 mb-8 flex-1\">\n                {p.features.map((f, i) => (\n                  <li key={i} className=\"flex items-start gap-3 text-sm text-slate-300\">\n                    <CheckIcon className={`w-5 h-5 flex-shrink-0 ${isPopular ? 'text-indigo-400' : 'text-slate-500'}`} />\n                    <span>{f}</span>\n                  </li>\n                ))}\n              </ul>\n\n              {isCurrent && (\n                <div className=\"mb-8 p-4 rounded-xl bg-slate-800/50 border border-slate-700\">\n                  <div className=\"flex justify-between items-center mb-4 border-b border-slate-700/50 pb-4\">\n                    <div>\n                      <div className=\"text-sm font-medium text-slate-300\">Total Tunnels</div>\n                      <div className=\"text-2xl font-bold text-white\">{baseTunnels + effectiveExtra}</div>\n                    </div>\n                    <div className=\"text-right\">\n                      <div className=\"text-xs text-slate-500 uppercase tracking-wider\">Extra Tunnels</div>\n                      <div className=\"text-lg font-semibold text-indigo-400\">+{effectiveExtra} (${extraCost}/mo)</div>\n                    </div>\n                  </div>\n\n                  <div className=\"flex justify-between items-center mb-6\">\n                    <div className=\"text-sm text-slate-400\">Total Monthly</div>\n                    <div className=\"text-xl font-bold text-white\">${totalMonthly}<span className=\"text-sm font-normal text-slate-500\">/mo</span></div>\n                  </div>\n                  \n                  <div className=\"flex items-center gap-4 mb-4\">\n                    <button\n                      onClick={() => changePending(effectiveExtra - increment)}\n                      disabled={updating || effectiveExtra <= 0}\n                      className=\"p-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n                    >\n                      <MinusIcon className=\"w-5 h-5\" />\n                    </button>\n                    <div className=\"flex-1 text-center font-mono text-white\">\n                      {effectiveExtra < extraTunnels ? `Remove ${increment}` : `Add ${increment}`}\n                    </div>\n                    <button\n                      onClick={() => changePending(effectiveExtra + increment)}\n                      disabled={updating}\n                      className=\"p-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n                    >\n                      <PlusIcon className=\"w-5 h-5\" />\n                    </button>\n                  </div>\n\n                  {pendingExtra !== null && pendingExtra !== extraTunnels && (\n                    <div className=\"flex gap-2\">\n                      <button\n                        onClick={handleUpdate}\n                        disabled={updating}\n                        className={`flex-1 py-2 text-sm font-semibold rounded-lg shadow-lg transition-all disabled:opacity-50 ${\n                          currentPlanKey === 'pro' && pendingExtra === 0 \n                            ? 'bg-red-600 hover:bg-red-500 shadow-red-500/20' \n                            : 'bg-indigo-600 hover:bg-indigo-500 shadow-indigo-500/20'\n                        } text-white`}\n                      >\n                        {currentPlanKey === 'pro' && pendingExtra === 0 ? 'Cancel subscription' : 'Update my subscription'}\n                      </button>\n                      <button\n                        onClick={() => setPendingExtra(null)}\n                        disabled={updating}\n                        className=\"px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm font-semibold rounded-lg transition-all disabled:opacity-50\"\n                      >\n                        Cancel\n                      </button>\n                    </div>\n                  )}\n                  {error && <p className=\"mt-2 text-xs text-red-400\">{error}</p>}\n                </div>\n              )}\n\n              <button \n                onClick={() => handleUpgrade(p.key)}\n                className={`w-full py-3 rounded-lg font-semibold transition-all ${\n                  isCurrent\n                    ? 'bg-slate-800 text-slate-400 cursor-default border border-slate-700'\n                    : isPopular\n                    ? 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25'\n                    : 'bg-slate-800 hover:bg-slate-700 text-white border border-slate-700'\n                } disabled:opacity-50`}\n                disabled={isCurrent || loading}\n              >\n                {isCurrent ? 'Current Plan' : (currentPlanKey === 'team' && p.key === 'pro' ? 'Downgrade' : 'Upgrade')}\n              </button>\n            </div>\n          )\n        })}\n      </div>\n\n      <div className=\"mt-8 flex flex-col items-center\">\n        {user?.stripeCustomerId && (\n          <button\n            onClick={handleManageBilling}\n            disabled={loading}\n            className=\"px-6 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm font-semibold rounded-lg border border-slate-700 transition-all disabled:opacity-50\"\n          >\n            Manage Billing & Subscriptions\n          </button>\n        )}\n        {error && <p className=\"mt-4 text-sm text-red-400\">{error}</p>}\n      </div>\n\n      <div className=\"mt-12 text-center\">\n        <p className=\"text-slate-500 text-sm mb-4\">\n          Need a custom enterprise plan? <a href=\"#\" className=\"text-indigo-400 hover:text-indigo-300 hover:underline\">Contact us</a>\n        </p>\n        <Link to=\"/app\" className=\"inline-flex items-center gap-2 text-slate-400 hover:text-white transition-colors text-sm\">\n          <ArrowLeftIcon className=\"w-4 h-4\" />\n          Back to dashboard\n        </Link>\n      </div>\n\n      <ConfirmModal\n        isOpen={confirmConfig.isOpen}\n        onClose={() => setConfirmConfig({ ...confirmConfig, isOpen: false })}\n        onConfirm={confirmConfig.onConfirm}\n        title={confirmConfig.title}\n        message={confirmConfig.message}\n        isDangerous={confirmConfig.isDangerous}\n      />\n\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/BillingCancel.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport { Link } from 'react-router-dom'\nimport { XCircleIcon } from '@heroicons/react/24/outline'\nimport { usePageTitle } from '../../components/PageHeader'\n\nexport default function BillingCancel() {\n  usePageTitle('Payment Canceled')\n\n  return (\n    <div className=\"max-w-2xl mx-auto pt-12 px-4 text-center\">\n      <div className=\"bg-slate-900 border border-slate-800 rounded-2xl p-8 md:p-12 shadow-xl\">\n        <div className=\"w-20 h-20 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-8\">\n          <XCircleIcon className=\"w-12 h-12 text-red-500\" />\n        </div>\n        \n        <h1 className=\"text-3xl font-bold text-white mb-4\">Payment Canceled</h1>\n        <p className=\"text-slate-400 text-lg mb-10\">\n          Your payment process was canceled. No charges were made to your account.\n          If you encountered any issues, please feel free to contact our support.\n        </p>\n\n        <div className=\"flex flex-col sm:flex-row items-center justify-center gap-4\">\n          <Link\n            to=\"/app/billing\"\n            className=\"w-full sm:w-auto px-8 py-3 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg font-semibold transition-all\"\n          >\n            Back to Billing\n          </Link>\n          <Link\n            to=\"/app\"\n            className=\"w-full sm:w-auto px-8 py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-lg font-semibold transition-all border border-slate-700\"\n          >\n            Return to Dashboard\n          </Link>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/BillingSuccess.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport { Link } from 'react-router-dom'\nimport { CheckCircleIcon } from '@heroicons/react/24/outline'\nimport { usePageTitle } from '../../components/PageHeader'\n\nexport default function BillingSuccess() {\n  usePageTitle('Payment Successful')\n\n  return (\n    <div className=\"max-w-2xl mx-auto pt-12 px-4 text-center\">\n      <div className=\"bg-slate-900 border border-slate-800 rounded-2xl p-8 md:p-12 shadow-xl\">\n        <div className=\"w-20 h-20 bg-green-500/10 rounded-full flex items-center justify-center mx-auto mb-8\">\n          <CheckCircleIcon className=\"w-12 h-12 text-green-500\" />\n        </div>\n        \n        <h1 className=\"text-3xl font-bold text-white mb-4\">Payment Successful!</h1>\n        <p className=\"text-slate-400 text-lg mb-10\">\n          Thank you for your purchase. Your subscription has been updated successfully.\n          It might take a few moments for the changes to reflect in your account.\n        </p>\n\n        <div className=\"flex flex-col sm:flex-row items-center justify-center gap-4\">\n          <Link\n            to=\"/app\"\n            className=\"w-full sm:w-auto px-8 py-3 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg font-semibold transition-all\"\n          >\n            Return to Dashboard\n          </Link>\n          <Link\n            to=\"/app/billing\"\n            className=\"w-full sm:w-auto px-8 py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-lg font-semibold transition-all border border-slate-700\"\n          >\n            View Billing\n          </Link>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/Domains.tsx",
    "content": "import { useEffect, useState } from 'react'\nimport { useAuth } from '../../auth/AuthContext'\nimport { usePageTitle } from '../../components/PageHeader'\nimport { GlobeAltIcon, PlusIcon, TrashIcon, PencilIcon, CheckIcon, XMarkIcon, LockClosedIcon, LockOpenIcon } from '@heroicons/react/24/outline'\nimport { apiJson } from '../../lib/api'\nimport { AlertModal, ConfirmModal, Modal } from '../../components/Modal'\n\ninterface Domain {\n  id: string\n  subdomain: string\n  domain: string\n  customDomain: string | null\n  cnameVerified: boolean\n  sslActive: boolean\n  passcodeProtected: boolean\n  createdAt: string\n  updatedAt: string\n}\n\nexport default function Domains() {\n  const { user } = useAuth()\n  usePageTitle('Domains')\n  \n  const [domains, setDomains] = useState<Domain[]>([])\n  const [loading, setLoading] = useState(true)\n  const [error, setError] = useState('')\n  const [editingId, setEditingId] = useState<string | null>(null)\n  const [editValue, setEditValue] = useState('')\n  const [creating, setCreating] = useState(false)\n\n  // Custom Domain state\n  const [customDomainDomainId, setCustomDomainDomainId] = useState<string | null>(null)\n  const [customDomainValue, setCustomDomainValue] = useState('')\n  const [customDomainSaving, setCustomDomainSaving] = useState(false)\n  const [customDomainRemoving, setCustomDomainRemoving] = useState(false)\n  const [verifyingCname, setVerifyingCname] = useState(false)\n\n  // Passcode modal state\n  const [passcodeDomainId, setPasscodeDomainId] = useState<string | null>(null)\n  const [pass1, setPass1] = useState('')\n  const [passSaving, setPassSaving] = useState(false)\n  const [passRemoving, setPassRemoving] = useState(false)\n\n  // Dialog states\n  const [alertState, setAlertState] = useState<{ isOpen: boolean, title: string, message: string }>({ \n    isOpen: false, \n    title: '', \n    message: '' \n  })\n  const [deleteId, setDeleteId] = useState<string | null>(null)\n\n  const fetchDomains = async () => {\n    try {\n      const data = await apiJson<Domain[]>('/api/domains')\n      setDomains(data)\n    } catch (err) {\n      setError('Failed to load domains')\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  useEffect(() => {\n    fetchDomains()\n  }, [])\n\n  const handleAdd = async () => {\n    setCreating(true)\n    try {\n      const newDomain = await apiJson<Domain>('/api/domains', { method: 'POST' })\n      setDomains([...domains, newDomain])\n    } catch (err: any) {\n        setAlertState({ \n            isOpen: true, \n            title: 'Error', \n            message: err.message || 'Failed to add domain' \n        })\n    } finally {\n        setCreating(false)\n    }\n  }\n  \n  const handleEditStart = (domain: Domain) => {\n      setEditingId(domain.id)\n      setEditValue(domain.subdomain)\n  }\n  \n  const handleEditCancel = () => {\n      setEditingId(null)\n      setEditValue('')\n  }\n\n  const handleEditSave = async (id: string) => {\n      try {\n          const updated = await apiJson<Domain>(`/api/domains/${id}`, {\n              method: 'PUT',\n              body: JSON.stringify({ subdomain: editValue })\n          })\n          setDomains(domains.map(d => d.id === id ? updated : d))\n          setEditingId(null)\n      } catch (err: any) {\n          setAlertState({ \n            isOpen: true, \n            title: 'Error', \n            message: err.message || 'Failed to update domain' \n        })\n      }\n  }\n  \n  const handleDeleteClick = (id: string) => {\n      setDeleteId(id)\n  }\n\n  const handleConfirmDelete = async () => {\n      if (!deleteId) return\n      try {\n          await apiJson(`/api/domains/${deleteId}`, { method: 'DELETE' })\n          setDomains(domains.filter(d => d.id !== deleteId))\n      } catch (err: any) {\n          setAlertState({ \n            isOpen: true, \n            title: 'Error', \n            message: err.message || 'Failed to delete domain' \n        })\n      } finally {\n          setDeleteId(null)\n      }\n  }\n\n  const handleCustomDomainClick = (domain: Domain) => {\n    setCustomDomainDomainId(domain.id)\n    setCustomDomainValue(domain.customDomain || '')\n  }\n\n  const handleCustomDomainSave = async () => {\n    if (!customDomainDomainId) return\n    setCustomDomainSaving(true)\n    try {\n      const updated = await apiJson<Domain>(`/api/domains/${customDomainDomainId}/custom-domain`, {\n        method: 'PUT',\n        body: JSON.stringify({ customDomain: customDomainValue })\n      })\n      setDomains(domains.map(d => d.id === customDomainDomainId ? updated : d))\n      setCustomDomainDomainId(null)\n    } catch (err: any) {\n      setAlertState({\n        isOpen: true,\n        title: 'Error',\n        message: err.message || 'Failed to update custom domain'\n      })\n    } finally {\n      setCustomDomainSaving(false)\n    }\n  }\n\n  const handleCustomDomainRemove = async () => {\n    if (!customDomainDomainId) return\n    setCustomDomainRemoving(true)\n    try {\n      await apiJson(`/api/domains/${customDomainDomainId}/custom-domain`, {\n        method: 'DELETE'\n      })\n      setDomains(domains.map(d => d.id === customDomainDomainId ? { ...d, customDomain: null, cnameVerified: false } : d))\n      setCustomDomainDomainId(null)\n    } catch (err: any) {\n      setAlertState({\n        isOpen: true,\n        title: 'Error',\n        message: err.message || 'Failed to remove custom domain'\n      })\n    } finally {\n      setCustomDomainRemoving(false)\n    }\n  }\n\n  const handleVerifyCname = async (id: string) => {\n    setVerifyingCname(true)\n    try {\n      const updated = await apiJson<Domain>(`/api/domains/${id}/verify-cname`, { method: 'POST' })\n      setDomains(domains.map(d => d.id === id ? updated : d))\n      setAlertState({\n        isOpen: true,\n        title: 'Success',\n        message: 'CNAME verified successfully! SSL certificate issuance has been triggered.'\n      })\n    } catch (err: any) {\n      setAlertState({\n        isOpen: true,\n        title: 'Error',\n        message: err.message || 'CNAME verification failed'\n      })\n    } finally {\n      setVerifyingCname(false)\n    }\n  }\n\n  const openSetPasscode = (id: string) => {\n    setPasscodeDomainId(id)\n    setPass1('')\n  }\n\n  const closePasscodeModal = () => {\n    setPasscodeDomainId(null)\n    setPass1('')\n    setPassSaving(false)\n    setPassRemoving(false)\n  }\n\n  const savePasscode = async () => {\n    if (!passcodeDomainId) return\n    if (pass1.length < 4) {\n      setAlertState({ isOpen: true, title: 'Invalid passcode', message: 'Passcode must be at least 4 characters long.' })\n      return\n    }\n    setPassSaving(true)\n    try {\n      const updated = await apiJson<Domain>(`/api/domains/${passcodeDomainId}/passcode`, {\n        method: 'PUT',\n        body: JSON.stringify({ passcode: pass1 })\n      })\n      setDomains(domains.map(d => d.id === updated.id ? updated : d))\n      closePasscodeModal()\n    } catch (err: any) {\n      setPassSaving(false)\n      setAlertState({ isOpen: true, title: 'Error', message: err.message || 'Failed to set passcode' })\n    }\n  }\n\n  const removePasscode = async (id: string) => {\n    try {\n      setPassRemoving(true)\n      await apiJson(`/api/domains/${id}/passcode`, { method: 'DELETE' })\n      setDomains(domains.map(d => d.id === id ? { ...d, passcodeProtected: false } : d))\n      closePasscodeModal()\n    } catch (err: any) {\n      setPassRemoving(false)\n      setAlertState({ isOpen: true, title: 'Error', message: err.message || 'Failed to remove passcode' })\n    }\n  }\n\n  return (\n    <div className=\"max-w-6xl\">\n      <AlertModal \n        isOpen={alertState.isOpen} \n        onClose={() => setAlertState({ ...alertState, isOpen: false })}\n        title={alertState.title}\n        message={alertState.message}\n      />\n\n      <ConfirmModal\n        isOpen={!!deleteId}\n        onClose={() => setDeleteId(null)}\n        onConfirm={handleConfirmDelete}\n        title=\"Delete Domain\"\n        message=\"Are you sure you want to delete this domain? This action cannot be undone.\"\n        confirmText=\"Delete\"\n        isDangerous\n      />\n\n      {/* Set/Change Passcode Modal */}\n      <Modal\n        isOpen={!!passcodeDomainId}\n        onClose={closePasscodeModal}\n        title={(domains.find(d => d.id === passcodeDomainId)?.passcodeProtected ? 'Change' : 'Set') + ' Domain Passcode'}\n      >\n        <div className=\"space-y-4\">\n          <div>\n            <label className=\"block text-sm text-slate-300 mb-1\">Passcode</label>\n            <input\n              type=\"password\"\n              value={pass1}\n              onChange={e => setPass1(e.target.value)}\n              className=\"w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white focus:outline-none focus:border-indigo-500\"\n              placeholder=\"Enter passcode\"\n            />\n          </div>\n          <div className=\"flex items-center justify-between gap-3 pt-2\">\n            {/* Left side: Remove Passcode (only if currently protected) */}\n            {domains.find(d => d.id === passcodeDomainId)?.passcodeProtected && (\n              <button\n                onClick={() => passcodeDomainId && removePasscode(passcodeDomainId)}\n                disabled={passSaving || passRemoving}\n                className=\"px-4 py-2 text-sm font-medium text-red-400 hover:text-white hover:bg-red-500/10 rounded-lg transition-colors disabled:opacity-50\"\n              >\n                {passRemoving ? 'Removing...' : 'Remove Passcode'}\n              </button>\n            )}\n\n            {/* Right side: Cancel + Save */}\n            <div className=\"ml-auto flex items-center gap-3\">\n              <button onClick={closePasscodeModal} className=\"px-4 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800 rounded-lg transition-colors\">\n                Cancel\n              </button>\n              <button\n                onClick={savePasscode}\n                disabled={passSaving || passRemoving}\n                className=\"px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors disabled:opacity-50\"\n              >\n                {passSaving ? 'Saving...' : 'Save Passcode'}\n              </button>\n            </div>\n          </div>\n        </div>\n      </Modal>\n\n      {/* Custom Domain Modal */}\n      <Modal\n        isOpen={!!customDomainDomainId}\n        onClose={() => setCustomDomainDomainId(null)}\n        title=\"Custom Domain\"\n      >\n        <div className=\"space-y-4\">\n          <div>\n            <p className=\"text-sm text-slate-400 mb-4\">\n              Bind your own custom domain (e.g., <code className=\"text-indigo-400\">app.mycompany.com</code>) to your Port Buddy subdomain.\n              Ensure you have a CNAME record pointing to your subdomain first.\n            </p>\n            <label className=\"block text-sm text-slate-300 mb-1\">Custom Domain</label>\n            <input\n              type=\"text\"\n              value={customDomainValue}\n              onChange={e => setCustomDomainValue(e.target.value)}\n              className=\"w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white focus:outline-none focus:border-indigo-500\"\n              placeholder=\"e.g. app.mycompany.com\"\n            />\n          </div>\n          <div className=\"flex items-center justify-between gap-3 pt-2\">\n            {domains.find(d => d.id === customDomainDomainId)?.customDomain && (\n              <button\n                onClick={handleCustomDomainRemove}\n                disabled={customDomainSaving || customDomainRemoving}\n                className=\"px-4 py-2 text-sm font-medium text-red-400 hover:text-white hover:bg-red-500/10 rounded-lg transition-colors disabled:opacity-50\"\n              >\n                {customDomainRemoving ? 'Removing...' : 'Remove Custom Domain'}\n              </button>\n            )}\n            <div className=\"ml-auto flex items-center gap-3\">\n              <button\n                onClick={() => setCustomDomainDomainId(null)}\n                className=\"px-4 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800 rounded-lg transition-colors\"\n              >\n                Cancel\n              </button>\n              <button\n                onClick={handleCustomDomainSave}\n                disabled={customDomainSaving || customDomainRemoving}\n                className=\"px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors disabled:opacity-50\"\n              >\n                {customDomainSaving ? 'Saving...' : 'Save Custom Domain'}\n              </button>\n            </div>\n          </div>\n        </div>\n      </Modal>\n\n      <div className=\"flex items-center justify-between mb-8\">\n        <div>\n            <h2 className=\"text-2xl font-bold text-white\">Domains</h2>\n            <p className=\"text-slate-400 mt-1\">Manage your custom domains and static subdomains.</p>\n        </div>\n        <button \n            onClick={handleAdd}\n            disabled={creating}\n            className=\"inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed\"\n        >\n            {creating ? (\n                <div className=\"w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin\" />\n            ) : (\n                <PlusIcon className=\"w-5 h-5\" />\n            )}\n            Add Domain\n        </button>\n      </div>\n\n      {loading ? (\n          <div className=\"grid gap-4\">\n              {[1, 2].map(i => (\n                  <div key={i} className=\"bg-slate-900/50 border border-slate-800 rounded-xl p-6 h-24 animate-pulse\"></div>\n              ))}\n          </div>\n      ) : error ? (\n          <div className=\"bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-lg mb-6\">\n              {error}\n          </div>\n      ) : domains.length === 0 ? (\n          <div className=\"bg-slate-900/50 border border-slate-800 rounded-xl p-12 text-center\">\n              <div className=\"inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-800/50 text-slate-500 mb-6\">\n                  <GlobeAltIcon className=\"w-8 h-8\" />\n              </div>\n              <h3 className=\"text-xl font-bold text-white mb-3\">No domains yet</h3>\n              <p className=\"text-slate-400 max-w-md mx-auto mb-8\">\n                  Create your first static subdomain to get started.\n              </p>\n              <button \n                onClick={handleAdd}\n                disabled={creating}\n                className=\"inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50\"\n              >\n                <PlusIcon className=\"w-5 h-5\" />\n                Add Domain\n              </button>\n          </div>\n      ) : (\n          <div className=\"grid gap-4\">\n              {domains.map(domain => (\n                  <div key={domain.id} className=\"bg-slate-900/50 border border-slate-800 rounded-xl p-6 flex items-center justify-between group hover:border-indigo-500/30 transition-all\">\n                      <div className=\"flex items-center gap-4\">\n                          <div className=\"p-3 rounded-lg bg-indigo-500/10 text-indigo-400\">\n                              <GlobeAltIcon className=\"w-6 h-6\" />\n                          </div>\n                          <div>\n                              {editingId === domain.id ? (\n                                  <div className=\"flex items-center gap-2\">\n                                      <input\n                                          type=\"text\"\n                                          value={editValue}\n                                          onChange={(e) => setEditValue(e.target.value)}\n                                          className=\"bg-slate-800 border border-slate-700 rounded px-2 py-1 text-white focus:outline-none focus:border-indigo-500\"\n                                          autoFocus\n                                      />\n                                      <span className=\"text-slate-500\">.{domain.domain}</span>\n                                  </div>\n                              ) : (\n                                  <div className=\"text-lg font-medium text-white\">\n                                      {domain.subdomain}.{domain.domain}\n                                  </div>\n                              )}\n                              <div className=\"text-sm text-slate-500 mt-1\">\n                                  Created on {new Date(domain.createdAt).toLocaleDateString()}\n                              </div>\n                              {domain.customDomain && (\n                                <div className=\"mt-3 flex flex-col gap-2\">\n                                  <div className=\"flex items-center gap-2\">\n                                    <span className=\"text-xs font-semibold uppercase tracking-wider text-slate-500\">Custom Domain:</span>\n                                    <span className=\"text-sm text-indigo-400 font-mono\">{domain.customDomain}</span>\n                                    {domain.cnameVerified ? (\n                                      domain.sslActive ? (\n                                        <span className=\"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-400/10 text-green-400\" title=\"CNAME is verified and SSL certificate is active.\">\n                                          <CheckIcon className=\"w-3 h-3 mr-1\" />\n                                          Verified & SSL Active\n                                        </span>\n                                      ) : (\n                                        <span className=\"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-400/10 text-green-400\" title=\"CNAME is verified. SSL certificate is being provisioned.\">\n                                          <CheckIcon className=\"w-3 h-3 mr-1\" />\n                                          Verified. SSL Provisioning...\n                                        </span>\n                                      )\n                                    ) : (\n                                      <span className=\"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-400/10 text-yellow-400\">\n                                        Pending Verification\n                                      </span>\n                                    )}\n                                  </div>\n                                  {!domain.cnameVerified && (\n                                    <button\n                                      onClick={() => handleVerifyCname(domain.id)}\n                                      disabled={verifyingCname}\n                                      className=\"text-xs text-indigo-400 hover:text-indigo-300 flex items-center gap-1 transition-colors disabled:opacity-50\"\n                                    >\n                                      {verifyingCname ? (\n                                        <div className=\"w-3 h-3 border border-indigo-400/30 border-t-indigo-400 rounded-full animate-spin\" />\n                                      ) : (\n                                        <CheckIcon className=\"w-3 h-3\" />\n                                      )}\n                                      Verify CNAME & Issue SSL\n                                    </button>\n                                  )}\n                                </div>\n                              )}\n                          </div>\n                      </div>\n                      \n                      <div className=\"flex items-center gap-2\">\n                          {editingId === domain.id ? (\n                              <>\n                                  <button\n                                      onClick={() => handleEditSave(domain.id)}\n                                      className=\"p-2 text-green-400 hover:bg-green-400/10 rounded-lg transition-colors\"\n                                      title=\"Save\"\n                                  >\n                                      <CheckIcon className=\"w-5 h-5\" />\n                                  </button>\n                                  <button\n                                      onClick={handleEditCancel}\n                                      className=\"p-2 text-slate-400 hover:bg-slate-700 rounded-lg transition-colors\"\n                                      title=\"Cancel\"\n                                  >\n                                      <XMarkIcon className=\"w-5 h-5\" />\n                                  </button>\n                              </>\n                          ) : (\n                              <>\n                                  <button\n                                    onClick={() => handleCustomDomainClick(domain)}\n                                    className=\"p-2 text-slate-400 hover:text-indigo-400 hover:bg-indigo-400/10 rounded-lg transition-colors\"\n                                    title=\"Custom Domain\"\n                                  >\n                                    <GlobeAltIcon className=\"w-5 h-5\" />\n                                  </button>\n                                  <button\n                                      onClick={() => handleEditStart(domain)}\n                                      className=\"p-2 text-slate-400 hover:text-indigo-400 hover:bg-indigo-400/10 rounded-lg transition-colors\"\n                                      title=\"Edit Subdomain\"\n                                  >\n                                      <PencilIcon className=\"w-5 h-5\" />\n                                  </button>\n                                  {/* Passcode control icon */}\n                                  <button\n                                    onClick={() => openSetPasscode(domain.id)}\n                                    className={`p-2 rounded-lg transition-colors ${domain.passcodeProtected\n                                      ? 'text-green-400 hover:bg-green-400/10'\n                                      : 'text-slate-400 hover:text-indigo-400 hover:bg-indigo-400/10'}`}\n                                    title={domain.passcodeProtected ? 'Change passcode' : 'Set passcode'}\n                                    aria-label={domain.passcodeProtected ? 'Change passcode' : 'Set passcode'}\n                                  >\n                                    {domain.passcodeProtected ? (\n                                      <LockClosedIcon className=\"w-5 h-5\" />\n                                    ) : (\n                                      <LockOpenIcon className=\"w-5 h-5\" />\n                                    )}\n                                  </button>\n                                  <button\n                                      onClick={() => handleDeleteClick(domain.id)}\n                                      className=\"p-2 text-slate-400 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors\"\n                                      title=\"Delete\"\n                                  >\n                                      <TrashIcon className=\"w-5 h-5\" />\n                                  </button>\n                              </>\n                          )}\n                      </div>\n                  </div>\n              ))}\n          </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/Ports.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport { useEffect, useMemo, useState } from 'react'\nimport { useAuth } from '../../auth/AuthContext'\nimport { usePageTitle } from '../../components/PageHeader'\nimport { GlobeAltIcon, PlusIcon, TrashIcon, PencilIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline'\nimport { apiJson } from '../../lib/api'\nimport { AlertModal, ConfirmModal } from '../../components/Modal'\n\ninterface PortReservation {\n  id: string\n  publicHost: string\n  publicPort: number\n  name: string\n  createdAt: string\n  updatedAt: string\n}\n\nexport default function Ports() {\n  const { user } = useAuth()\n  usePageTitle('Port Reservations')\n\n  const [items, setItems] = useState<PortReservation[]>([])\n  const [loading, setLoading] = useState(true)\n  const [creating, setCreating] = useState(false)\n  const [error, setError] = useState('')\n\n  // Edit state\n  const [editingId, setEditingId] = useState<string | null>(null)\n  const [hosts, setHosts] = useState<string[]>([])\n  const [selectedHost, setSelectedHost] = useState<string>('')\n  const [portRange, setPortRange] = useState<{ min: number, max: number } | null>(null)\n  const [editPort, setEditPort] = useState<string>('')\n  const [editName, setEditName] = useState<string>('')\n\n  // Dialogs\n  const [alertState, setAlertState] = useState<{ isOpen: boolean, title: string, message: string }>({\n    isOpen: false, title: '', message: ''\n  })\n  const [deleteId, setDeleteId] = useState<string | null>(null)\n\n  const load = async () => {\n    try {\n      const data = await apiJson<PortReservation[]>('/api/ports')\n      setItems(data)\n    } catch (e) {\n      setError('Failed to load port reservations')\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  useEffect(() => { load() }, [])\n\n  const fetchHosts = async () => {\n    const hs = await apiJson<string[]>('/api/ports/hosts')\n    setHosts(hs)\n    return hs\n  }\n\n  const fetchRange = async (host: string) => {\n    const r = await apiJson<{ min: number, max: number }>(`/api/ports/hosts/${encodeURIComponent(host)}/range`)\n    setPortRange(r)\n    return r\n  }\n\n  const startEdit = async (res: PortReservation) => {\n    setEditingId(res.id)\n    setEditName(res.name || '')\n    try {\n      const hs = await fetchHosts()\n      if (hs.length <= 1) {\n        // Only port is editable\n        setSelectedHost(res.publicHost)\n        const r = await fetchRange(res.publicHost)\n        setEditPort(String(res.publicPort))\n      } else {\n        setSelectedHost(res.publicHost)\n        await fetchRange(res.publicHost)\n        setEditPort(String(res.publicPort))\n      }\n    } catch (e: any) {\n      setAlertState({ isOpen: true, title: 'Error', message: e.message || 'Failed to start editing' })\n      setEditingId(null)\n    }\n  }\n\n  const cancelEdit = () => {\n    setEditingId(null)\n    setHosts([])\n    setPortRange(null)\n    setSelectedHost('')\n    setEditPort('')\n    setEditName('')\n  }\n\n  const saveEdit = async (id: string) => {\n    try {\n      const body: any = {}\n      if (hosts.length > 1) body.publicHost = selectedHost\n      body.publicPort = Number(editPort)\n      body.name = editName\n      const updated = await apiJson<PortReservation>(`/api/ports/${id}`, { method: 'PUT', body: JSON.stringify(body) })\n      setItems(items.map(i => i.id === id ? updated : i))\n      cancelEdit()\n    } catch (e: any) {\n      let msg = e.message || 'Failed to update reservation'\n      if (e.status === 400 && msg.includes('already exists')) {\n        msg = 'This name is already taken. Please choose a different name.'\n      }\n      setAlertState({ isOpen: true, title: 'Update failed', message: msg })\n    }\n  }\n\n  const addReservation = async () => {\n    setCreating(true)\n    try {\n      const created = await apiJson<PortReservation>('/api/ports', { method: 'POST' })\n      setItems([...items, created])\n    } catch (e: any) {\n      setAlertState({ isOpen: true, title: 'Error', message: e.message || 'Failed to create reservation' })\n    } finally {\n      setCreating(false)\n    }\n  }\n\n  const confirmDelete = async () => {\n    if (!deleteId) return\n    try {\n      await apiJson(`/api/ports/${deleteId}`, { method: 'DELETE' })\n      setItems(items.filter(i => i.id !== deleteId))\n      setDeleteId(null)\n    } catch (e: any) {\n      const msg = (e.status === 409)\n        ? 'Cannot delete this reservation because there are active TCP tunnels using it. Close them first and try again.'\n        : (e.message || 'Failed to delete reservation')\n      setAlertState({ isOpen: true, title: 'Delete failed', message: msg })\n    }\n  }\n\n  const singleHost = hosts.length <= 1\n  const portHint = portRange ? `Allowed range: ${portRange.min} - ${portRange.max}` : ''\n\n  return (\n    <div>\n      <AlertModal isOpen={alertState.isOpen} title={alertState.title} message={alertState.message} onClose={() => setAlertState({ ...alertState, isOpen: false })} />\n      <ConfirmModal isOpen={!!deleteId} title=\"Delete Port Reservation\" message=\"Are you sure you want to delete this reservation?\" onClose={() => setDeleteId(null)} onConfirm={() => void confirmDelete()} />\n\n      <div className=\"flex items-center justify-between mb-6\">\n        <div>\n          <h2 className=\"text-2xl font-bold text-white\">Port Reservations</h2>\n          <p className=\"text-slate-400 mt-1\">Reserve public TCP ports on available proxy hosts.</p>\n        </div>\n        <button\n          onClick={() => void addReservation()}\n          disabled={creating || loading}\n          className=\"inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-all shadow-lg shadow-indigo-500/20 disabled:opacity-60\"\n        >\n          <PlusIcon className=\"h-5 w-5\" /> Add Reservation\n        </button>\n      </div>\n\n      {loading ? (\n        <div className=\"space-y-4\">\n          {[1, 2].map(i => (\n            <div key={i} className=\"bg-slate-900/50 border border-slate-800 rounded-xl p-6 h-20 animate-pulse\"></div>\n          ))}\n        </div>\n      ) : items.length === 0 ? (\n        <div className=\"border border-slate-800 rounded-xl p-10 text-center bg-slate-900/40\">\n          <div className=\"mx-auto w-12 h-12 rounded-xl bg-indigo-500/10 border border-indigo-500/30 flex items-center justify-center mb-4\">\n            <GlobeAltIcon className=\"h-6 w-6 text-indigo-400\" />\n          </div>\n          <h3 className=\"text-xl font-bold text-white mb-1\">No reservations yet</h3>\n          <p className=\"text-slate-400 mb-6\">Create your first TCP port reservation to get started.</p>\n          <button onClick={() => void addReservation()} className=\"inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-all shadow-lg shadow-indigo-500/20\">\n            <PlusIcon className=\"h-5 w-5\" /> Add Reservation\n          </button>\n        </div>\n      ) : (\n        <div className=\"space-y-4\">\n          {items.map(item => (\n            <div key={item.id} className=\"bg-slate-900/50 border border-slate-800 rounded-xl p-6 flex items-center justify-between gap-6\">\n              <div className=\"flex items-center gap-4\">\n                <div className=\"w-10 h-10 rounded-lg bg-indigo-500/10 border border-indigo-500/30 flex items-center justify-center\">\n                  <GlobeAltIcon className=\"h-5 w-5 text-indigo-400\" />\n                </div>\n                <div>\n                  {editingId === item.id ? (\n                    <div className=\"flex flex-col gap-2\">\n                      <input\n                        type=\"text\"\n                        value={editName}\n                        onChange={(e) => setEditName(e.target.value)}\n                        className=\"bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white w-full\"\n                        placeholder=\"Reservation name (optional)\"\n                      />\n                      <div className=\"flex items-center gap-3\">\n                        {hosts.length > 1 && (\n                          <select value={selectedHost} onChange={async (e) => { setSelectedHost(e.target.value); await fetchRange(e.target.value) }} className=\"bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white\">\n                            {hosts.map(h => <option key={h} value={h}>{h}</option>)}\n                          </select>\n                        )}\n                        <input type=\"number\" inputMode=\"numeric\" value={editPort} onChange={(e) => setEditPort(e.target.value)} className=\"bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white w-32\" placeholder={portHint} />\n                        {portRange && <div className=\"text-xs text-slate-400\">{portHint}</div>}\n                      </div>\n                    </div>\n                  ) : (\n                    <>\n                      <div className=\"text-white font-medium\">\n                        {item.name ? <span className=\"mr-2\">{item.name}</span> : null}\n                        <span className={item.name ? \"text-slate-400 text-sm font-normal\" : \"\"}>\n                          {item.publicHost}:{item.publicPort}\n                        </span>\n                      </div>\n                    </>\n                  )}\n                  <div className=\"text-slate-500 text-sm mt-1\">Created on {new Date(item.createdAt).toLocaleDateString()}</div>\n                </div>\n              </div>\n              <div className=\"flex items-center gap-2\">\n                {editingId === item.id ? (\n                  <>\n                    <button onClick={() => void saveEdit(item.id)} className=\"inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white\">\n                      <CheckIcon className=\"h-5 w-5\" /> Save\n                    </button>\n                    <button onClick={cancelEdit} className=\"inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200\">\n                      <XMarkIcon className=\"h-5 w-5\" /> Cancel\n                    </button>\n                  </>\n                ) : (\n                  <>\n                    <button onClick={() => void startEdit(item)} className=\"inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200\">\n                      <PencilIcon className=\"h-5 w-5\" /> Edit\n                    </button>\n                    <button onClick={() => setDeleteId(item.id)} className=\"inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 hover:bg-red-500/20 text-red-300 border border-red-500/30\">\n                      <TrashIcon className=\"h-5 w-5\" /> Delete\n                    </button>\n                  </>\n                )}\n              </div>\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/Profile.tsx",
    "content": "/*\n * Copyright (c) 2026 AMAK Inc. All rights reserved.\n */\n\nimport { useEffect, useMemo, useState } from 'react'\nimport { useAuth } from '../../auth/AuthContext'\nimport { usePageTitle } from '../../components/PageHeader'\nimport { apiJson } from '../../lib/api'\nimport { UserCircleIcon } from '@heroicons/react/24/outline'\n\nexport default function Profile() {\n  const { user, refresh } = useAuth()\n  usePageTitle('Personal Details')\n  const [firstName, setFirstName] = useState('')\n  const [lastName, setLastName] = useState('')\n  const [saving, setSaving] = useState(false)\n  const [message, setMessage] = useState<string | null>(null)\n  const [loading, setLoading] = useState(true)\n\n  const [orig, setOrig] = useState<{ firstName: string, lastName: string } | null>(null)\n\n  useEffect(() => {\n    let cancelled = false\n    async function load() {\n      setLoading(true)\n      setMessage(null)\n      try {\n        const details = await apiJson<{ user: { firstName?: string, lastName?: string } }>(\n          '/api/users/me/details'\n        )\n        if (cancelled) return\n        const first = details.user?.firstName || ''\n        const last = details.user?.lastName || ''\n        setFirstName(first)\n        setLastName(last)\n        setOrig({ firstName: first, lastName: last })\n      } catch {\n        if (!firstName && !lastName && (user?.name || '')) {\n          const parts = (user?.name || '').trim().split(/\\s+/)\n          if (parts.length > 0) setFirstName(parts[0])\n          if (parts.length > 1) setLastName(parts.slice(1).join(' '))\n        }\n      } finally {\n        if (!cancelled) setLoading(false)\n      }\n    }\n    void load()\n    return () => { cancelled = true }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [user?.id])\n\n  const isChanged = useMemo(() => {\n    if (!orig) return true\n    return (\n      (firstName || '').trim() !== (orig.firstName || '').trim() ||\n      (lastName || '').trim() !== (orig.lastName || '').trim()\n    )\n  }, [orig, firstName, lastName])\n\n  async function onSave() {\n    setSaving(true)\n    setMessage(null)\n    try {\n      await apiJson('/api/users/me/profile', {\n        method: 'PATCH',\n        body: JSON.stringify({ firstName: firstName.trim(), lastName: lastName.trim() })\n      })\n      await refresh()\n      setOrig({ firstName, lastName })\n      setMessage('Profile updated.')\n    } catch {\n      setMessage('Failed to update profile. Please try again later.')\n    } finally {\n      setSaving(false)\n    }\n  }\n\n  return (\n    <div className=\"max-w-3xl\">\n      <div className=\"mb-8\">\n        <h2 className=\"text-2xl font-bold text-white\">Personal Details</h2>\n        <p className=\"text-slate-400 mt-1\">Manage your personal information.</p>\n      </div>\n\n      <div className=\"bg-slate-900/50 border border-slate-800 rounded-xl p-6 md:p-8 relative overflow-hidden\">\n        <div className=\"absolute top-0 right-0 -mt-4 -mr-4 w-32 h-32 bg-indigo-500/10 blur-3xl rounded-full pointer-events-none\"></div>\n\n        <div className=\"flex items-center gap-5 mb-8\">\n          {user?.avatarUrl ? (\n            <img src={user.avatarUrl} alt=\"avatar\" className=\"w-20 h-20 rounded-full border-4 border-slate-800 shadow-xl\" />\n          ) : (\n            <div className=\"w-20 h-20 rounded-full bg-indigo-600 flex items-center justify-center text-white text-2xl font-bold shadow-lg shadow-indigo-500/20\">\n              {user?.name?.[0] || user?.email?.[0] || '?'}\n            </div>\n          )}\n          <div>\n            <div className=\"text-xl font-bold text-white\">{user?.name || 'Unknown User'}</div>\n            <div className=\"text-slate-400\">{user?.email}</div>\n          </div>\n        </div>\n\n        <div className=\"space-y-6\">\n          <div className=\"grid md:grid-cols-2 gap-6\">\n            <div>\n              <label className=\"block text-sm font-medium text-slate-300 mb-2\">First Name</label>\n              <div className=\"relative\">\n                 <div className=\"absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none\">\n                   <UserCircleIcon className=\"h-5 w-5 text-slate-500\" />\n                 </div>\n                 <input\n                  value={firstName}\n                  onChange={(e) => setFirstName(e.target.value)}\n                  className=\"block w-full pl-10 bg-slate-950 border border-slate-800 rounded-lg py-2.5 text-slate-200 placeholder-slate-600 focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition-all\"\n                  placeholder=\"John\"\n                  disabled={loading}\n                />\n              </div>\n            </div>\n            <div>\n              <label className=\"block text-sm font-medium text-slate-300 mb-2\">Last Name</label>\n              <div className=\"relative\">\n                 <div className=\"absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none\">\n                   <UserCircleIcon className=\"h-5 w-5 text-slate-500\" />\n                 </div>\n                 <input\n                  value={lastName}\n                  onChange={(e) => setLastName(e.target.value)}\n                  className=\"block w-full pl-10 bg-slate-950 border border-slate-800 rounded-lg py-2.5 text-slate-200 placeholder-slate-600 focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition-all\"\n                  placeholder=\"Doe\"\n                  disabled={loading}\n                />\n              </div>\n            </div>\n          </div>\n        </div>\n\n        {message && (\n           <div className={`mt-6 p-3 rounded-lg text-sm ${message.includes('Failed') ? 'bg-red-500/10 text-red-400 border border-red-500/20' : 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'}`}>\n             {message}\n           </div>\n        )}\n\n        <div className=\"mt-8 pt-6 border-t border-slate-800 flex justify-end\">\n          <button \n            className={`px-6 py-2.5 rounded-lg font-medium transition-all ${\n              isChanged \n                ? 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25' \n                : 'bg-slate-800 text-slate-500 cursor-not-allowed'\n            }`}\n            onClick={() => { void onSave() }} \n            disabled={saving || loading || !isChanged}\n          >\n            {saving ? 'Saving...' : isChanged ? 'Save Changes' : 'Saved'}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/Settings.tsx",
    "content": "import { useEffect, useMemo, useState } from 'react'\nimport { useAuth } from '../../auth/AuthContext'\nimport { usePageTitle } from '../../components/PageHeader'\nimport { apiJson } from '../../lib/api'\nimport { BuildingOfficeIcon } from '@heroicons/react/24/outline'\n\nexport default function Settings() {\n  const { user, refresh } = useAuth()\n  usePageTitle('Account Settings')\n  const [accountName, setAccountName] = useState('')\n  const [saving, setSaving] = useState(false)\n  const [message, setMessage] = useState<string | null>(null)\n  const [loading, setLoading] = useState(true)\n\n  const [orig, setOrig] = useState<{ accountName: string } | null>(null)\n\n  useEffect(() => {\n    let cancelled = false\n    async function load() {\n      setLoading(true)\n      setMessage(null)\n      try {\n        const details = await apiJson<{ account: { name: string } }>(\n          '/api/users/me/details'\n        )\n        if (cancelled) return\n        const acc = details.account?.name || ''\n        setAccountName(acc || defaultAccountName(user?.name, user?.email))\n        setOrig({ accountName: acc })\n      } catch {\n        // Fallback to best-effort defaults if backend not available\n        const defAcc = defaultAccountName(user?.name, user?.email)\n        if (!accountName) setAccountName(defAcc)\n      } finally {\n        if (!cancelled) setLoading(false)\n      }\n    }\n    void load()\n    return () => { cancelled = true }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [user?.id])\n\n  function defaultAccountName(name?: string, email?: string): string {\n    const base = (name && name.trim()) || (email ? email.split('@')[0] : '')\n    return base ? `${base}'s account` : 'My account'\n  }\n\n  const isChanged = useMemo(() => {\n    if (!orig) return true\n    return accountName.trim() !== (orig.accountName || '').trim()\n  }, [orig, accountName])\n\n  async function onSave() {\n    setSaving(true)\n    setMessage(null)\n    try {\n      // Update account name if changed\n      if (!orig || accountName.trim() !== (orig.accountName || '').trim()) {\n        await apiJson('/api/users/me/account', {\n          method: 'PATCH',\n          body: JSON.stringify({ name: accountName.trim() })\n        })\n      }\n      await refresh()\n      setOrig({ accountName })\n      setMessage('Settings saved.')\n    } catch {\n      setMessage('Failed to save settings. Please try again later.')\n    } finally {\n      setSaving(false)\n    }\n  }\n\n  return (\n    <div className=\"max-w-3xl\">\n      <div className=\"mb-8\">\n        <h2 className=\"text-2xl font-bold text-white\">Account Settings</h2>\n        <p className=\"text-slate-400 mt-1\">Manage your account preferences.</p>\n      </div>\n\n      <div className=\"bg-slate-900/50 border border-slate-800 rounded-xl p-6 md:p-8 relative overflow-hidden\">\n        {/* Decorative gradient */}\n        <div className=\"absolute top-0 right-0 -mt-4 -mr-4 w-32 h-32 bg-indigo-500/10 blur-3xl rounded-full pointer-events-none\"></div>\n\n        <div className=\"space-y-6\">\n          <div>\n            <label className=\"block text-sm font-medium text-slate-300 mb-2\">Account Name</label>\n            <div className=\"relative\">\n              <div className=\"absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none\">\n                <BuildingOfficeIcon className=\"h-5 w-5 text-slate-500\" />\n              </div>\n              <input\n                value={accountName}\n                onChange={(e) => setAccountName(e.target.value)}\n                className=\"block w-full pl-10 bg-slate-950 border border-slate-800 rounded-lg py-2.5 text-slate-200 placeholder-slate-600 focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition-all\"\n                placeholder=\"e.g. Acme Inc\"\n                disabled={loading}\n              />\n            </div>\n          </div>\n        </div>\n\n        {message && (\n           <div className={`mt-6 p-3 rounded-lg text-sm ${message.includes('Failed') ? 'bg-red-500/10 text-red-400 border border-red-500/20' : 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'}`}>\n             {message}\n           </div>\n        )}\n\n        <div className=\"mt-8 pt-6 border-t border-slate-800 flex justify-end\">\n          <button \n            className={`px-6 py-2.5 rounded-lg font-medium transition-all ${\n              isChanged \n                ? 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25' \n                : 'bg-slate-800 text-slate-500 cursor-not-allowed'\n            }`}\n            onClick={() => { void onSave() }} \n            disabled={saving || loading || !isChanged}\n          >\n            {saving ? 'Saving...' : isChanged ? 'Save Changes' : 'Saved'}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/Team.tsx",
    "content": "/*\n * Copyright (c) 2025 AMAK Inc. All rights reserved.\n */\n\nimport { useEffect, useState } from 'react'\nimport { apiJson } from '../../lib/api'\nimport { useAuth } from '../../auth/AuthContext'\nimport { usePageTitle } from '../../components/PageHeader'\nimport { UserGroupIcon, UserPlusIcon, XMarkIcon, TrashIcon, ArrowPathIcon } from '@heroicons/react/24/outline'\nimport { AlertModal, ConfirmModal } from '../../components/Modal'\n\ntype Member = {\n  id: string\n  email: string\n  firstName: string | null\n  lastName: string | null\n  avatarUrl: string | null\n  roles: string[]\n  joinedAt: string\n}\n\ntype Invitation = {\n  id: string\n  email: string\n  invitedBy: string\n  createdAt: string\n  expiresAt: string\n}\n\nexport default function Team() {\n  const { user } = useAuth()\n  usePageTitle('Team Management')\n  \n  const [members, setMembers] = useState<Member[]>([])\n  const [invitations, setInvitations] = useState<Invitation[]>([])\n  const [loading, setLoading] = useState(true)\n  const [email, setEmail] = useState('')\n  const [inviting, setInviting] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n\n  // Dialogs\n  const [alertState, setAlertState] = useState<{ isOpen: boolean, title: string, message: string }>({\n    isOpen: false, title: '', message: ''\n  })\n  const [cancelId, setCancelId] = useState<string | null>(null)\n  const [removeMemberId, setRemoveMemberId] = useState<string | null>(null)\n\n  const isTeamPlan = user?.plan === 'team'\n  const isAccountAdmin = user?.roles?.some(r => r === 'ACCOUNT_ADMIN' || r === 'ADMIN')\n\n  useEffect(() => {\n    if (isTeamPlan) {\n      void loadData()\n    } else {\n      setLoading(false)\n    }\n  }, [isTeamPlan])\n\n  async function loadData() {\n    setLoading(true)\n    try {\n      const promises: [Promise<Member[]>, Promise<Invitation[]> | null] = [\n        apiJson<Member[]>('/api/team/members'),\n        isAccountAdmin ? apiJson<Invitation[]>('/api/team/invitations') : Promise.resolve([])\n      ]\n      const [m, i] = await Promise.all(promises)\n      setMembers(m)\n      if (i) setInvitations(i)\n    } catch (err) {\n      console.error('Failed to load team data', err)\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  async function handleInvite(e: React.FormEvent) {\n    e.preventDefault()\n    if (!email) return\n    \n    setInviting(true)\n    setError(null)\n    try {\n      await apiJson('/api/team/invitations', {\n        method: 'POST',\n        body: JSON.stringify({ email })\n      })\n      setEmail('')\n      void loadData()\n    } catch (err: any) {\n      setError(err.message || 'Failed to send invitation')\n    } finally {\n      setInviting(false)\n    }\n  }\n\n  async function confirmCancelInvitation() {\n    if (!cancelId) return\n    \n    try {\n      await apiJson(`/api/team/invitations/${cancelId}`, { method: 'DELETE' })\n      setCancelId(null)\n      void loadData()\n    } catch (err: any) {\n      setAlertState({ \n        isOpen: true, \n        title: 'Error', \n        message: err.message || 'Failed to cancel invitation' \n      })\n    }\n  }\n\n  async function handleResendInvitation(id: string) {\n    try {\n      await apiJson(`/api/team/invitations/${id}/resend`, { method: 'POST' })\n      setAlertState({\n        isOpen: true,\n        title: 'Success',\n        message: 'Invitation has been resent successfully.'\n      })\n      void loadData()\n    } catch (err: any) {\n      setAlertState({\n        isOpen: true,\n        title: 'Error',\n        message: err.message || 'Failed to resend invitation'\n      })\n    }\n  }\n\n  async function confirmRemoveMember() {\n    if (!removeMemberId) return\n    \n    try {\n      await apiJson(`/api/team/members/${removeMemberId}`, { method: 'DELETE' })\n      setRemoveMemberId(null)\n      void loadData()\n    } catch (err: any) {\n      setAlertState({ \n        isOpen: true, \n        title: 'Error', \n        message: err.message || 'Failed to remove member' \n      })\n    }\n  }\n\n  if (!isTeamPlan) {\n    return (\n      <div className=\"max-w-6xl\">\n        <div className=\"bg-slate-900 border border-slate-800 rounded-xl p-8 text-center\">\n          <UserGroupIcon className=\"w-12 h-12 text-slate-500 mx-auto mb-4\" />\n          <h2 className=\"text-xl font-bold text-white mb-2\">Team plan required</h2>\n          <p className=\"text-slate-400 mb-6\">\n            Team features like inviting members and shared tunnel limits are only available on the Team plan.\n          </p>\n          <a href=\"/app/billing\" className=\"inline-flex items-center justify-center px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg font-medium transition-colors\">\n            Upgrade to Team Plan\n          </a>\n        </div>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"max-w-6xl space-y-8\">\n      <AlertModal \n        isOpen={alertState.isOpen} \n        title={alertState.title} \n        message={alertState.message} \n        onClose={() => setAlertState({ ...alertState, isOpen: false })} \n      />\n      <ConfirmModal \n        isOpen={!!cancelId} \n        title=\"Cancel Invitation\" \n        message=\"Are you sure you want to cancel this invitation?\" \n        onClose={() => setCancelId(null)} \n        onConfirm={() => void confirmCancelInvitation()}\n        isDangerous={true}\n      />\n      <ConfirmModal \n        isOpen={!!removeMemberId} \n        title=\"Remove Member\" \n        message=\"Are you sure you want to remove this member from your account? They will lose access to all shared resources.\" \n        onClose={() => setRemoveMemberId(null)} \n        onConfirm={() => void confirmRemoveMember()}\n        isDangerous={true}\n      />\n      \n      {/* Invite Section */}\n      {isAccountAdmin && (\n        <div className=\"bg-slate-900 border border-slate-800 rounded-xl p-6\">\n          <h3 className=\"text-lg font-bold text-white mb-4 flex items-center gap-2\">\n            <UserPlusIcon className=\"w-5 h-5 text-indigo-400\" />\n            Invite Team Member\n          </h3>\n          <form onSubmit={handleInvite} className=\"flex gap-4\">\n            <input\n              type=\"email\"\n              value={email}\n              onChange={(e) => setEmail(e.target.value)}\n              placeholder=\"colleague@example.com\"\n              required\n              className=\"flex-1 bg-slate-950 border border-slate-800 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50\"\n            />\n            <button\n              type=\"submit\"\n              disabled={inviting}\n              className=\"bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-6 py-2 rounded-lg font-medium transition-colors\"\n            >\n              {inviting ? 'Sending...' : 'Send Invite'}\n            </button>\n          </form>\n          {error && <p className=\"mt-2 text-red-400 text-sm\">{error}</p>}\n        </div>\n      )}\n\n      {/* Members List */}\n      <div className=\"bg-slate-900 border border-slate-800 rounded-xl overflow-hidden\">\n        <div className=\"px-6 py-4 border-b border-slate-800 bg-slate-900/50\">\n          <h3 className=\"text-lg font-bold text-white\">Team Members</h3>\n        </div>\n        <div className=\"overflow-x-auto\">\n          <table className=\"min-w-full text-left text-sm\">\n            <thead className=\"bg-slate-900 text-slate-400 uppercase font-medium tracking-wider\">\n              <tr>\n                <th className=\"px-6 py-4\">User</th>\n                <th className=\"px-6 py-4\">Roles</th>\n                <th className=\"px-6 py-4\">Joined</th>\n                <th className=\"px-6 py-4 text-right\">Actions</th>\n              </tr>\n            </thead>\n            <tbody className=\"divide-y divide-slate-800\">\n              {loading ? (\n                <tr>\n                   <td colSpan={4} className=\"px-6 py-12 text-center text-slate-500\">Loading members...</td>\n                </tr>\n              ) : members.map((m) => (\n                <tr key={m.id} className=\"hover:bg-slate-800/30 transition-colors\">\n                  <td className=\"px-6 py-4\">\n                    <div className=\"flex items-center gap-3\">\n                      {m.avatarUrl ? (\n                        <img src={m.avatarUrl} alt={`${m.firstName || m.email}'s avatar`} className=\"w-8 h-8 rounded-full\" />\n                      ) : (\n                        <div className=\"w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center text-xs font-bold text-slate-400\">\n                          {m.firstName?.[0] || m.email[0].toUpperCase()}\n                        </div>\n                      )}\n                      <div>\n                        <div className=\"text-white font-medium\">\n                          {m.firstName} {m.lastName}\n                          {m.id === user?.id && <span className=\"ml-2 text-[10px] bg-slate-800 text-slate-400 px-1.5 py-0.5 rounded uppercase\">You</span>}\n                        </div>\n                        <div className=\"text-slate-500 text-xs\">{m.email}</div>\n                      </div>\n                    </div>\n                  </td>\n                  <td className=\"px-6 py-4\">\n                    <div className=\"flex flex-wrap gap-1\">\n                      {m.roles.map(r => (\n                        <span key={r} className={`text-[10px] px-1.5 py-0.5 rounded uppercase font-bold ${\n                          r === 'ACCOUNT_ADMIN' ? 'bg-indigo-500/10 text-indigo-400 border border-indigo-500/20' : 'bg-slate-800 text-slate-400'\n                        }`}>\n                          {r.replace('_', ' ')}\n                        </span>\n                      ))}\n                    </div>\n                  </td>\n                  <td className=\"px-6 py-4 text-slate-400 text-xs\">\n                    {new Date(m.joinedAt).toLocaleDateString()}\n                  </td>\n                  <td className=\"px-6 py-4 text-right\">\n                    {isAccountAdmin && m.id !== user?.id && (\n                      <button\n                        onClick={() => setRemoveMemberId(m.id)}\n                        className=\"text-slate-500 hover:text-red-400 p-1 rounded-lg transition-colors\"\n                        title=\"Remove member\"\n                      >\n                        <TrashIcon className=\"w-5 h-5\" />\n                      </button>\n                    )}\n                  </td>\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>\n      </div>\n\n      {/* Pending Invitations */}\n      {isAccountAdmin && invitations.length > 0 && (\n        <div className=\"bg-slate-900 border border-slate-800 rounded-xl overflow-hidden\">\n          <div className=\"px-6 py-4 border-b border-slate-800 bg-slate-900/50\">\n            <h3 className=\"text-lg font-bold text-white\">Pending Invitations</h3>\n          </div>\n          <div className=\"overflow-x-auto\">\n            <table className=\"min-w-full text-left text-sm\">\n              <thead className=\"bg-slate-900 text-slate-400 uppercase font-medium tracking-wider\">\n                <tr>\n                  <th className=\"px-6 py-4\">Email</th>\n                  <th className=\"px-6 py-4\">Invited By</th>\n                  <th className=\"px-6 py-4\">Expires</th>\n                  <th className=\"px-6 py-4\"></th>\n                </tr>\n              </thead>\n              <tbody className=\"divide-y divide-slate-800\">\n                {invitations.map((i) => (\n                  <tr key={i.id} className=\"hover:bg-slate-800/30 transition-colors\">\n                    <td className=\"px-6 py-4 text-white font-medium\">{i.email}</td>\n                    <td className=\"px-6 py-4 text-slate-400\">{i.invitedBy}</td>\n                    <td className=\"px-6 py-4 text-slate-400 text-xs\">\n                      {new Date(i.expiresAt).toLocaleDateString()}\n                    </td>\n                    <td className=\"px-6 py-4 text-right\">\n                      {isAccountAdmin && (\n                        <div className=\"flex justify-end gap-2\">\n                          <button\n                            onClick={() => void handleResendInvitation(i.id)}\n                            className=\"text-slate-500 hover:text-indigo-400 p-1 rounded-lg transition-colors\"\n                            title=\"Resend invitation\"\n                          >\n                            <ArrowPathIcon className=\"w-5 h-5\" />\n                          </button>\n                          <button\n                            onClick={() => setCancelId(i.id)}\n                            className=\"text-slate-500 hover:text-red-400 p-1 rounded-lg transition-colors\"\n                            title=\"Cancel invitation\"\n                          >\n                            <XMarkIcon className=\"w-5 h-5\" />\n                          </button>\n                        </div>\n                      )}\n                    </td>\n                  </tr>\n                ))}\n              </tbody>\n            </table>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/Tokens.tsx",
    "content": "import { useEffect, useMemo, useState } from 'react'\nimport { apiJson } from '../../lib/api'\nimport { useAuth } from '../../auth/AuthContext'\nimport { usePageTitle } from '../../components/PageHeader'\nimport { ConfirmModal } from '../../components/Modal'\nimport { \n  KeyIcon, \n  TrashIcon, \n  ClipboardDocumentIcon, \n  CheckIcon,\n  PlusIcon \n} from '@heroicons/react/24/outline'\n\ntype TokenItem = { id: string, label: string, createdAt: string, revoked: boolean, lastUsedAt?: string }\n\nexport default function Tokens() {\n  const { user } = useAuth()\n  usePageTitle('Access Tokens')\n  const hasUser = useMemo(() => !!user, [user])\n  const [tokens, setTokens] = useState<TokenItem[]>([])\n  const [loading, setLoading] = useState(false)\n  const [isInitialLoad, setIsInitialLoad] = useState(true)\n  const [newLabel, setNewLabel] = useState('cli')\n  const [justCreatedToken, setJustCreatedToken] = useState<string | null>(null)\n  \n  // Dialog state\n  const [revokeId, setRevokeId] = useState<string | null>(null)\n\n  useEffect(() => {\n    if (!hasUser) return\n    void loadTokens().finally(() => setIsInitialLoad(false))\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [hasUser])\n\n  async function loadTokens() {\n    setLoading(true)\n    try {\n      const list = await apiJson<TokenItem[]>('/api/tokens')\n      setTokens(list)\n    } catch {\n      setTokens([])\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  async function createToken() {\n    setLoading(true)\n    try {\n      const resp = await apiJson<{ token: string }>('/api/tokens', { method: 'POST', body: JSON.stringify({ label: newLabel || 'cli' }) })\n      setJustCreatedToken(resp.token as string)\n      await loadTokens()\n    } catch {\n      // noop\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  async function revokeToken(id: string) {\n    setLoading(true)\n    try {\n      await apiJson(`/api/tokens/${id}`, { method: 'DELETE' })\n      setTokens(current => current.map(t => \n        t.id === id ? { ...t, revoked: true } : t\n      ))\n    } catch (err) {\n      console.error('Failed to revoke token', err)\n    } finally {\n      setLoading(false)\n      setRevokeId(null)\n    }\n  }\n\n  return (\n    <div className=\"max-w-6xl\">\n      <ConfirmModal\n        isOpen={!!revokeId}\n        onClose={() => setRevokeId(null)}\n        onConfirm={() => {\n            if (revokeId) void revokeToken(revokeId)\n        }}\n        title=\"Revoke Token\"\n        message=\"Are you sure you want to revoke this token? This action cannot be undone and any applications using this token will lose access.\"\n        confirmText=\"Revoke\"\n        isDangerous\n      />\n\n      <div className=\"mb-8\">\n        <h2 className=\"text-2xl font-bold text-white\">Access Tokens</h2>\n        <p className=\"text-slate-400 mt-1\">Generate and manage API tokens to authenticate the CLI.</p>\n      </div>\n\n      <div className=\"bg-slate-900/50 border border-slate-800 rounded-xl p-6 mb-8 shadow-lg\">\n        <h3 className=\"text-lg font-medium text-white mb-4\">Generate New Token</h3>\n        <div className=\"flex flex-col md:flex-row gap-4 items-start md:items-end\">\n          <div className=\"flex-1 w-full\">\n            <label className=\"block text-sm font-medium text-slate-300 mb-2\">Token Label</label>\n            <input \n              value={newLabel} \n              onChange={(e) => setNewLabel(e.target.value)} \n              className=\"block w-full bg-slate-950 border border-slate-800 rounded-lg py-2.5 px-4 text-slate-200 placeholder-slate-600 focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition-all\" \n              placeholder=\"e.g. MacBook Pro\" \n            />\n          </div>\n          <button \n            className=\"w-full md:w-auto px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg font-medium transition-all flex items-center justify-center gap-2 shadow-lg shadow-indigo-500/20 disabled:opacity-50 disabled:cursor-not-allowed\"\n            onClick={() => { void createToken() }} \n            disabled={loading}\n          >\n            <PlusIcon className=\"w-5 h-5\" />\n            Generate\n          </button>\n        </div>\n\n        {justCreatedToken && (\n          <div className=\"mt-6 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-lg animate-in fade-in slide-in-from-top-2\">\n            <div className=\"flex items-start justify-between gap-4\">\n              <div>\n                <div className=\"text-emerald-400 font-medium mb-1\">Token generated successfully</div>\n                <div className=\"text-emerald-400/70 text-sm mb-3\">Make sure to copy your token now. You won't be able to see it again!</div>\n              </div>\n              <button className=\"text-emerald-400 hover:text-emerald-300\" onClick={() => setJustCreatedToken(null)}>\n                <span className=\"sr-only\">Dismiss</span>\n                <svg className=\"w-5 h-5\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path fillRule=\"evenodd\" d=\"M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z\" clipRule=\"evenodd\" /></svg>\n              </button>\n            </div>\n            \n            <div className=\"relative group\">\n              <div className=\"bg-slate-950 border border-slate-800 rounded-lg p-3 font-mono text-sm text-emerald-300 break-all pr-12\">\n                {justCreatedToken}\n              </div>\n              <CopyButton text={justCreatedToken} />\n            </div>\n            \n            <div className=\"mt-4 p-3 bg-slate-900/50 rounded border border-slate-800 text-slate-400 text-xs font-mono\">\n               port-buddy init {justCreatedToken}\n            </div>\n          </div>\n        )}\n      </div>\n\n      <div className=\"space-y-4\">\n        {isInitialLoad ? (\n          <div className=\"bg-slate-900/50 border border-slate-800 rounded-xl p-6 h-24 animate-pulse\"></div>\n        ) : tokens.length === 0 ? (\n          <div className=\"bg-slate-900/50 border border-slate-800 rounded-xl p-12 text-center\">\n            <div className=\"inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-800 text-slate-400 mb-4\">\n              <KeyIcon className=\"w-6 h-6\" />\n            </div>\n            <h3 className=\"text-lg font-medium text-white mb-2\">No tokens yet</h3>\n            <p className=\"text-slate-400\">Generate your first token to start using the CLI.</p>\n          </div>\n        ) : (\n          <div className=\"grid gap-4\">\n            {tokens.map(t => (\n              <div key={t.id} className=\"group bg-slate-900/50 border border-slate-800 rounded-xl p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:border-slate-700 transition-all\">\n                <div className=\"flex items-start gap-4\">\n                  <div className={`p-2.5 rounded-lg ${t.revoked ? 'bg-red-500/10 text-red-400' : 'bg-indigo-500/10 text-indigo-400'}`}>\n                    <KeyIcon className=\"w-6 h-6\" />\n                  </div>\n                  <div>\n                    <div className=\"flex items-center gap-3 mb-1\">\n                      <div className=\"font-semibold text-white text-lg\">{t.label}</div>\n                      {t.revoked && <span className=\"px-2 py-0.5 rounded text-xs font-medium bg-red-500/10 text-red-400 border border-red-500/20\">Revoked</span>}\n                    </div>\n                    <div className=\"text-slate-500 text-sm flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3\">\n                      <span>Created {new Date(t.createdAt).toLocaleDateString()}</span>\n                      {t.lastUsedAt && (\n                        <>\n                          <span className=\"hidden sm:inline\">•</span>\n                          <span>Last used {new Date(t.lastUsedAt).toLocaleDateString()}</span>\n                        </>\n                      )}\n                    </div>\n                  </div>\n                </div>\n                \n                {!t.revoked && (\n                  <button \n                    className=\"self-end sm:self-center px-4 py-2 text-sm font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg border border-transparent hover:border-red-500/20 transition-all flex items-center gap-2 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100\"\n                    onClick={() => setRevokeId(t.id)}\n                  >\n                    <TrashIcon className=\"w-4 h-4\" />\n                    Revoke\n                  </button>\n                )}\n              </div>\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n\nfunction CopyButton({ text }: { text: string }) {\n  const [copied, setCopied] = useState(false)\n  const copy = () => {\n    navigator.clipboard.writeText(text)\n    setCopied(true)\n    setTimeout(() => setCopied(false), 2000)\n  }\n  return (\n    <button \n      onClick={copy}\n      className=\"absolute top-2 right-2 p-1.5 rounded-md bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white transition-all\"\n      title=\"Copy to clipboard\"\n    >\n      {copied ? <CheckIcon className=\"w-4 h-4 text-emerald-400\" /> : <ClipboardDocumentIcon className=\"w-4 h-4\" />}\n    </button>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/app/Tunnels.tsx",
    "content": "import { useEffect, useMemo, useRef, useState } from 'react'\nimport { apiJson } from '../../lib/api'\nimport { formatDateTime } from '../../lib/utils'\nimport { useAuth } from '../../auth/AuthContext'\nimport { usePageTitle } from '../../components/PageHeader'\nimport { ArrowTopRightOnSquareIcon, GlobeAltIcon, ServerIcon } from '@heroicons/react/24/outline'\n\ntype TunnelView = {\n  id: string\n  tunnelId: string\n  type: 'HTTP' | 'TCP' | 'UDP'\n  status: 'PENDING' | 'CONNECTED' | 'CLOSED'\n  local: string | null\n  publicEndpoint: string | null\n  publicUrl: string | null\n  publicHost: string | null\n  publicPort: number | null\n  subdomain: string | null\n  portReservationName: string | null\n  lastHeartbeatAt: string | null\n  createdAt: string | null\n}\n\nexport default function Tunnels() {\n  const { user } = useAuth()\n  usePageTitle('Tunnels')\n  const hasUser = useMemo(() => !!user, [user])\n  const [tunnels, setTunnels] = useState<TunnelView[]>([])\n  const [page, setPage] = useState(0)\n  const [totalPages, setTotalPages] = useState(0)\n  const [loading, setLoading] = useState(false)\n  const [isInitialLoad, setIsInitialLoad] = useState(true)\n  const sentinelRef = useRef<HTMLDivElement | null>(null)\n\n  useEffect(() => {\n    if (!hasUser) return\n    void loadTunnels(0, false).finally(() => setIsInitialLoad(false))\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [hasUser])\n\n  // Infinite scroll using IntersectionObserver\n  useEffect(() => {\n    if (!hasUser) return\n    const el = sentinelRef.current\n    if (!el) return\n    const rootEl = document.querySelector('[data-scroll-root]') as Element | null\n    const observer = new IntersectionObserver((entries) => {\n      const entry = entries[0]\n      const hasNext = page < totalPages - 1\n      if (entry.isIntersecting && hasNext && !loading) {\n        void loadTunnels(page + 1, true)\n      }\n    }, { root: rootEl ?? null, rootMargin: '0px', threshold: 0.1 })\n    observer.observe(el)\n    return () => observer.disconnect()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [hasUser, page, totalPages, loading])\n\n  // Ensure we fill the viewport\n  useEffect(() => {\n    if (!hasUser || loading) return\n    const hasNext = page < totalPages - 1\n    if (!hasNext) return\n    const el = sentinelRef.current\n    if (!el) return\n    const rect = el.getBoundingClientRect()\n    const rootEl = document.querySelector('[data-scroll-root]') as Element | null\n    if (rootEl) {\n      const rootRect = rootEl.getBoundingClientRect()\n      if (rect.top <= rootRect.bottom) {\n        void loadTunnels(page + 1, true)\n      }\n    } else {\n      if (rect.top <= window.innerHeight) {\n        void loadTunnels(page + 1, true)\n      }\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [hasUser, loading, page, totalPages])\n\n  async function loadTunnels(nextPage: number, append: boolean) {\n    setLoading(true)\n    try {\n      const res = await apiJson<{ content: TunnelView[], number: number, totalPages: number }>(`/api/tunnels?page=${nextPage}&size=30`)\n      const nextContent = res.content || []\n      if (append) {\n        setTunnels((prev) => [...prev, ...nextContent])\n      } else {\n        setTunnels(nextContent)\n      }\n      setPage(res.number ?? nextPage)\n      setTotalPages(res.totalPages ?? 0)\n    } catch {\n      setTunnels([])\n      setPage(0)\n      setTotalPages(0)\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  function formatDate(iso: string | null | undefined): string {\n    if (!iso) return '-'\n    return formatDateTime(iso)\n  }\n\n  return (\n    <div className=\"flex flex-col max-w-6xl\">\n      <div className=\"mb-8\">\n        <h2 className=\"text-2xl font-bold text-white\">Active Tunnels</h2>\n        <p className=\"text-slate-400 mt-1\">Monitor and manage your HTTP and TCP tunnels.</p>\n      </div>\n\n      <div className=\"mt-2\">\n        {isInitialLoad ? (\n          <div className=\"bg-slate-900/50 border border-slate-800 rounded-xl p-12 h-64 animate-pulse\"></div>\n        ) : tunnels.length === 0 ? (\n          <div className=\"bg-slate-900/50 border border-slate-800 rounded-xl p-12 text-center\">\n            <div className=\"inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-800 text-slate-400 mb-4\">\n              <GlobeAltIcon className=\"w-6 h-6\" />\n            </div>\n            <h3 className=\"text-lg font-medium text-white mb-2\">No tunnels found</h3>\n            <p className=\"text-slate-400 mb-6\">Start your first tunnel using the CLI.</p>\n            <code className=\"px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-sm text-indigo-400 font-mono\">\n              port-buddy 8080\n            </code>\n          </div>\n        ) : (\n          <div className=\"overflow-hidden rounded-xl border border-slate-800 bg-slate-900/50 shadow-xl\">\n            <div className=\"overflow-x-auto\">\n              <table className=\"min-w-full text-left text-sm\">\n                <thead className=\"bg-slate-900 text-slate-400 uppercase font-medium tracking-wider\">\n                  <tr>\n                    <th className=\"px-6 py-4\">Type</th>\n                    <th className=\"px-6 py-4\">Name</th>\n                    <th className=\"px-6 py-4\">Local Address</th>\n                    <th className=\"px-6 py-4\">Public URL</th>\n                    <th className=\"px-6 py-4\">Status</th>\n                    <th className=\"px-6 py-4\">Last Activity</th>\n                  </tr>\n                </thead>\n                <tbody className=\"divide-y divide-slate-800\">\n                  {tunnels.map((t) => {\n                    const canOpen = t.type === 'HTTP' && t.status === 'CONNECTED' && !!t.publicUrl\n                    const publicText = t.type === 'HTTP' ? (t.publicUrl || '-') : (t.publicEndpoint || '-')\n                    return (\n                      <tr key={t.id} className=\"hover:bg-slate-800/30 transition-colors\">\n                        <td className=\"px-6 py-4\">\n                          <div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${\n                            t.type === 'HTTP' \n                              ? 'bg-indigo-500/10 text-indigo-400 border-indigo-500/20' \n                              : 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20'\n                          }`}>\n                            {t.type === 'HTTP' ? <GlobeAltIcon className=\"w-3 h-3\" /> : <ServerIcon className=\"w-3 h-3\" />}\n                            {t.type}\n                          </div>\n                        </td>\n                        <td className=\"px-6 py-4 text-slate-300 font-medium\">\n                          {t.type === 'HTTP' && t.subdomain ? (\n                            <span className=\"text-indigo-400 font-mono text-xs\">{t.subdomain}</span>\n                          ) : t.portReservationName ? (\n                            <span className=\"text-indigo-400\">{t.portReservationName}</span>\n                          ) : (t.type === 'TCP' || t.type === 'UDP') && t.publicPort ? (\n                            <span className=\"text-indigo-400 font-mono text-xs\">{t.publicPort}</span>\n                          ) : (\n                            <span className=\"text-slate-500 italic text-xs\">unnamed</span>\n                          )}\n                        </td>\n                        <td className=\"px-6 py-4 text-slate-300 font-mono text-xs\">{t.local || '-'}</td>\n                        <td className=\"px-6 py-4\">\n                          {canOpen ? (\n                            <a href={t.publicUrl!} target=\"_blank\" rel=\"noopener noreferrer\" className=\"flex items-center gap-1.5 text-indigo-400 hover:text-indigo-300 hover:underline font-mono text-xs break-all group\">\n                              {publicText}\n                              <ArrowTopRightOnSquareIcon className=\"w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity\" />\n                            </a>\n                          ) : (\n                            <span className=\"text-slate-400 font-mono text-xs break-all\">{publicText}</span>\n                          )}\n                        </td>\n                        <td className=\"px-6 py-4\">\n                           <div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${\n                             t.status === 'CONNECTED' \n                               ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' \n                               : t.status === 'CLOSED'\n                               ? 'bg-slate-700/10 text-slate-400 border-slate-700/20'\n                               : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20'\n                           }`}>\n                             <span className={`w-1.5 h-1.5 rounded-full ${t.status === 'CONNECTED' ? 'bg-emerald-400 animate-pulse' : t.status === 'CLOSED' ? 'bg-slate-400' : 'bg-yellow-400'}`}></span>\n                             {t.status}\n                           </div>\n                        </td>\n                        <td className=\"px-6 py-4 text-slate-500 text-xs\">{formatDate(t.lastHeartbeatAt)}</td>\n                      </tr>\n                    )\n                  })}\n                </tbody>\n              </table>\n            </div>\n          </div>\n        )}\n      </div>\n\n      {/* Infinite scroll sentinel */}\n      <div ref={sentinelRef} className=\"mt-4 h-8 w-full\" />\n\n      {/* Loading indicator for next page */}\n      {loading && tunnels.length > 0 ? (\n        <div className=\"text-center py-4 text-slate-500 text-sm\">Loading more tunnels...</div>\n      ) : null}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/docs/DocsLayout.tsx",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\nimport { Link, Outlet, useLocation } from 'react-router-dom'\n\nexport default function DocsLayout() {\n  const location = useLocation()\n\n  return (\n    <div className=\"min-h-screen flex flex-col\">\n      <div className=\"flex-1 relative pt-12 md:pt-20 pb-20\">\n        <div className=\"absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-900/20 via-slate-900/0 to-slate-900/0 pointer-events-none\" />\n        \n        <div className=\"container max-w-5xl relative z-10\">\n          <div className=\"flex flex-col md:flex-row gap-12\">\n            {/* Sidebar Navigation */}\n            <aside className=\"md:w-64 flex-shrink-0\">\n              <div className=\"sticky top-24 space-y-8\">\n                <div>\n                  <h3 className=\"text-xs font-semibold text-slate-500 uppercase tracking-wider mb-4 px-2\">Getting Started</h3>\n                  <nav className=\"space-y-1\">\n                    <SidebarLink to=\"/docs#introduction\" label=\"Introduction\" active={location.pathname === '/docs' && (!location.hash || location.hash === '#introduction')} />\n                    <SidebarLink to=\"/docs#installation\" label=\"Installation\" active={location.hash === '#installation'} />\n                    <SidebarLink to=\"/docs#authentication\" label=\"Authentication\" active={location.hash === '#authentication'} />\n                  </nav>\n                </div>\n                <div>\n                  <h3 className=\"text-xs font-semibold text-slate-500 uppercase tracking-wider mb-4 px-2\">Tunnels</h3>\n                  <nav className=\"space-y-1\">\n                    <SidebarLink to=\"/docs#http-tunnels\" label=\"HTTP Tunnels\" active={location.hash === '#http-tunnels'} />\n                    <SidebarLink to=\"/docs#tcp-tunnels\" label=\"TCP Tunnels\" active={location.hash === '#tcp-tunnels'} />\n                    <SidebarLink to=\"/docs#udp-tunnels\" label=\"UDP Tunnels\" active={location.hash === '#udp-tunnels'} />\n                  </nav>\n                </div>\n                <div>\n                  <h3 className=\"text-xs font-semibold text-slate-500 uppercase tracking-wider mb-4 px-2\">Advanced</h3>\n                  <nav className=\"space-y-1\">\n                    <SidebarLink to=\"/docs#run-as-service\" label=\"Run as Service\" active={location.hash === '#run-as-service'} />\n                    <SidebarLink to=\"/docs#custom-domains\" label=\"Custom Domains\" active={location.hash === '#custom-domains'} />\n                    <SidebarLink to=\"/docs#private-tunnels\" label=\"Private Tunnels\" active={location.hash === '#private-tunnels'} />\n                    <SidebarLink to=\"/docs#pricing-limits\" label=\"Pricing & Limits\" active={location.hash === '#pricing-limits'} />\n                  </nav>\n                </div>\n                <div>\n                  <h3 className=\"text-xs font-semibold text-slate-500 uppercase tracking-wider mb-4 px-2\">How-to Guides</h3>\n                  <nav className=\"space-y-1\">\n                    <SidebarLink to=\"/docs/guides/minecraft-server\" label=\"Minecraft Server\" active={location.pathname === '/docs/guides/minecraft-server'} />\n                    <SidebarLink to=\"/docs/guides/hytale-server\" label=\"Hytale Server\" active={location.pathname === '/docs/guides/hytale-server'} />\n                  </nav>\n                </div>\n              </div>\n            </aside>\n\n            {/* Main Content */}\n            <main className=\"flex-1 max-w-3xl\">\n              <Outlet />\n            </main>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nfunction SidebarLink({ to, label, active }: { to: string, label: string, active: boolean }) {\n  // If we are on a different page and the link is an anchor on /docs, we need to make sure we go to /docs first.\n  // The 'to' prop should be the full path (e.g., \"/docs#introduction\").\n  // However, ScrollToHash in App.tsx handles the scrolling.\n  \n  return (\n    <Link \n      to={to} \n      className={`block px-2 py-1.5 text-sm rounded-lg transition-colors ${\n        active \n          ? 'text-white bg-slate-800' \n          : 'text-slate-400 hover:text-white hover:bg-slate-800'\n      }`}\n    >\n      {label}\n    </Link>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/docs/DocsOverview.tsx",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\nimport { Link } from 'react-router-dom'\nimport {\n  CommandLineIcon,\n  GlobeAltIcon,\n  ShieldCheckIcon,\n  BoltIcon,\n  LockClosedIcon,\n  BookOpenIcon,\n  InformationCircleIcon\n} from '@heroicons/react/24/outline'\nimport CodeBlock from '../../components/CodeBlock'\n\nexport default function DocsOverview() {\n  return (\n    <>\n      <header className=\"mb-12\">\n        <div className=\"inline-flex items-center gap-2 text-indigo-400 font-medium mb-4\">\n          <BookOpenIcon className=\"w-5 h-5\" />\n          <span>Documentation</span>\n        </div>\n        <h1 className=\"text-4xl font-bold text-white mb-4\">Introduction</h1>\n        <p className=\"text-slate-400 text-lg leading-relaxed\">\n          Port Buddy is a tool that allows you to share a port opened on your local host or private network to the public network. \n          It's perfect for testing webhooks, sharing your work with clients, or exposing any local service securely.\n        </p>\n      </header>\n\n      <section id=\"introduction\" className=\"mb-16 scroll-mt-24\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">How it works</h2>\n        <p className=\"text-slate-400 mb-6\">\n          Port Buddy works as a reverse proxy. When you run the CLI, it establishes a secure connection to our edge servers. \n          Any traffic sent to your unique Port Buddy URL is then proxied through this connection to your local machine.\n        </p>\n        <div className=\"bg-slate-900 border border-slate-800 rounded-xl p-6\">\n          <h3 className=\"text-lg font-semibold text-white mb-4 flex items-center gap-2\">\n            <InformationCircleIcon className=\"w-5 h-5 text-indigo-400\" />\n            Key Features\n          </h3>\n          <ul className=\"grid sm:grid-cols-2 gap-4\">\n            <FeatureItem icon={<GlobeAltIcon className=\"w-5 h-5\" />} label=\"HTTP/HTTPS Tunnels\" />\n            <FeatureItem icon={<ShieldCheckIcon className=\"w-5 h-5\" />} label=\"TCP & UDP Support\" />\n            <FeatureItem icon={<BoltIcon className=\"w-5 h-5\" />} label=\"WebSocket Support\" />\n            <FeatureItem icon={<LockClosedIcon className=\"w-5 h-5\" />} label=\"Custom Domains\" />\n          </ul>\n        </div>\n      </section>\n\n      <section id=\"installation\" className=\"mb-16 scroll-mt-24\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">Installation</h2>\n        <p className=\"text-slate-400 mb-6\">\n          Before you can start using Port Buddy, you need to install the CLI on your machine. \n          We provide binaries for macOS, Linux, and Windows.\n        </p>\n        <Link \n          to=\"/install\" \n          className=\"inline-flex items-center gap-2 text-indigo-400 hover:text-indigo-300 font-medium\"\n        >\n          View Installation Guide\n          <CommandLineIcon className=\"w-4 h-4\" />\n        </Link>\n      </section>\n\n      <section id=\"authentication\" className=\"mb-16 scroll-mt-24\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">Authentication</h2>\n        <p className=\"text-slate-400 mb-6\">\n          To use Port Buddy, you must be authenticated. This allows us to manage your tunnels and respect your subscription limits.\n        </p>\n        <ol className=\"space-y-4 text-slate-400 list-decimal list-inside mb-6\">\n          <li>Log in to your account at <Link to=\"/login\" className=\"text-indigo-400 hover:underline\">portbuddy.dev</Link>.</li>\n          <li>Go to the <Link to=\"/app/tokens\" className=\"text-indigo-400 hover:underline\">Tokens</Link> page and generate a new API token.</li>\n          <li>Run the following command in your terminal:</li>\n        </ol>\n        <CodeBlock code=\"portbuddy init {YOUR_API_TOKEN}\" />\n      </section>\n\n      <section id=\"http-tunnels\" className=\"mb-16 scroll-mt-24\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">HTTP Tunnels</h2>\n        <p className=\"text-slate-400 mb-6\">\n          HTTP is the default mode for Port Buddy. It's used for web applications and APIs.\n        </p>\n        <h3 className=\"text-lg font-semibold text-white mb-3\">Usage</h3>\n        <CodeBlock code=\"portbuddy 3000\" />\n        <p className=\"text-slate-400 mt-4\">\n          This command will expose your local web server running on port 3000. \n          You will receive a public URL like <code className=\"text-indigo-300\">https://abc123.portbuddy.dev</code>.\n        </p>\n      </section>\n\n      <section id=\"tcp-tunnels\" className=\"mb-16 scroll-mt-24\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">TCP Tunnels</h2>\n        <p className=\"text-slate-400 mb-6\">\n          TCP mode allows you to expose any TCP-based service, such as databases or SSH.\n        </p>\n        <h3 className=\"text-lg font-semibold text-white mb-3\">Usage</h3>\n        <CodeBlock code=\"portbuddy tcp 5432\" />\n        <p className=\"text-slate-400 mt-4\">\n          This command exposes your local PostgreSQL database on port 5432. \n          You will get an address like <code className=\"text-indigo-300\">net-proxy-3.portbuddy.dev:43452</code>.\n        </p>\n      </section>\n\n      <section id=\"udp-tunnels\" className=\"mb-16 scroll-mt-24\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">UDP Tunnels</h2>\n        <p className=\"text-slate-400 mb-6\">\n          UDP mode is useful for game servers, VoIP, and other UDP-based protocols.\n        </p>\n        <h3 className=\"text-lg font-semibold text-white mb-3\">Usage</h3>\n        <CodeBlock code=\"portbuddy udp 19132\" />\n      </section>\n\n      <section id=\"run-as-service\" className=\"mb-16 scroll-mt-24\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">Run as Service</h2>\n        <p className=\"text-slate-400 mb-6\">\n          You can configure Port Buddy to run as a background service. This ensures that your tunnel starts automatically on system boot and restarts if it fails.\n        </p>\n        <p className=\"text-slate-400 mb-6\">\n          We provide helper scripts to set this up easily. You can run these scripts multiple times to set up different tunnels.\n        </p>\n        \n        <div className=\"space-y-8\">\n          <div>\n            <h3 className=\"text-lg font-semibold text-white mb-3\">Linux (systemd)</h3>\n            <CodeBlock code=\"curl -sSL https://portbuddy.dev/setup-portbuddy-service.sh | sudo bash -s -- [options] <mode> <port> [host]\" />\n          </div>\n\n          <div>\n            <h3 className=\"text-lg font-semibold text-white mb-3\">Windows (Scheduled Task)</h3>\n            <p className=\"text-slate-400 mb-2 text-sm\">Run as Administrator:</p>\n            <CodeBlock code={`iwr https://portbuddy.dev/setup-portbuddy-service.ps1 -OutFile setup-service.ps1\n./setup-service.ps1 [options] <mode> <port> [host]`} />\n          </div>\n        </div>\n\n        <h3 className=\"text-lg font-semibold text-white mb-3 mt-6\">Options</h3>\n        <ul className=\"list-disc list-inside text-slate-400 mb-4 space-y-1\">\n            <li><code className=\"text-indigo-300\">--name &lt;name&gt;</code> (Linux) or <code className=\"text-indigo-300\">-Name &lt;name&gt;</code> (Windows) - Custom name for the service.</li>\n        </ul>\n        \n        <h3 className=\"text-lg font-semibold text-white mb-3 mt-6\">Example</h3>\n        <p className=\"text-slate-400 mb-4\">\n          To expose port 22 (SSH) over TCP and run it as a service:\n        </p>\n        \n        <div className=\"grid gap-4\">\n            <div>\n                <p className=\"text-sm text-slate-500 mb-2 font-semibold\">Linux:</p>\n                <CodeBlock code=\"curl -sSL https://portbuddy.dev/setup-portbuddy-service.sh | sudo bash -s -- tcp 22\" />\n            </div>\n            <div>\n                <p className=\"text-sm text-slate-500 mb-2 font-semibold\">Windows:</p>\n                <CodeBlock code=\"./setup-service.ps1 tcp 22\" />\n            </div>\n        </div>\n        \n        <p className=\"text-slate-400 mt-4 mb-4\">\n          By default, the service name follows the pattern <code className=\"text-indigo-300\">portbuddy-&lt;mode&gt;-&lt;port&gt;</code>.\n        </p>\n\n        <h3 className=\"text-lg font-semibold text-white mb-3 mt-6\">Custom Service Name</h3>\n        <div className=\"grid gap-4\">\n            <div>\n                <p className=\"text-sm text-slate-500 mb-2 font-semibold\">Linux:</p>\n                <CodeBlock code=\"curl -sSL https://portbuddy.dev/setup-portbuddy-service.sh | sudo bash -s -- --name my-ssh-service tcp 22\" />\n            </div>\n            <div>\n                <p className=\"text-sm text-slate-500 mb-2 font-semibold\">Windows:</p>\n                <CodeBlock code=\"./setup-service.ps1 -Name my-ssh-service tcp 22\" />\n            </div>\n        </div>\n        \n        <h3 className=\"text-lg font-semibold text-white mb-3 mt-6\">Managing the Service</h3>\n        <div className=\"grid md:grid-cols-2 gap-4\">\n            <div>\n                <p className=\"text-sm text-slate-500 mb-2 font-semibold\">Linux (systemctl):</p>\n                <CodeBlock code={`sudo systemctl status portbuddy-tcp-22\nsudo systemctl stop portbuddy-tcp-22\nsudo systemctl start portbuddy-tcp-22`} />\n            </div>\n            <div>\n                <p className=\"text-sm text-slate-500 mb-2 font-semibold\">Windows (PowerShell):</p>\n                <CodeBlock code={`Get-ScheduledTask -TaskName portbuddy-tcp-22\nStop-ScheduledTask -TaskName portbuddy-tcp-22\nStart-ScheduledTask -TaskName portbuddy-tcp-22`} />\n            </div>\n        </div>\n      </section>\n\n      <section id=\"custom-domains\" className=\"mb-16 scroll-mt-24\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">Custom Domains</h2>\n        <p className=\"text-slate-400 mb-6\">\n          With a Pro or Team plan, you can use your own domain name for your tunnels. \n          We handle the SSL certificate issuance and renewal for you automatically.\n        </p>\n        <p className=\"text-slate-400\">\n          Configure your custom domains in the <Link to=\"/app/domains\" className=\"text-indigo-400 hover:underline\">Domains</Link> section of the dashboard.\n        </p>\n      </section>\n\n      <section id=\"pricing-limits\" className=\"mb-16 scroll-mt-24\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">Pricing & Limits</h2>\n        <p className=\"text-slate-400 mb-6\">\n          Port Buddy offers two simple plans to suit your needs.\n        </p>\n        <div className=\"grid sm:grid-cols-2 gap-6\">\n          <div className=\"bg-slate-900/50 border border-slate-800 rounded-xl p-6\">\n            <h3 className=\"text-xl font-bold text-white mb-2\">Pro</h3>\n            <p className=\"text-indigo-400 font-bold mb-4\">$0 / mo</p>\n            <ul className=\"text-sm text-slate-400 space-y-2\">\n              <li>• 1 free tunnel at a time</li>\n              <li>• Custom domains</li>\n              <li>• Static subdomains</li>\n              <li>• $1/mo per extra tunnel</li>\n            </ul>\n          </div>\n          <div className=\"bg-slate-900/50 border border-indigo-500/30 rounded-xl p-6 shadow-lg shadow-indigo-500/10\">\n            <h3 className=\"text-xl font-bold text-white mb-2\">Team</h3>\n            <p className=\"text-indigo-400 font-bold mb-4\">$10 / mo</p>\n            <ul className=\"text-sm text-slate-400 space-y-2\">\n              <li>• 10 free tunnels at a time</li>\n              <li>• Team members</li>\n              <li>• Priority support</li>\n              <li>• $1/mo per extra tunnel</li>\n            </ul>\n          </div>\n        </div>\n      </section>\n    </>\n  )\n}\n\nfunction FeatureItem({ icon, label }: { icon: React.ReactNode, label: string }) {\n  return (\n    <li className=\"flex items-center gap-3 text-sm text-slate-300\">\n      <div className=\"text-indigo-400\">{icon}</div>\n      {label}\n    </li>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/docs/guides/HytaleGuide.tsx",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\nimport { BookOpenIcon } from '@heroicons/react/24/outline'\nimport CodeBlock from '../../../components/CodeBlock'\n\nexport default function HytaleGuide() {\n  return (\n    <>\n      <header className=\"mb-12\">\n        <div className=\"inline-flex items-center gap-2 text-indigo-400 font-medium mb-4\">\n          <BookOpenIcon className=\"w-5 h-5\" />\n          <span>How-to Guides</span>\n        </div>\n        <h1 className=\"text-4xl font-bold text-white mb-4\">Hosting a Hytale Server</h1>\n        <p className=\"text-slate-400 text-lg leading-relaxed\">\n          Learn how to expose your local Hytale server to the internet using Port Buddy, allowing your friends to join your adventure without complex network configuration.\n        </p>\n      </header>\n\n      <section className=\"mb-16\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">Prerequisites</h2>\n        <ul className=\"list-disc list-inside text-slate-400 space-y-2 mb-6\">\n          <li>A running Hytale Server on your local machine.</li>\n          <li>Port Buddy CLI installed and authenticated.</li>\n        </ul>\n      </section>\n\n      <section className=\"mb-16\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">Exposing the Server</h2>\n        <p className=\"text-slate-400 mb-4\">\n          Hytale servers use UDP port <strong>5520</strong> by default.\n        </p>\n        \n        <h3 className=\"text-lg font-semibold text-white mb-3\">1. Start your Hytale Server</h3>\n        <p className=\"text-slate-400 mb-4\">\n          Launch your Hytale server and ensure it is running locally.\n        </p>\n\n        <h3 className=\"text-lg font-semibold text-white mb-3\">2. Expose the port</h3>\n        <p className=\"text-slate-400 mb-4\">\n          Run the following command in your terminal:\n        </p>\n        <div className=\"mb-4\">\n            <CodeBlock code=\"portbuddy udp 5520\" />\n        </div>\n        \n        <p className=\"text-slate-400 mb-4\">\n          You will see output similar to this:\n        </p>\n        <div className=\"bg-slate-950 border border-slate-800 rounded-lg p-4 font-mono text-sm text-slate-300 mb-6\">\n          udp localhost:5520 exposed to: net-proxy-2.portbuddy.dev:54321\n        </div>\n\n        <h3 className=\"text-lg font-semibold text-white mb-3\">3. Connect</h3>\n        <p className=\"text-slate-400 mb-6\">\n          Share the address (e.g., <code>net-proxy-2.portbuddy.dev:54321</code>) with your friends. \n          They can use this address to connect to your server from the game client.\n        </p>\n      </section>\n    </>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/docs/guides/MinecraftGuide.tsx",
    "content": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n */\n\nimport { BookOpenIcon } from '@heroicons/react/24/outline'\nimport CodeBlock from '../../../components/CodeBlock'\n\nexport default function MinecraftGuide() {\n  return (\n    <>\n      <header className=\"mb-12\">\n        <div className=\"inline-flex items-center gap-2 text-indigo-400 font-medium mb-4\">\n          <BookOpenIcon className=\"w-5 h-5\" />\n          <span>How-to Guides</span>\n        </div>\n        <h1 className=\"text-4xl font-bold text-white mb-4\">Hosting a Minecraft Server</h1>\n        <p className=\"text-slate-400 text-lg leading-relaxed\">\n          Learn how to expose your local Minecraft server to the internet using Port Buddy, allowing your friends to join without port forwarding or configuring your router.\n        </p>\n      </header>\n\n      <section className=\"mb-16\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">Prerequisites</h2>\n        <ul className=\"list-disc list-inside text-slate-400 space-y-2 mb-6\">\n          <li>A running Minecraft Server (Java or Bedrock Edition) on your local machine.</li>\n          <li>Port Buddy CLI installed and authenticated.</li>\n        </ul>\n      </section>\n\n      <section className=\"mb-16\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">Java Edition</h2>\n        <p className=\"text-slate-400 mb-4\">\n          Minecraft Java Edition uses TCP port <strong>25565</strong> by default.\n        </p>\n        \n        <h3 className=\"text-lg font-semibold text-white mb-3\">1. Start your Minecraft Server</h3>\n        <p className=\"text-slate-400 mb-4\">\n          Ensure your server is running and accessible locally (usually at <code>localhost:25565</code>).\n        </p>\n\n        <h3 className=\"text-lg font-semibold text-white mb-3\">2. Expose the port</h3>\n        <p className=\"text-slate-400 mb-4\">\n          Run the following command in your terminal:\n        </p>\n        <div className=\"mb-4\">\n            <CodeBlock code=\"portbuddy tcp 25565\" />\n        </div>\n        \n        <p className=\"text-slate-400 mb-4\">\n          You will see output similar to this:\n        </p>\n        <div className=\"bg-slate-950 border border-slate-800 rounded-lg p-4 font-mono text-sm text-slate-300 mb-6\">\n          tcp localhost:25565 exposed to: net-proxy-1.portbuddy.dev:42123\n        </div>\n\n        <h3 className=\"text-lg font-semibold text-white mb-3\">3. Connect</h3>\n        <p className=\"text-slate-400 mb-6\">\n          Share the address (e.g., <code>net-proxy-1.portbuddy.dev:42123</code>) with your friends. \n          They can enter this address in the Multiplayer menu under \"Direct Connection\" or \"Add Server\".\n        </p>\n      </section>\n\n      <section className=\"mb-16\">\n        <h2 className=\"text-2xl font-bold text-white mb-6\">Bedrock Edition</h2>\n        <p className=\"text-slate-400 mb-4\">\n          Minecraft Bedrock Edition uses UDP port <strong>19132</strong> by default.\n        </p>\n        \n        <h3 className=\"text-lg font-semibold text-white mb-3\">1. Start your Bedrock Server</h3>\n        <p className=\"text-slate-400 mb-4\">\n          Ensure your server is running locally.\n        </p>\n\n        <h3 className=\"text-lg font-semibold text-white mb-3\">2. Expose the port</h3>\n        <p className=\"text-slate-400 mb-4\">\n            Run the following command:\n        </p>\n        <div className=\"mb-4\">\n            <CodeBlock code=\"portbuddy udp 19132\" />\n        </div>\n        \n        <h3 className=\"text-lg font-semibold text-white mb-3\">3. Connect</h3>\n        <p className=\"text-slate-400 mb-6\">\n          Share the generated address and port with your friends. They can add it to their server list.\n        </p>\n      </section>\n    </>\n  )\n}\n"
  },
  {
    "path": "web/tailwind.config.js",
    "content": "/**** @type {import('tailwindcss').Config} ****/\nexport default {\n  content: [\n    './index.html',\n    './src/**/*.{ts,tsx}',\n  ],\n  theme: {\n    extend: {\n      fontFamily: {\n        mono: ['\"JetBrains Mono\"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace']\n      },\n      colors: {\n        primary: {\n          50: '#f8fafc',\n          100: '#f1f5f9',\n          200: '#e2e8f0',\n          300: '#cbd5e1',\n          400: '#94a3b8',\n          500: '#64748b',\n          600: '#475569',\n          700: '#334155',\n          800: '#1e293b',\n          900: '#0f172a',\n          950: '#0b1020',\n        },\n        accent: {\n          50: '#ecfeff',\n          100: '#cffafe',\n          200: '#a5f3fc',\n          300: '#67e8f9',\n          400: '#22d3ee',\n          500: '#06b6d4',\n          600: '#0891b2',\n          700: '#0e7490',\n          800: '#155e75',\n          900: '#164e63',\n        },\n        jb: {\n          purple: '#cc33ff',\n          pink: '#ff3399',\n          orange: '#ff9933',\n          blue: '#33ccff',\n        }\n      },\n      backgroundImage: {\n        'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',\n        'mesh-gradient': 'radial-gradient(at 0% 0%, rgba(204, 51, 255, 0.15) 0, transparent 50%), radial-gradient(at 50% 0%, rgba(34, 211, 238, 0.15) 0, transparent 50%), radial-gradient(at 100% 0%, rgba(255, 51, 153, 0.15) 0, transparent 50%)',\n      },\n      keyframes: {\n        progress: {\n          '0%': { transform: 'translateX(-100%)' },\n          '50%': { transform: 'translateX(-30%)' },\n          '100%': { transform: 'translateX(0%)' },\n        },\n        flow: {\n          '0%': { left: '-10%' },\n          '100%': { left: '110%' },\n        },\n      },\n      animation: {\n        progress: 'progress 2s ease-in-out infinite',\n        flow: 'flow 3s linear infinite',\n        'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',\n      },\n    },\n  },\n  plugins: [],\n}\n"
  },
  {
    "path": "web/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"strict\": true,\n    \"baseUrl\": \".\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "web/vite.config.ts",
    "content": "import { defineConfig, loadEnv } from 'vite'\nimport react from '@vitejs/plugin-react'\nimport Prerender from '@prerenderer/rollup-plugin'\nimport Renderer from '@prerenderer/renderer-puppeteer'\nimport path from 'path'\nimport fs from 'fs'\n\n// https://vitejs.dev/config/\nexport default defineConfig(({ mode }) => {\n  const env = loadEnv(mode, process.cwd());\n\n  return {\n    define: {\n      'process.env.NODE_ENV': JSON.stringify('development')\n    },\n    plugins: [\n      react(),\n      Prerender({\n        routes: ['/', '/index', '/install', '/docs', '/docs/guides/minecraft-server', '/docs/guides/hytale-server', '/privacy', '/terms', '/contacts'],\n        renderer: new Renderer({\n          renderAfterDocumentEvent: 'render-event',\n        }),\n        staticDir: path.join(__dirname, 'dist'),\n        postProcess(renderedRoute) {\n          const route = renderedRoute.route;\n          renderedRoute.html = renderedRoute.html.replace(\n            '<div id=\"root\">',\n            `<div id=\"root\" data-prerendered-route=\"${route}\">`\n          );\n\n          let templatePath = '';\n\n          // Check for specific guide html files\n          if (route.startsWith('/docs/guides/')) {\n            const guideName = route.substring('/docs/guides/'.length);\n            const potentialPath = `public/pages/docs/guides/${guideName}.html`;\n            if (fs.existsSync(path.join(__dirname, potentialPath))) {\n              templatePath = potentialPath;\n            }\n          }\n\n          if (!templatePath) {\n            if (route === '/' || route === '/index') templatePath = 'public/pages/index.html';\n            else if (route === '/install') templatePath = 'public/pages/install.html';\n            else if (route === '/docs' || route.startsWith('/docs/')) templatePath = 'public/pages/docs.html';\n            else if (route === '/privacy') templatePath = 'public/pages/privacy.html';\n            else if (route === '/terms') templatePath = 'public/pages/terms.html';\n            else if (route === '/contacts') templatePath = 'public/pages/contacts.html';\n          }\n\n          if (templatePath) {\n            let template = fs.readFileSync(path.join(__dirname, templatePath), 'utf8');\n\n            // Replace environment variables\n            Object.keys(env).forEach((key) => {\n              if (key.startsWith('VITE_')) {\n                template = template.replace(new RegExp(`%${key}%`, 'g'), env[key]);\n              }\n            });\n\n            const titleMatch = template.match(/<title>(.*?)<\\/title>/);\n            const headContent = template.match(/<head>([\\s\\S]*?)<\\/head>/);\n\n            if (titleMatch) {\n              renderedRoute.html = renderedRoute.html.replace(/<title>.*?<\\/title>/, titleMatch[0]);\n            }\n            if (headContent) {\n              // Extract meta and link tags from template head\n              const tags = headContent[1].match(/<(meta|link|script type=\"application\\/ld\\+json\")[\\s\\S]*?>([\\s\\S]*?<\\/(script)>)?/g);\n              if (tags) {\n                const headEndIndex = renderedRoute.html.indexOf('</head>');\n                let newHead = renderedRoute.html.substring(0, headEndIndex);\n                tags.forEach(tag => {\n                  // Avoid duplicating tags if they already exist with same name/property/rel\n                  // Simple check to avoid some common duplicates\n                  const nameMatch = tag.match(/(name|property|rel|type)=\"([^\"]+)\"/);\n                  if (nameMatch) {\n                    const attr = nameMatch[1];\n                    const val = nameMatch[2];\n                    if (renderedRoute.html.includes(`${attr}=\"${val}\"`)) {\n                      // Replace existing tag if found (simplified)\n                      const existingTagRegex = new RegExp(`<[^>]*${attr}=\"${val}\"[^>]*>`, 'g');\n                      newHead = newHead.replace(existingTagRegex, tag);\n                      return;\n                    }\n                  }\n                  newHead += `\\n    ${tag}`;\n                });\n                renderedRoute.html = newHead + renderedRoute.html.substring(headEndIndex);\n              }\n            }\n          }\n          return renderedRoute;\n        }\n      }),\n    ],\n  build: {\n    minify: false,\n    sourcemap: true,\n  },\n};\n});\n"
  }
]