Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	frontend/package-lock.json
This commit is contained in:
Anthony Stirling
2026-08-10 11:48:57 +01:00
379 changed files with 87338 additions and 37484 deletions
+23 -13
View File
@@ -1,18 +1,28 @@
# All PRs must be approved by Frooodle or Ludy87
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
# Review ownership is assigned to teams where possible.
# Teams can only contain org members, so outside collaborators are listed by hand.
#
# @Stirling-Tools/maintainers - Frooodle, jbrunton96, ConnorYoh
# @Stirling-Tools/backend-reviewers - Frooodle, jbrunton96, ConnorYoh
# @Stirling-Tools/frontend-reviewers - Frooodle, jbrunton96, ConnorYoh, reecebrowne, EthanHealy01
# @Stirling-Tools/devops-reviewers - Frooodle, jbrunton96, ConnorYoh
# @Stirling-Tools/all - all of the above
#
# Outside collaborators (need Write access to count as owners): @Ludy87 @balazs-szucs
# Default owners for everything
* @Stirling-Tools/maintainers @Ludy87
# Backend
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs
/app/** @Stirling-Tools/backend-reviewers @Ludy87 @balazs-szucs
#V2 frontend
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
# V2 frontend
/frontend/** @Stirling-Tools/frontend-reviewers @balazs-szucs
/app/core/src/main/resources/static/** @Stirling-Tools/frontend-reviewers @Ludy87 @balazs-szucs
#V2 docker
/docker/backend/** @Frooodle @Ludy87 @DarioGii
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
# V2 docker
/docker/backend/** @Stirling-Tools/devops-reviewers @Ludy87
/docker/frontend/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87
/docker/compose/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87
#GHA (All users)
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
# GHA (all users)
/.github/** @Stirling-Tools/all @Ludy87 @balazs-szucs
-1
View File
@@ -11,7 +11,6 @@
"LaserKaspar",
"sbplat",
"reecebrowne",
"DarioGii",
"ConnorYoh",
"EthanHealy01",
"jbrunton96",
+5 -8
View File
@@ -16,6 +16,11 @@ updates:
cooldown:
default-days: 7
rebase-strategy: "auto"
groups:
simple-java-mail:
patterns:
- "org.simplejavamail:simple-java-mail"
- "org.simplejavamail:outlook-module"
- package-ecosystem: "docker"
directories:
@@ -73,14 +78,6 @@ updates:
- "react-dom"
- "@types/react"
- "@types/react-dom"
typescript-eslint:
patterns:
- "@typescript-eslint/*"
- "typescript-eslint"
eslint:
patterns:
- "eslint"
- "@eslint/*"
vite:
patterns:
- "vite"
+2 -2
View File
@@ -86,7 +86,7 @@ jobs:
fi
fi
else
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
if [ "$is_auth" = true ]; then
should=true
@@ -193,7 +193,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -54,7 +54,6 @@ jobs:
github.event.comment.user.login == 'Ludy87' ||
github.event.comment.user.login == 'balazs-szucs' ||
github.event.comment.user.login == 'reecebrowne' ||
github.event.comment.user.login == 'DarioGii' ||
github.event.comment.user.login == 'EthanHealy01' ||
github.event.comment.user.login == 'jbrunton96' ||
github.event.comment.user.login == 'ConnorYoh'
@@ -206,15 +205,21 @@ jobs:
token: ${{ steps.setup-bot.outputs.token }}
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -236,7 +241,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
+7 -11
View File
@@ -35,25 +35,21 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+12 -2
View File
@@ -56,10 +56,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -320,7 +330,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
cache: "pip"
+44 -9
View File
@@ -60,8 +60,43 @@ jobs:
with:
filters: .github/config/.files.yaml
build:
gradle-cache-prime:
name: Prime shared Gradle cache
needs: [files-changed]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Resolve backend dependencies
run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon
env:
STIRLING_FLAVOR: saas
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
build:
needs: [files-changed, gradle-cache-prime]
permissions:
actions: read
contents: read
@@ -76,7 +111,7 @@ jobs:
# works after Hibernate's ddl-auto=update migrates the schema. Gated on
# the `project` filter so doc-only PRs skip this ~5-minute job.
if: needs.files-changed.outputs.project == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/db-migration-test.yml
@@ -84,7 +119,7 @@ jobs:
check-generateOpenApiDocs:
if: needs.files-changed.outputs.openapi == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/check-openapi.yml
@@ -120,7 +155,7 @@ jobs:
playwright-e2e-live:
if: needs.files-changed.outputs.frontend == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/e2e-live.yml
@@ -128,7 +163,7 @@ jobs:
playwright-e2e-enterprise:
if: needs.files-changed.outputs.proprietary == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/build-enterprise.yml
@@ -136,7 +171,7 @@ jobs:
check-licence:
if: needs.files-changed.outputs.build == 'true'
needs: [files-changed, build]
needs: [files-changed, build, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/check-licence.yml
@@ -144,7 +179,7 @@ jobs:
docker-compose-tests:
if: needs.files-changed.outputs.project == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
actions: write
contents: read
@@ -156,7 +191,7 @@ jobs:
test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
needs: [files-changed, build, check-generateOpenApiDocs, check-licence]
needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime]
permissions:
contents: read
packages: read
@@ -199,7 +234,7 @@ jobs:
# frontend filter, so a CSS-only PR does not pay for a backend build.
generated-models:
if: needs.files-changed.outputs.generated-models == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
pull-requests: write
+10 -4
View File
@@ -37,15 +37,21 @@ jobs:
cache-suffix: generated-models
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.0
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+7 -11
View File
@@ -21,25 +21,21 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+7 -11
View File
@@ -22,25 +22,21 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+10 -14
View File
@@ -41,25 +41,21 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -81,7 +77,7 @@ jobs:
# Each lands as a sibling dir under coverage-execs/, with the .exec
# files preserving their original relative paths.
- name: Download all .exec artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: jacoco-exec-*
path: coverage-execs/
@@ -206,7 +202,7 @@ jobs:
# absence on backend-only runs by skipping the download entirely
# when the producer job was not part of this workflow run.
if: inputs.frontend-validation-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-coverage
path: matrix-inputs/vitest/
@@ -216,7 +212,7 @@ jobs:
# e2e-live uploads the artifact with a stable name. Skip the
# download entirely when the producer job did not run.
if: inputs.playwright-e2e-live-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: playwright-frontend-coverage
path: matrix-inputs/playwright/
+9 -13
View File
@@ -25,28 +25,24 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: 25
distribution: temurin
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
# No `-PnoSpotless` here yet because the upstream cache layer matches the
# backend build's; reuse keeps cold-cache cost identical.
# Keep the normal formatting path here so this smoke test exercises the
# same Gradle configuration as the backend build.
- name: Build Stirling-PDF JAR
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
fi
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
+7 -11
View File
@@ -33,25 +33,21 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# When the PR changes the base image, test.sh builds it locally
# (stirling-pdf-base:local) into the daemon image store. A buildx
+7 -12
View File
@@ -21,25 +21,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
# Same cache layer as backend-build.yml. Without it every run resolved the
# whole classpath cold and eventually got HTTP 429 from Maven Central.
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# Gradle does not retry 429s, and a cold cache resolving the buildscript
# classpath is exactly where Maven Central rate-limits us. Retry it here,
# where a failure is cheap, instead of inside the backgrounded bootRun.
@@ -345,15 +345,21 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+28 -21
View File
@@ -52,25 +52,21 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependencies
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -146,15 +142,21 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -238,7 +240,7 @@ jobs:
# before the second setup-java overwrites JAVA_HOME.
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
@@ -250,15 +252,21 @@ jobs:
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -467,7 +475,7 @@ jobs:
fi
- name: Build Tauri app
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -497,7 +505,6 @@ jobs:
projectPath: ./frontend/editor
tauriScript: npx tauri
args: ${{ matrix.args }}
updaterJsonKeepUniversal: true
# Bundled libwayland conflicts with the host's on some distros (Fedora
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). Repack without it,
@@ -894,7 +901,7 @@ jobs:
# instead of silently shipping a broken auto-update.
- name: Upload binaries to Release
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
# Don't regenerate/append notes on re-runs, and don't force this into the
+4 -4
View File
@@ -50,13 +50,13 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -67,7 +67,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Convert repository owner to lowercase
id: repoowner
@@ -75,7 +75,7 @@ jobs:
- name: Generate tags for base image
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-base
+11 -15
View File
@@ -60,25 +60,21 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependencies
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Docker Buildx
id: buildx
@@ -107,20 +103,20 @@ jobs:
cosign-release: "v2.4.1"
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Convert repository owner to lowercase
id: repoowner
@@ -129,7 +125,7 @@ jobs:
- name: Generate tags for latest
id: meta
if: env.RUN_MAIN_APP == 'true'
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
@@ -177,7 +173,7 @@ jobs:
- name: Generate tags for latest-fat
id: meta-fat
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
@@ -221,7 +217,7 @@ jobs:
- name: Generate tags for ultra-lite
id: meta-lite
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
+2 -2
View File
@@ -26,13 +26,13 @@ jobs:
uses: imjasonh/setup-crane@31b88afe9de28ae0ffa220711af4b60be9435f6e # v0.4
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
with:
results_file: results.sarif
results_format: sarif
+10 -4
View File
@@ -34,15 +34,21 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+14 -8
View File
@@ -162,7 +162,7 @@ jobs:
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
@@ -174,15 +174,21 @@ jobs:
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -380,7 +386,7 @@ jobs:
- name: Build Tauri app (signed)
if: env.SIGN_BUNDLE == 'true'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -415,7 +421,7 @@ jobs:
- name: Build Tauri app (unsigned)
if: env.SIGN_BUNDLE != 'true'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: "0"
@@ -441,7 +447,7 @@ jobs:
- name: Build Tauri app (Linux AppImage)
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
continue-on-error: true
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
+10 -14
View File
@@ -61,7 +61,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -79,25 +79,21 @@ jobs:
echo "Disk space after cleanup:" && df -h
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -111,7 +107,7 @@ jobs:
STIRLING_PDF_DESKTOP_UI: false
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
id: buildx
@@ -203,7 +199,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
id: buildx
+11 -5
View File
@@ -33,15 +33,21 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Build with Gradle
run: ./gradlew build
@@ -61,7 +67,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
+3
View File
@@ -22,6 +22,9 @@ pipeline/
customFiles/
configs/
watchedFolders/
# The rule above targets the app's runtime watched-folders working dir, but it
# also matches this frontend source component dir; keep the source tracked.
!frontend/editor/src/proprietary/components/watchedFolders/
clientWebUI/
# Scratch dir used by local fixture-regeneration runs (see
# app/proprietary/src/test/resources/db-migration-fixtures/README.md).
+1 -1
View File
@@ -22,7 +22,7 @@ frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
app/core/src/main/java/stirling/software/SPDF/pdf/signature/CreateSignatureBase.java:generic-api-key:224
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
+1
View File
@@ -146,6 +146,7 @@ tasks:
swagger:
desc: "Generate OpenAPI docs"
run: once
cmds:
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:copySwaggerDoc"
platforms: [windows]
+5 -14
View File
@@ -260,8 +260,7 @@ tasks:
desc: "Run linting"
deps: [install]
cmds:
- task: lint:eslint
- task: lint:dpdm
- task: lint:oxlint
- task: lint:colors
- task: lint:css
@@ -290,25 +289,17 @@ tasks:
cmds:
- node editor/scripts/lint/theme-lint.mjs contrast
lint:eslint:
desc: "Run ESLint linting"
lint:oxlint:
desc: "Run oxlint linting"
deps: [install]
cmds:
- npx eslint --max-warnings=0
lint:dpdm:
desc: "Run circular import linting"
deps: [install]
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
# shell-agnostic. Covers the whole editor tree, including the portal layer.
- npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
- npx oxlint --config oxlint.config.ts --max-warnings=0
lint:fix:
desc: "Auto-fix lint issues"
deps: [install]
cmds:
- npx eslint --fix
- npx oxlint --config oxlint.config.ts --fix
format:
desc: "Auto-fix code formatting"
+1 -1
View File
@@ -19,6 +19,6 @@
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
"oxc.oxc-vscode", // Oxc (oxlint) extension for JavaScript/TypeScript linting
]
}
+1 -1
View File
@@ -192,7 +192,7 @@ What goes where:
- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`.
- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (all enforced by the linter). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
+1 -1
View File
@@ -158,7 +158,7 @@ Stirling-PDF/
│ │ │ └── locales/ # Internationalization files (JSON)
│ │ └── vite.config.ts # Vite configuration
│ ├── package.json # Shared workspace dependencies
│ └── eslint.config.mjs # Shared lint config
│ └── oxlint.config.ts # Shared lint config
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
├── docs/ # Documentation files
├── exampleYmlFiles/ # Example YAML configuration files
+8 -8
View File
@@ -156,6 +156,14 @@
"moduleName": ".*",
"moduleLicense": "GNU GENERAL PUBLIC LICENSE, Version 2 + Classpath Exception"
},
{
"moduleName": ".*",
"moduleLicense": "GNU Lesser Public License"
},
{
"moduleName": ".*",
"moduleLicense": "The GNU Lesser General Public License"
},
{
"moduleName": "com.martiansoftware:jsap",
"moduleLicense": "LGPL"
@@ -224,14 +232,6 @@
"moduleName": "com.google.re2j:re2j",
"moduleLicense": "Go License"
},
{
"moduleName": "com.hubspot:algebra",
"moduleLicense": null
},
{
"moduleName": "com.hubspot.immutables:immutables-exceptions",
"moduleLicense": null
},
{
"moduleName": ".*",
"moduleLicense": "UnRar License"
+4 -4
View File
@@ -10,19 +10,19 @@ dependencies {
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
api "org.apache.commons:commons-lang3:${commonsLang3}"
api 'com.drewnoakes:metadata-extractor:2.20.0' // Image metadata extractor
api 'com.drewnoakes:metadata-extractor:2.21.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
api "org.apache.pdfbox:preflight:$pdfboxVersion"
api 'com.github.junrar:junrar:7.6.0' // RAR archive support for CBR files
api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:8.12.6'
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
api 'org.simplejavamail:simple-java-mail:9.2.0'
api 'org.simplejavamail:outlook-module:9.2.0' // MSG file support
api 'jakarta.mail:jakarta.mail-api:2.1.5'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
@@ -983,7 +983,7 @@ public class PdfMarkdownConverter {
ordered.sort(Comparator.comparingDouble((Line l) -> l.y).reversed());
StringBuilder sb = new StringBuilder();
for (Line l : ordered) {
if (sb.length() > 0) {
if (!sb.isEmpty()) {
sb.append(' ');
}
sb.append(l.text);
@@ -17,7 +17,6 @@ import java.util.concurrent.Semaphore;
import java.util.function.Consumer;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.examples.util.DeletingRandomAccessFile;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.io.MemoryUsageSetting;
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
@@ -31,6 +30,7 @@ import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.DeletingRandomAccessFile;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFileManager;
@@ -19,6 +19,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import jakarta.annotation.PreDestroy;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
@@ -63,6 +64,21 @@ public class JobExecutorService {
"Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs);
}
/** Stop the service-owned executor when the application context is closed or restarted. */
@PreDestroy
public void shutdown() {
log.debug("Shutting down job executor");
executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
executor.shutdownNow();
}
}
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
return runJobGeneric(async, work, -1);
}
@@ -225,19 +225,21 @@ public class MobileScannerService {
Path sessionDir = getSafeSessionDirectory(sessionId);
if (Files.exists(sessionDir)) {
// Delete all files in session directory
Files.walk(sessionDir)
.sorted(
(a, b) ->
-a.compareTo(b)) // Reverse order to delete files before
// directory
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete file: {}", path, e);
}
});
try (var paths = Files.walk(sessionDir)) {
paths.sorted(
(a, b) ->
-a.compareTo(
b)) // Reverse order to delete files before
// directory
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete file: {}", path, e);
}
});
}
}
log.info("Deleted session: {}", sessionId);
} catch (IllegalArgumentException e) {
@@ -48,6 +48,10 @@ public class TaskManager {
@Value("${stirling.jobResultExpiryMinutes:30}")
private int jobResultExpiryMinutes = 30;
/** Maximum age of a task that never reached a terminal state. */
@Value("${stirling.job.pendingExpiryMinutes:1440}")
private int pendingJobExpiryMinutes = 1440;
private final FileStorage fileStorage;
private final JobStore jobStore;
private final ClusterBackplane clusterBackplane;
@@ -332,19 +336,32 @@ public class TaskManager {
}
LocalDateTime expiryThreshold =
LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
LocalDateTime pendingExpiryThreshold =
LocalDateTime.now().minus(pendingJobExpiryMinutes, ChronoUnit.MINUTES);
int removedCount = 0;
try {
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
JobResult result = entry.getValue();
// Remove completed jobs that are older than the expiry threshold
if (result.isComplete()
&& result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold)) {
boolean expiredCompletedJob =
result.isComplete()
&& result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold);
boolean abandonedPendingJob =
!result.isComplete()
&& result.getCreatedAt() != null
&& result.getCreatedAt().isBefore(pendingExpiryThreshold);
// Remove old terminal results and abandoned pending jobs. Without the second
// branch, a client that starts a task and never completes it keeps its result in
// memory forever.
if (expiredCompletedJob || abandonedPendingJob) {
// Clean up file results
cleanupJobFiles(result, entry.getKey());
if (expiredCompletedJob) {
cleanupJobFiles(result, entry.getKey());
}
// Remove the job result
jobResults.remove(entry.getKey());
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.util;
package stirling.software.common.util;
import java.io.File;
import java.io.IOException;
@@ -194,7 +194,7 @@ public class EmlParser {
}
attachment.setFilename(filename);
String contentId = embedded ? stripCid(resourceName) : null;
String contentId = embedded ? stripCid(resource.getContentId()) : null;
attachment.setContentId(contentId);
String detectedContentType = EmlProcessingUtils.detectMimeType(filename, contentType);
@@ -941,7 +941,7 @@ public class GeneralUtils {
}
// If no MAC address found, use hostname as fallback
if (sb.length() == 0) {
if (sb.isEmpty()) {
String hostname = InetAddress.getLocalHost().getHostName();
sb.append(hostname != null ? hostname : "unknown-host");
log.warn("No MAC address found, using hostname for fingerprint generation");
@@ -1,17 +1,22 @@
package stirling.software.common.util;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.util.concurrent.UncheckedExecutionException;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public final class RegexPatternUtils {
private static final RegexPatternUtils INSTANCE = new RegexPatternUtils();
private final ConcurrentHashMap<PatternKey, Pattern> patternCache = new ConcurrentHashMap<>();
private static final long MAX_CACHED_PATTERNS = 512;
private final Cache<PatternKey, Pattern> patternCache =
CacheBuilder.newBuilder().maximumSize(MAX_CACHED_PATTERNS).build();
private static final String WHITESPACE_REGEX = "\\s++";
private static final String EXTENSION_REGEX = "\\.(?:[^.]*+)?$";
@@ -51,7 +56,7 @@ public final class RegexPatternUtils {
throw new IllegalArgumentException("Regex pattern cannot be null");
}
return patternCache.computeIfAbsent(new PatternKey(regex, 0), this::compilePattern);
return getOrCompile(new PatternKey(regex, 0));
}
/**
@@ -77,7 +82,7 @@ public final class RegexPatternUtils {
throw new IllegalArgumentException("Regex pattern cannot be null");
}
return patternCache.computeIfAbsent(new PatternKey(regex, flags), this::compilePattern);
return getOrCompile(new PatternKey(regex, flags));
}
/**
@@ -98,7 +103,7 @@ public final class RegexPatternUtils {
* @return true if pattern is cached, false otherwise
*/
public boolean isCached(String regex, int flags) {
return regex != null && patternCache.containsKey(new PatternKey(regex, flags));
return regex != null && patternCache.getIfPresent(new PatternKey(regex, flags)) != null;
}
/**
@@ -107,7 +112,7 @@ public final class RegexPatternUtils {
* @return number of patterns currently cached
*/
public int getCacheSize() {
return patternCache.size();
return (int) patternCache.size();
}
/**
@@ -115,7 +120,7 @@ public final class RegexPatternUtils {
* useful for testing or memory cleanup in long-running applications.
*/
public void clearCache() {
patternCache.clear();
patternCache.invalidateAll();
log.debug("Regex pattern cache cleared");
}
@@ -141,13 +146,32 @@ public final class RegexPatternUtils {
return false;
}
PatternKey key = new PatternKey(regex, flags);
boolean removed = patternCache.remove(key) != null;
boolean removed = patternCache.getIfPresent(key) != null;
patternCache.invalidate(key);
if (removed) {
log.debug("Removed regex pattern from cache: {} (flags: {})", regex, flags);
}
return removed;
}
private Pattern getOrCompile(PatternKey key) {
try {
return patternCache.get(key, () -> compilePattern(key));
} catch (UncheckedExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof PatternSyntaxException patternSyntaxException) {
throw patternSyntaxException;
}
throw e;
} catch (java.util.concurrent.ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof PatternSyntaxException patternSyntaxException) {
throw patternSyntaxException;
}
throw new IllegalStateException("Failed to compile regex pattern", cause);
}
}
/**
* Internal method to compile a pattern and handle errors consistently.
*
@@ -154,7 +154,7 @@ class PdfMarkdownConverterTest {
|| isTableSeparatorRow(line)) {
continue;
}
if (sb.length() > 0) {
if (!sb.isEmpty()) {
sb.append('\n');
}
sb.append(line);
+1 -1
View File
@@ -57,7 +57,7 @@ dependencies {
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
implementation 'com.sun.xml.bind:jaxb-core:4.0.9'
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation "com.google.code.gson:gson:${gsonVersion}"
@@ -321,7 +321,7 @@ public class ExternalAppDepConfig {
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
if (sb.length() > 0) sb.append('\n');
if (!sb.isEmpty()) sb.append('\n');
sb.append(line);
}
}
@@ -130,7 +130,7 @@ public class UIDataController {
objectMapper.readValue(
config, new TypeReference<Map<String, Object>>() {});
String name = (String) jsonContent.get("name");
if (name == null || name.length() < 1) {
if (name == null || name.isEmpty()) {
String filename =
jsonFiles
.get(pipelineConfigs.indexOf(config))
@@ -13,7 +13,6 @@ import java.util.Calendar;
import java.util.List;
import org.apache.commons.io.FileUtils;
import org.apache.pdfbox.examples.signature.CreateSignatureBase;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
@@ -76,6 +75,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.pdf.signature.CreateSignatureBase;
import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
@@ -61,7 +61,6 @@ import stirling.software.common.model.api.PDFFile;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -270,25 +269,6 @@ public class GetInfoOnPDF {
}
}
private static void validatePdfFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("PDF file is required");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileSizeLimit",
"File size ({0} bytes) exceeds maximum allowed size ({1} bytes)",
file.getSize(),
MAX_FILE_SIZE);
}
String contentType = file.getContentType();
if (contentType != null && !"application/pdf".equals(contentType)) {
log.warn("File content type is {}, expected application/pdf", contentType);
}
}
private static ResponseEntity<byte[]> createErrorResponse(String errorMessage) {
try {
ObjectNode errorNode = objectMapper.createObjectNode();
@@ -1104,14 +1084,6 @@ public class GetInfoOnPDF {
public ResponseEntity<byte[]> getPdfInfo(@ModelAttribute PDFFile request) throws IOException {
MultipartFile inputFile = request.getFileInput();
// Validate input
try {
validatePdfFile(inputFile);
} catch (IllegalArgumentException e) {
log.error("Invalid PDF file: {}", e.getMessage());
return createErrorResponse("Invalid PDF file: " + e.getMessage());
}
List<PDFVerificationResult> verificationResults = null;
try {
verificationResults = veraPDFService.validatePDF(inputFile.getInputStream());
@@ -124,15 +124,15 @@ public class PasswordController {
StandardProtectionPolicy spp =
new StandardProtectionPolicy(ownerPassword, password, ap);
if ((ownerPassword != null && ownerPassword.length() > 0)
|| (password != null && password.length() > 0)) {
if ((ownerPassword != null && !ownerPassword.isEmpty())
|| (password != null && !password.isEmpty())) {
spp.setEncryptionKeyLength(keyLength);
}
spp.setPermissions(ap);
document.protect(spp);
if ((ownerPassword == null || ownerPassword.length() == 0)
&& (password == null || password.length() == 0))
if ((ownerPassword == null || ownerPassword.isEmpty())
&& (password == null || password.isEmpty()))
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
@@ -760,12 +760,12 @@ class RedactExecuteService {
char ch = raw.charAt(i);
if (Character.isLetterOrDigit(ch)) {
current.append(ch);
} else if (current.length() > 0) {
} else if (!current.isEmpty()) {
tokens.add(current.toString());
current.setLength(0);
}
}
if (current.length() > 0) tokens.add(current.toString());
if (!current.isEmpty()) tokens.add(current.toString());
if (tokens.size() < 2) return null;
StringBuilder out = new StringBuilder();
for (int i = 0; i < tokens.size(); i++) {
@@ -788,25 +788,25 @@ class RedactExecuteService {
StringBuilder current = new StringBuilder();
for (String token : tokens) {
if (token.isEmpty()) {
if (current.length() > 0) {
if (result.length() > 0) result.append(' ');
if (!current.isEmpty()) {
if (!result.isEmpty()) result.append(' ');
result.append(current);
current.setLength(0);
}
} else if (token.length() == 1) {
current.append(token);
} else {
if (current.length() > 0) {
if (result.length() > 0) result.append(' ');
if (!current.isEmpty()) {
if (!result.isEmpty()) result.append(' ');
result.append(current);
current.setLength(0);
}
if (result.length() > 0) result.append(' ');
if (!result.isEmpty()) result.append(' ');
result.append(token);
}
}
if (current.length() > 0) {
if (result.length() > 0) result.append(' ');
if (!current.isEmpty()) {
if (!result.isEmpty()) result.append(' ');
result.append(current);
}
return result.toString().trim();
@@ -251,7 +251,7 @@ public class MetricsController {
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
!endpointInspector.getValidGetEndpoints().isEmpty();
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
@@ -292,7 +292,7 @@ public class MetricsController {
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
!endpointInspector.getValidGetEndpoints().isEmpty();
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
@@ -332,7 +332,7 @@ public class MetricsController {
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
!endpointInspector.getValidGetEndpoints().isEmpty();
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
@@ -5,17 +5,26 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.core.Ordered;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.servlet.function.support.RouterFunctionMapping;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.util.JavaScriptUtils;
@@ -32,6 +41,33 @@ public class ReactRoutingController {
private static final Pattern BASE_HREF_PATTERN =
Pattern.compile("<base href=\\\"[^\\\"]*\\\"\\s*/?>");
// First path segments owned by the backend or static assets, never SPA routes.
// Mirrors the exclusion regexes on forwardRootPaths/forwardNestedPaths below.
private static final Set<String> NON_SPA_FIRST_SEGMENTS =
Set.of(
"api",
"static",
"pipeline",
"pdfjs",
"pdfjs-legacy",
"pdfium",
"vendor",
"fonts",
"images",
"css",
"js",
"assets",
"locales",
"modern-logo",
"classic-logo",
"Login",
"og_images",
"samples");
// After the annotated controllers (order 0), before the resource chain
// (LOWEST_PRECEDENCE - 1).
private static final int SPA_FALLBACK_ORDER = Ordered.LOWEST_PRECEDENCE - 2;
@Value("${server.servlet.context-path:/}")
private String contextPath;
@@ -256,6 +292,59 @@ public class ReactRoutingController {
return serveIndexHtml(request);
}
// The regex mappings above only cover 1- and 2-segment paths (Spring path variables cannot
// span '/'), so deep SPA links like /processor/pipelines/new 404d on direct navigation.
//
// Registered as its own mapping rather than exposed as a bare RouterFunction @Bean:
// Spring's own RouterFunctionMapping is ordered -1, ahead of the annotated controllers at
// order 0, so a plain bean would shadow every dot-free backend route the denylist below
// does not name (/v1/api-docs, /error, /actuator, ...). LOWEST_PRECEDENCE - 2 puts it after
// the controllers and before the resource chain (LOWEST_PRECEDENCE - 1), which is the only
// position where a catch-all fallback is safe.
@Bean
public RouterFunctionMapping spaDeepLinkFallbackMapping() {
RouterFunction<ServerResponse> fallback =
RouterFunctions.route(
request -> {
HttpServletRequest servletRequest = request.servletRequest();
return "GET".equals(servletRequest.getMethod())
&& isSpaFallbackRoute(
stripContextPath(
servletRequest.getContextPath(),
servletRequest.getRequestURI()));
},
request ->
ServerResponse.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(serveIndexHtml(request.servletRequest()).getBody()));
RouterFunctionMapping mapping = new RouterFunctionMapping(fallback);
mapping.setOrder(SPA_FALLBACK_ORDER);
mapping.setMessageConverters(
List.of(new StringHttpMessageConverter(StandardCharsets.UTF_8)));
return mapping;
}
// Dot-free paths only, so requests for real files still fall through to the resource
// handlers. This is a denylist, so it is only safe because the mapping above runs after
// the annotated controllers - see spaDeepLinkFallbackMapping.
static boolean isSpaFallbackRoute(String path) {
if (path == null || path.isEmpty() || "/".equals(path) || path.indexOf('.') >= 0) {
return false;
}
String[] segments = (path.startsWith("/") ? path.substring(1) : path).split("/");
return segments.length > 0
&& !segments[0].isEmpty()
&& !NON_SPA_FIRST_SEGMENTS.contains(segments[0]);
}
private static String stripContextPath(String contextPath, String uri) {
if (contextPath != null && !contextPath.isBlank() && uri.startsWith(contextPath)) {
return uri.substring(contextPath.length());
}
return uri;
}
private String buildFallbackHtml() {
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.IOException;
import java.io.InputStream;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.IOException;
import java.io.InputStream;
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.IOException;
import java.io.InputStream;
@@ -15,7 +15,7 @@
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -237,12 +237,12 @@ public class HardwareKeyStoreService {
combined.append(env);
}
if (prop != null && !prop.isBlank()) {
if (combined.length() > 0) {
if (!combined.isEmpty()) {
combined.append(java.io.File.pathSeparator);
}
combined.append(prop);
}
if (combined.length() == 0) {
if (combined.isEmpty()) {
return List.of();
}
return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]"))
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.pdfbox.examples.util;
package stirling.software.SPDF.utils;
import java.io.IOException;
import java.io.InputStream;
@@ -264,21 +264,6 @@ class GetInfoOnPDFMoreTest {
@DisplayName("error handling")
class Errors {
@Test
@DisplayName("empty file input yields an error response")
void emptyFile() throws Exception {
MockMultipartFile mf =
new MockMultipartFile("fileInput", "x.pdf", "application/pdf", new byte[0]);
PDFFile request = new PDFFile();
request.setFileInput(mf);
ResponseEntity<byte[]> resp = getInfoOnPDF.getPdfInfo(request);
// createErrorResponse returns HTTP 200 with a JSON body carrying an "error" field.
assertThat(resp.getBody()).isNotNull();
JsonNode body = om.readTree(resp.getBody());
assertThat(body.has("error")).isTrue();
assertThat(body.get("error").asText("")).contains("Invalid");
}
@Test
@DisplayName("veraPDF failure is swallowed and a report is still produced")
void veraPdfFailureSwallowed() throws Exception {
@@ -556,24 +556,6 @@ class GetInfoOnPDFTest {
@DisplayName("Validation and Error Handling Tests")
class ValidationErrorTests {
@Test
@DisplayName("Should reject null file")
void testValidation_NullFile() throws IOException {
PDFFile request = new PDFFile();
request.setFileInput(null);
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
Assertions.assertEquals(
HttpStatus.OK, response.getStatusCode()); // Returns error JSON with 200
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
Assertions.assertTrue(jsonNode.has("error"));
Assertions.assertTrue(
jsonNode.get("error").asText("").contains("PDF file is required"));
}
@Test
@DisplayName("Should reject empty file")
void testValidation_EmptyFile() throws IOException {
@@ -591,64 +573,6 @@ class GetInfoOnPDFTest {
Assertions.assertTrue(jsonNode.has("error"));
}
@Test
@DisplayName("Should reject file that exceeds max size")
void testValidation_TooLargeFile() throws IOException {
MultipartFile largeFile =
new MultipartFile() {
@Override
public String getName() {
return "file";
}
@Override
public String getOriginalFilename() {
return "large.pdf";
}
@Override
public String getContentType() {
return MediaType.APPLICATION_PDF_VALUE;
}
@Override
public boolean isEmpty() {
return false;
}
@Override
public long getSize() {
// Report 101 MB without allocating memory
return 101L * 1024L * 1024L;
}
@Override
public byte[] getBytes() {
return new byte[0];
}
@Override
public java.io.InputStream getInputStream() {
return java.io.InputStream.nullInputStream();
}
@Override
public void transferTo(java.io.File dest) throws IllegalStateException {}
};
PDFFile request = new PDFFile();
request.setFileInput(largeFile);
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
Assertions.assertTrue(jsonNode.has("error"));
Assertions.assertTrue(
jsonNode.get("error").asText("").contains("exceeds maximum allowed size"));
}
}
@Nested
@@ -4,12 +4,24 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.servlet.function.EntityResponse;
import org.springframework.web.servlet.function.HandlerFunction;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.servlet.function.support.RouterFunctionMapping;
import org.springframework.web.util.ServletRequestPathUtils;
import jakarta.servlet.http.HttpServletRequest;
@@ -175,6 +187,83 @@ class ReactRoutingControllerTest {
assertNotNull(response.getBody());
}
// --- deep-link SPA fallback (router function) ---
@Test
void isSpaFallbackRoute_acceptsDeepSpaPaths() {
assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/new"));
assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/123/runs/456"));
assertTrue(ReactRoutingController.isSpaFallbackRoute("/workflow/sign/some-token"));
assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/new/"));
// "pipelines" must not be swallowed by the "pipeline" exclusion
assertTrue(ReactRoutingController.isSpaFallbackRoute("/pipelines"));
}
@Test
void isSpaFallbackRoute_rejectsBackendStaticAndFilePaths() {
assertFalse(ReactRoutingController.isSpaFallbackRoute("/api/v1/some/endpoint"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/pipeline"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/pipeline/anything"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/assets/deep/path"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/file.js"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/branding/sub/logo.png"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/"));
assertFalse(ReactRoutingController.isSpaFallbackRoute(""));
assertFalse(ReactRoutingController.isSpaFallbackRoute(null));
}
@Test
void spaDeepLinkFallback_servesIndexForDeepRoute() throws Exception {
controller.init();
RouterFunction<ServerResponse> router = routerOf(controller.spaDeepLinkFallbackMapping());
ServerRequest deepRequest = serverRequest("GET", "/processor/pipelines/new");
Optional<HandlerFunction<ServerResponse>> handler = router.route(deepRequest);
assertTrue(handler.isPresent());
ServerResponse response = handler.get().handle(deepRequest);
assertEquals(HttpStatus.OK, response.statusCode());
assertInstanceOf(EntityResponse.class, response);
Object body = ((EntityResponse<?>) response).entity();
assertTrue(body.toString().contains("Stirling PDF"));
}
@Test
void spaDeepLinkFallback_ignoresApiFilesAndNonGet() {
controller.init();
RouterFunction<ServerResponse> router = routerOf(controller.spaDeepLinkFallbackMapping());
assertTrue(router.route(serverRequest("GET", "/api/v1/policies/run")).isEmpty());
assertTrue(router.route(serverRequest("GET", "/branding/sub/logo.png")).isEmpty());
assertTrue(router.route(serverRequest("POST", "/processor/pipelines/new")).isEmpty());
}
@Test
void spaDeepLinkFallback_runsAfterControllersAndBeforeResources() {
controller.init();
int order = controller.spaDeepLinkFallbackMapping().getOrder();
// A catch-all denylist is only safe below every annotated controller; Spring's own
// RouterFunctionMapping sits at -1, which would shadow /v1/api-docs, /error and friends.
assertTrue(order > 0, "SPA fallback must run after annotated controllers");
assertTrue(
order < Ordered.LOWEST_PRECEDENCE - 1,
"SPA fallback must run before the static-resource chain");
}
private static RouterFunction<ServerResponse> routerOf(RouterFunctionMapping mapping) {
@SuppressWarnings("unchecked")
RouterFunction<ServerResponse> router =
(RouterFunction<ServerResponse>) mapping.getRouterFunction();
return router;
}
private static ServerRequest serverRequest(String method, String uri) {
MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, uri);
ServletRequestPathUtils.parseAndCache(servletRequest);
return ServerRequest.create(servletRequest, List.of(new StringHttpMessageConverter()));
}
// --- context path handling ---
@Test
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.util;
package stirling.software.SPDF.utils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
+12
View File
@@ -0,0 +1,12 @@
{
"com.hubspot.immutables:immutables-exceptions:1.9": {
"name": "The Apache License, Version 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.txt",
"projectUrl": "https://github.com/HubSpot/hubspot-immutables/tree/58628096ac99b286fe4f8bfe12aa3cff0f0589d3"
},
"com.hubspot:algebra:1.5": {
"name": "The Apache License, Version 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.txt",
"projectUrl": "https://github.com/HubSpot/algebra/tree/5d42983fd3a26539df9ba2cbeac32a1bddce0494"
}
}
@@ -2,6 +2,7 @@ package stirling.software.proprietary.config;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.slf4j.MDC;
@@ -12,10 +13,15 @@ import org.springframework.core.task.support.TaskExecutorAdapter;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
import jakarta.annotation.PreDestroy;
@Configuration
@EnableAsync
public class AsyncConfig {
private ExecutorService auditExecutorService;
private ExecutorService aiStreamExecutorService;
/**
* MDC context-propagating task decorator. Copies MDC context from the caller thread to the
* virtual thread executing the task.
@@ -44,8 +50,8 @@ public class AsyncConfig {
@Bean(name = "auditExecutor")
public Executor auditExecutor() {
TaskExecutorAdapter adapter =
new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
auditExecutorService = Executors.newVirtualThreadPerTaskExecutor();
TaskExecutorAdapter adapter = new TaskExecutorAdapter(auditExecutorService);
adapter.setTaskDecorator(new MDCContextTaskDecorator());
return adapter;
}
@@ -53,9 +59,25 @@ public class AsyncConfig {
/** Propagates the request's SecurityContext onto background AI-orchestration threads. */
@Bean(name = "aiStreamExecutor")
public Executor aiStreamExecutor() {
TaskExecutorAdapter adapter =
new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
aiStreamExecutorService = Executors.newVirtualThreadPerTaskExecutor();
TaskExecutorAdapter adapter = new TaskExecutorAdapter(aiStreamExecutorService);
adapter.setTaskDecorator(new MDCContextTaskDecorator());
return new DelegatingSecurityContextExecutor(adapter);
}
/**
* Close the underlying executors because the exposed Spring adapters do not own their
* lifecycle.
*/
@PreDestroy
void shutdown() {
shutdownExecutor(auditExecutorService);
shutdownExecutor(aiStreamExecutorService);
}
private void shutdownExecutor(ExecutorService executor) {
if (executor != null) {
executor.shutdownNow();
}
}
}
@@ -16,6 +16,8 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientResponseException;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -79,6 +81,21 @@ public class PolicyEngine {
private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor();
/** Stop the service-owned executor when the application context is closed or restarted. */
@PreDestroy
void shutdown() {
log.debug("Shutting down policy engine executor");
asyncExecutor.shutdown();
try {
if (!asyncExecutor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) {
asyncExecutor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
asyncExecutor.shutdownNow();
}
}
/**
* Submit a pipeline to run asynchronously. The handle's run id scopes a {@link TaskManager} job
* (status/notes/results observable via the job endpoints); its future resolves when the run
@@ -199,7 +199,7 @@ public class UserController {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "usernameExists", "message", "Username already exists"));
}
if (newUsername != null && newUsername.length() > 0) {
if (newUsername != null && !newUsername.isEmpty()) {
try {
userService.changeUsername(user, newUsername);
} catch (IllegalArgumentException e) {
@@ -205,7 +205,7 @@ public class UserService implements UserServiceInterface {
User user =
findByUsernameIgnoreCase(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
if (user.getApiKey() == null || user.getApiKey().length() == 0) {
if (user.getApiKey() == null || user.getApiKey().isEmpty()) {
user = addApiKeyToUser(username);
}
return user.getApiKey();
@@ -224,7 +224,7 @@ public class PortalInfraAuditService {
if (word.isEmpty()) {
continue;
}
if (sb.length() > 0) {
if (!sb.isEmpty()) {
sb.append(' ');
}
String lower = word.toLowerCase(Locale.ROOT);
@@ -515,7 +515,7 @@ public class UserLicenseSettingsService {
appendIfPresent(builder, applicationProperties.getAutomaticallyGenerated().getUUID());
appendIfPresent(builder, applicationProperties.getPremium().getKey());
if (builder.length() == 0) {
if (builder.isEmpty()) {
builder.append(DEFAULT_INTEGRITY_SECRET);
}
@@ -524,7 +524,7 @@ public class UserLicenseSettingsService {
private void appendIfPresent(StringBuilder builder, String value) {
if (value != null && !value.isBlank()) {
if (builder.length() > 0) {
if (!builder.isEmpty()) {
builder.append(SIGNATURE_SEPARATOR);
}
builder.append(value);
@@ -7,12 +7,14 @@ import java.util.concurrent.TimeUnit;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.storage.model.StorageCleanupEntry;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
@@ -25,6 +27,7 @@ public class StorageCleanupService {
private final StorageProvider storageProvider;
private final StorageCleanupEntryRepository cleanupEntryRepository;
private final FileShareAccessRepository fileShareAccessRepository;
private final FileShareRepository fileShareRepository;
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
@@ -62,12 +65,14 @@ public class StorageCleanupService {
}
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
@Transactional
public void cleanupExpiredShareLinks() {
List<stirling.software.proprietary.storage.model.FileShare> expired =
fileShareRepository.findByExpiresAtBeforeAndShareTokenNotNull(LocalDateTime.now());
if (expired.isEmpty()) {
return;
}
expired.forEach(fileShareAccessRepository::deleteByFileShare);
fileShareRepository.deleteAll(expired);
}
}
@@ -298,24 +298,44 @@ class LiveValkeyIntegrationTest {
ValkeyRateLimitStore store = newRateLimitStore(factoryA);
String key = "boundary-" + java.util.UUID.randomUUID();
long capacity = 5;
Duration window = Duration.ofMillis(500);
// refillGreedy tops the bucket up continuously, one token every window/capacity. A 500ms
// window left the drain loop only 100ms before a 6th token appeared, so a slow Valkey
// round-trip broke the count; 4s spaces refills 800ms apart, clear of any burst.
Duration window = Duration.ofSeconds(4);
long refillIntervalMs = window.toMillis() / capacity;
long drainStart = System.nanoTime();
int firstAllowed = 0;
for (int i = 0; i < 10; i++) {
if (store.tryConsume(key, capacity, window).allowed()) firstAllowed++;
}
assertEquals(capacity, firstAllowed, "must allow exactly capacity tokens initially");
long drainMs = (System.nanoTime() - drainStart) / 1_000_000;
// Refill never pauses, so a slow drain earns extra tokens honestly - allow exactly the
// number the elapsed time can have produced and no more.
long earned = drainMs / refillIntervalMs;
assertTrue(
firstAllowed >= capacity && firstAllowed <= capacity + earned,
"initial burst must be capacity ("
+ capacity
+ ") plus at most the "
+ earned
+ " token(s) refilled during a "
+ drainMs
+ "ms drain, got "
+ firstAllowed);
Thread.sleep(window.toMillis() + 50);
// A fixed-window limiter would hand back a whole fresh capacity at the boundary; a token
// bucket hands back one token per refill interval.
Thread.sleep(refillIntervalMs + 200);
int secondAllowed = 0;
long start = System.nanoTime();
for (int i = 0; i < 20 && (System.nanoTime() - start) < 20_000_000L; i++) {
for (int i = 0; i < 10; i++) {
if (store.tryConsume(key, capacity, window).allowed()) secondAllowed++;
}
assertTrue(
secondAllowed <= capacity,
"token-bucket must not let a fresh full capacity be consumed instantly across"
+ " the boundary; got "
secondAllowed >= 1 && secondAllowed < capacity,
"one refill interval must yield about one token, not a fresh full window of "
+ capacity
+ "; got "
+ secondAllowed);
}
+5
View File
@@ -6,6 +6,11 @@ dependencies {
implementation project(':common')
implementation project(':proprietary')
// Markdown -> HTML for rendering versioned legal documents (agreement) to PDF via the
// shared FileToPdf/WeasyPrint path in :common. Same library the core Markdown-to-PDF tool uses.
implementation "org.commonmark:commonmark:$commonmarkVersion"
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
api 'org.springframework.boot:spring-boot-starter-security'
api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
@@ -20,7 +20,8 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
"stirling.software.saas.ai.repository",
"stirling.software.saas.payg.repository",
"stirling.software.saas.payg.bundle",
"stirling.software.saas.procurement.repository"
"stirling.software.saas.procurement.repository",
"stirling.software.saas.legal"
})
@EntityScan({
"stirling.software.saas.accountlink",
@@ -28,6 +29,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
"stirling.software.saas.billing.model",
"stirling.software.saas.ai.model",
"stirling.software.saas.payg",
"stirling.software.saas.procurement.model"
"stirling.software.saas.procurement.model",
"stirling.software.saas.legal"
})
public class SaasJpaConfig {}
@@ -0,0 +1,62 @@
package stirling.software.saas.legal;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* An append-only record that a user accepted a versioned legal document at a particular moment in
* the product. Distinct from a signed agreement (which is a negotiated, signature-bearing artifact,
* see {@code ProcurementAgreementSignature}); this captures the lighter clickwrap consents — the
* EULA accepted at trial start and at quote generation — with the exact document version, so what
* was agreed is auditable even after the document versions up.
*/
@Entity
@Table(name = "legal_consent")
@NoArgsConstructor
@Getter
@Setter
public class LegalConsent implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "consent_id")
private Long consentId;
@Column(name = "team_id")
private Long teamId;
@Column(name = "user_id")
private Long userId;
@Column(name = "document_id", nullable = false, length = 64)
private String documentId;
@Column(name = "document_version", nullable = false, length = 32)
private String documentVersion;
// Where in the product the consent was given: "trial", "quote", etc.
@Column(name = "context", nullable = false, length = 32)
private String context;
@Column(name = "signer_ip", length = 64)
private String signerIp;
@CreationTimestamp
@Column(name = "consented_at", nullable = false, updatable = false)
private LocalDateTime consentedAt;
}
@@ -0,0 +1,5 @@
package stirling.software.saas.legal;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LegalConsentRepository extends JpaRepository<LegalConsent, Long> {}
@@ -0,0 +1,47 @@
package stirling.software.saas.legal;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/** Records clickwrap consents to versioned legal documents (see {@link LegalConsent}). */
@Slf4j
@Service
@Profile("saas")
@RequiredArgsConstructor
public class LegalConsentService {
private final LegalDocumentRegistry registry;
private final LegalConsentRepository consents;
/**
* Record that the given user accepted the current version of {@code documentId} in {@code
* context} (e.g. "trial", "quote"). No-op for an unknown document. Best-effort: callers treat a
* failure as non-fatal so it never blocks the flow the consent accompanies.
*/
@Transactional
public void record(Long teamId, Long userId, String documentId, String context, String ip) {
LegalDocumentMeta meta = registry.meta(documentId).orElse(null);
if (meta == null) {
log.warn("[legal] consent for unknown document '{}' ignored", documentId);
return;
}
LegalConsent consent = new LegalConsent();
consent.setTeamId(teamId);
consent.setUserId(userId);
consent.setDocumentId(meta.id());
consent.setDocumentVersion(meta.version());
consent.setContext(context);
consent.setSignerIp(ip);
consents.save(consent);
log.info(
"[legal] consent recorded team={} doc={} v{} context={}",
teamId,
meta.id(),
meta.version(),
context);
}
}
@@ -0,0 +1,133 @@
package stirling.software.saas.legal;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Serves the versioned legal documents (EULA, SLA exhibit, subprocessors) for in-product viewing,
* and records the lighter clickwrap consents. The enterprise agreement itself is served + signed
* through the procurement controller, since it needs a quote to fill its Order Form.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/legal")
@Profile("saas")
@RequiredArgsConstructor
public class LegalController {
private final LegalDocumentRegistry registry;
private final LegalConsentService consents;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
/** A legal document rendered for viewing: registry metadata + the static markdown body. */
public record LegalDocumentResponse(
String docId,
String version,
String versionLabel,
String displayName,
String effectiveDate,
String status,
String markdown) {}
public record ConsentRequest(String documentId, String context) {}
/** Fetch a legal document's current version as markdown. 404 for an unknown document. */
@GetMapping("/{docId}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<LegalDocumentResponse> document(@PathVariable String docId) {
return registry.meta(docId)
.<ResponseEntity<LegalDocumentResponse>>map(
meta ->
ResponseEntity.ok(
new LegalDocumentResponse(
meta.id(),
meta.version(),
meta.versionLabel(),
meta.displayName(),
meta.effectiveDate(),
meta.status(),
registry.staticMarkdown(docId))))
.orElseGet(() -> ResponseEntity.notFound().build());
}
/**
* Record a clickwrap consent (e.g. the EULA accepted at trial start or quote generation).
* Best-effort — a teamless caller still returns 200 so the accompanying flow is never blocked.
*/
@PostMapping("/consent")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> consent(
@RequestBody ConsentRequest request, Authentication auth, HttpServletRequest http) {
if (request == null || request.documentId() == null || request.context() == null) {
return ResponseEntity.badRequest().build();
}
Optional<TeamMembership> membership = primaryMembership(auth);
Long teamId = membership.map(m -> m.getTeam().getId()).orElse(null);
Long userId = membership.map(m -> m.getUser().getId()).orElse(null);
// Best-effort for real: consent is audit metadata, not an authorisation gate, so a failed
// write must not fail the trial start or quote generation this call accompanies. Previously
// that only held because the caller happened to swallow the 500.
try {
consents.record(
teamId, userId, request.documentId(), request.context(), clientIp(http));
} catch (RuntimeException e) {
log.warn(
"[legal] consent not recorded doc={} context={}: {}",
request.documentId(),
request.context(),
e.getMessage());
}
return ResponseEntity.ok().build();
}
private Optional<TeamMembership> primaryMembership(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return Optional.empty();
}
return memberRepo.findPrimaryMembership(user.getId()).stream().findFirst();
}
/**
* Best guess at the caller's address, for the audit record.
*
* <p>Informational only, and must stay that way: the first {@code X-Forwarded-For} hop is set
* by the client, so a stored address is trivially spoofable and is not evidence of where a
* consent or signature came from. Treat it as a hint when reconstructing events, never as
* proof.
*/
private static String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
@@ -0,0 +1,27 @@
package stirling.software.saas.legal;
import java.util.List;
/**
* One legal document's registry entry, as declared in {@code legal/manifest.json}. Immutable
* snapshot loaded at startup by {@link LegalDocumentRegistry}.
*
* <p>{@code parts} lists the pieces, in render order, that make up the document. A plain entry
* (e.g. {@code "msa.md"}) is a static markdown file under {@code legal/<id>/<version>/}; an entry
* prefixed with {@code "@"} (e.g. {@code "@order-form"}) is a dynamic section that a document
* assembler generates at render time.
*/
public record LegalDocumentMeta(
String id,
String label,
String displayName,
String version,
String effectiveDate,
String status,
List<String> parts) {
/** Fully-qualified version label shown to users and stored on signatures, e.g. "SEA v0.9.1". */
public String versionLabel() {
return label + " v" + version;
}
}
@@ -0,0 +1,151 @@
package stirling.software.saas.legal;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
/**
* Loads the versioned legal-document registry from {@code legal/manifest.json} on startup and
* serves document metadata + rendered markdown from the classpath.
*
* <p>Publishing a new version of any document is a content-only change: drop the markdown under
* {@code legal/<id>/<newVersion>/} and bump that document's {@code version} in the manifest — no
* code change. Signatures pin the exact {@code {id, version, contentHash}} they were signed against
* (see the procurement agreement flow), so historical documents stay reproducible.
*
* <p>Token slots of the form <code>{{name}}</code> in the markdown are filled at render time. This
* registry fills the document-level common tokens ({@code version}, {@code version_date}, {@code
* subprocessor_url}, {@code eula_url}); callers that need per-quote tokens (the enterprise
* agreement's Order Form) fill the rest.
*/
@Slf4j
@Service
public class LegalDocumentRegistry {
private static final String MANIFEST = "legal/manifest.json";
private static final Pattern TOKEN = Pattern.compile("\\{\\{\\s*([a-zA-Z0-9_]+)\\s*}}");
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, LegalDocumentMeta> documents = new LinkedHashMap<>();
private String subprocessorUrl = "";
private String eulaUrl = "";
@PostConstruct
void load() throws IOException {
JsonNode root;
try (InputStream in = new ClassPathResource(MANIFEST).getInputStream()) {
root = objectMapper.readTree(in);
}
subprocessorUrl = root.path("subprocessorUrl").asText("");
eulaUrl = root.path("eulaUrl").asText("");
JsonNode docs = root.path("documents");
docs.fieldNames()
.forEachRemaining(
id -> {
JsonNode d = docs.get(id);
List<String> parts =
objectMapper.convertValue(
d.path("parts"),
objectMapper
.getTypeFactory()
.constructCollectionType(
List.class, String.class));
documents.put(
id,
new LegalDocumentMeta(
id,
d.path("label").asText(id),
d.path("displayName").asText(id),
d.path("version").asText("0"),
d.path("effectiveDate").asText(""),
d.path("status").asText("draft"),
parts == null ? List.of() : parts));
});
log.info("[legal] loaded {} document(s) from {}", documents.size(), MANIFEST);
}
public Optional<LegalDocumentMeta> meta(String docId) {
return Optional.ofNullable(documents.get(docId));
}
public String subprocessorUrl() {
return subprocessorUrl;
}
public String eulaUrl() {
return eulaUrl;
}
/** Document-level tokens available to every document (before any per-quote tokens). */
public Map<String, String> commonTokens(LegalDocumentMeta meta) {
Map<String, String> t = new LinkedHashMap<>();
t.put("version", meta.version());
t.put("version_date", meta.effectiveDate());
t.put("subprocessor_url", subprocessorUrl);
t.put("eula_url", eulaUrl);
return t;
}
/** Read one static markdown part of a document from the classpath. */
public String readPart(LegalDocumentMeta meta, String partFile) {
String path = "legal/" + meta.id() + "/" + meta.version() + "/" + partFile;
try (InputStream in = new ClassPathResource(path).getInputStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("Missing legal document part: " + path, e);
}
}
/**
* The concatenated static parts of a document (dynamic {@code @}-parts skipped), with only the
* common tokens filled. Use for fully-static documents (EULA, SLA, subprocessors).
*/
public String staticMarkdown(String docId) {
LegalDocumentMeta meta =
meta(docId)
.orElseThrow(
() -> new IllegalArgumentException("Unknown document: " + docId));
Map<String, String> tokens = commonTokens(meta);
StringBuilder sb = new StringBuilder();
for (String part : meta.parts()) {
if (part.startsWith("@")) continue; // dynamic section — not part of the static body
if (sb.length() > 0) sb.append("\n\n");
sb.append(fill(readPart(meta, part), tokens));
}
return sb.toString();
}
/** Replace {@code {{token}}} slots; unknown tokens are left intact so gaps are visible. */
public static String fill(String markdown, Map<String, String> tokens) {
Matcher m = TOKEN.matcher(markdown);
StringBuilder out = new StringBuilder();
while (m.find()) {
String key = m.group(1);
String value = tokens.get(key);
m.appendReplacement(
out,
value == null
? Matcher.quoteReplacement(m.group(0))
: Matcher.quoteReplacement(value));
}
m.appendTail(out);
return out.toString();
}
}
@@ -34,17 +34,22 @@ import lombok.Setter;
@Entity
@Table(
name = "payg_prepaid_bundle",
// Declared here for ddl-auto (fresh schemas) and to document intent. The authoritative creator
// in production is the Supabase CLI migration 20260720000000_payg_prepaid_bundle, which builds
// Declared here for ddl-auto (fresh schemas) and to document intent. The authoritative
// creator
// in production is the Supabase CLI migration 20260720000000_payg_prepaid_bundle, which
// builds
// the partial forms (WHERE units_remaining > 0 / WHERE stripe_ref IS NOT NULL). Flyway was
// retired for SaaS (#7100), so there is no migration twin — names match the CLI migration.
indexes = {
// Hot-path FIFO draw lookup — findDrawableForUpdate runs a locked read on every billable
// charge past the free grant; without it that degrades to a locked scan as the table grows.
// Hot-path FIFO draw lookup — findDrawableForUpdate runs a locked read on every
// billable
// charge past the free grant; without it that degrades to a locked scan as the table
// grows.
@Index(
name = "idx_payg_prepaid_bundle_team_expiry",
columnList = "team_id, expires_at"),
// One pool per Stripe payment — the idempotency guard so a redelivered invoice.paid can't
// One pool per Stripe payment — the idempotency guard so a redelivered invoice.paid
// can't
// credit the same purchase twice.
@Index(
name = "uq_payg_prepaid_bundle_stripe_ref",
@@ -22,6 +22,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
@@ -30,6 +32,8 @@ import stirling.software.proprietary.security.database.repository.UserRepository
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
import stirling.software.saas.procurement.legal.AgreementSigning;
import stirling.software.saas.procurement.model.ProcurementAgreementSignature;
import stirling.software.saas.procurement.model.ProcurementDeal;
import stirling.software.saas.procurement.model.ProcurementQuote;
import stirling.software.saas.procurement.model.QuoteDetails;
@@ -178,7 +182,17 @@ public class ProcurementController {
String taxId) {}
/** Trial setup captured before the trial starts: deployment target + seat count. */
public record StartTrialRequest(String deployment, int users) {}
/**
* Setup step 2 collects the buying entity; all of it is optional so an older client still
* starts.
*/
public record StartTrialRequest(
String deployment,
int users,
String businessName,
String contactName,
String contactEmail,
String inviteEmails) {}
public record SnapshotResponse(
Long dealId,
@@ -190,8 +204,33 @@ public class ProcurementController {
int trialExtensionsUsed,
boolean licensed,
String licenseKey,
// Version label of the signed agreement PDF available for download, else null.
String agreementSignedVersion,
// Buying entity captured at trial setup; null on deals started before that step.
String businessName,
String contactName,
String contactEmail,
QuoteResponse latestQuote) {}
/** The filled agreement for review: registry metadata + the rendered markdown body. */
public record AgreementDocumentResponse(
String docId,
String version,
String versionLabel,
String displayName,
String effectiveDate,
String status,
String markdown) {}
/** Buyer-supplied signing inputs from the agreement stage. */
public record SignAgreementRequest(
String customerLegalName,
String signatoryName,
String signatoryTitle,
boolean authorityConfirmed) {}
public record SignAgreementResponse(Long signatureId, String versionLabel, boolean pdfStored) {}
// ---- endpoints ----------------------------------------------------------
/**
@@ -213,7 +252,8 @@ public class ProcurementController {
}
private static final SnapshotResponse EMPTY_SNAPSHOT =
new SnapshotResponse(null, null, null, 0, null, null, 0, false, null, null);
new SnapshotResponse(
null, null, null, 0, null, null, 0, false, null, null, null, null, null, null);
/**
* Download the offline / air-gapped licence file (.lic) for the team — available for an
@@ -239,6 +279,15 @@ public class ProcurementController {
.orElseGet(() -> ResponseEntity.notFound().build());
}
/** Mark the account as looking at enterprise. Idempotent; never disturbs an existing deal. */
@PostMapping("/interest")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> recordInterest(Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return ResponseEntity.ok(toSnapshot(procurement.recordInterest(teamId), true));
}
@PostMapping("/trial/start")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> startTrial(
@@ -248,8 +297,30 @@ public class ProcurementController {
// Body is optional so an older client (no setup step) still starts a cloud trial.
String deployment = request != null ? request.deployment() : null;
int seats = request != null ? request.users() : 0;
return ResponseEntity.ok(
toSnapshot(procurement.startTrial(teamId, deployment, seats), true));
ProcurementDeal deal;
try {
deal =
procurement.startTrial(
teamId,
deployment,
seats,
request != null ? request.businessName() : null,
request != null ? request.contactName() : null,
request != null ? request.contactEmail() : null,
request != null ? request.inviteEmails() : null);
} catch (IllegalStateException e) {
// Past the trial the deal holds a committed licence; restarting would replace it.
log.warn("[procurement] trial start rejected team={}: {}", teamId, e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
// After the trial exists, so a rejected invite can never stop it starting.
if (request != null) {
procurement.sendTrialInvites(
teamId,
primaryMembership(auth).map(TeamMembership::getUser).orElse(null),
request.inviteEmails());
}
return ResponseEntity.ok(toSnapshot(deal, true));
}
@PostMapping("/trial/extend")
@@ -270,8 +341,17 @@ public class ProcurementController {
@RequestBody QuoteRequest request, Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return ResponseEntity.ok(
toQuote(procurement.buildQuote(teamId, request.toConfig(), request.toDetails())));
try {
return ResponseEntity.ok(
toQuote(
procurement.buildQuote(
teamId, request.toConfig(), request.toDetails())));
} catch (IllegalStateException e) {
// Below the minimum deal size, or the deal is already live. A client error, not a
// fault.
log.warn("[procurement] quote rejected team={}: {}", teamId, e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
// Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a
@@ -293,6 +373,111 @@ public class ProcurementController {
}
}
/**
* The filled Stirling Enterprise Agreement (MSA + Order Form + DPA) for the team's current
* quote, as markdown, for the buyer to review before signing. 404 when there's no quote yet.
*/
@GetMapping("/agreement/document")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<AgreementDocumentResponse> agreementDocument(Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return procurement
.agreementDocument(teamId)
.<ResponseEntity<AgreementDocumentResponse>>map(
a ->
ResponseEntity.ok(
new AgreementDocumentResponse(
a.docId(),
a.version(),
a.versionLabel(),
a.displayName(),
a.effectiveDate(),
a.status(),
a.markdown())))
.orElseGet(() -> ResponseEntity.notFound().build());
}
/**
* Record a signed agreement: capture the typed legal name / signatory / title / authority, pin
* the exact document version + content hash + variable snapshot, and store the rendered PDF
* (best-effort). The caller then proceeds to accept the quote as before.
*/
@PostMapping("/agreement/sign")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SignAgreementResponse> signAgreement(
@RequestBody SignAgreementRequest request,
Authentication auth,
HttpServletRequest http) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
if (request == null
|| request.signatoryName() == null
|| request.signatoryName().isBlank()
|| !request.authorityConfirmed()) {
return ResponseEntity.badRequest().build();
}
try {
ProcurementAgreementSignature sig =
procurement.signAgreement(
teamId,
new AgreementSigning(
request.customerLegalName(),
request.signatoryName(),
request.signatoryTitle(),
request.authorityConfirmed()),
clientIp(http));
return ResponseEntity.ok(
new SignAgreementResponse(
sig.getSignatureId(), sig.getDocumentLabel(), sig.getPdf() != null));
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
/** Download the stored signed-agreement PDF for the team. 404 if none was rendered/stored. */
@GetMapping("/agreement/signature/pdf")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<byte[]> signaturePdf(Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return procurement
.signedAgreementPdf(teamId)
.<ResponseEntity<byte[]>>map(
pdf ->
ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment;"
+ " filename=\"stirling-enterprise-agreement.pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf))
.orElseGet(() -> ResponseEntity.notFound().build());
}
/**
* Download the current (unsigned) agreement as a PDF — the document shown at the sign step. 404
* when there's no quote yet or the render runtime is unavailable.
*/
@GetMapping("/agreement/document/pdf")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<byte[]> agreementDocumentPdf(Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return procurement
.agreementDocumentPdf(teamId)
.<ResponseEntity<byte[]>>map(
pdf ->
ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment;"
+ " filename=\"stirling-enterprise-agreement.pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf))
.orElseGet(() -> ResponseEntity.notFound().build());
}
/**
* Provision on accept: upgrade the team's licence to the committed annual term, valid
* immediately. Called server-side by the accept edge function (ROLE_ADMIN via X-API-Key) once
@@ -311,9 +496,38 @@ public class ProcurementController {
}
}
/**
* Go live once payment settles: advance the deal to active and re-affirm the annual licence.
* Called server-side by the {@code invoice.paid} webhook (ROLE_ADMIN via X-API-Key), alongside
* {@code /provision}, which runs earlier at accept and deliberately leaves the stage alone.
*
* <p>Answers 200 when the team has no deal at all, rather than erroring: a committed
* subscription can be closed directly in Stripe by sales with no portal deal behind it, and a
* non-2xx would have Stripe retry a webhook that can never succeed.
*
* <p>{@code invoiceId} is what makes this idempotent without swallowing renewals: the same
* invoice twice is a redelivery, a different one is next year's payment and has to re-issue the
* licence. Optional so an older caller still works, at the cost of that distinction.
*/
@PostMapping("/activate")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> activate(
@RequestParam("teamId") long teamId,
@RequestParam(value = "invoiceId", required = false) String invoiceId) {
try {
procurement.markLive(teamId, invoiceId);
} catch (IllegalStateException e) {
log.info(
"[procurement] activate skipped, no deal for team={}: {}",
teamId,
e.getMessage());
}
return ResponseEntity.ok().build();
}
/**
* Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the
* annual licence, advance to active). The real go-live is webhook-driven once payment settles.
* annual licence, advance to active). Production go-live runs through {@code /activate}.
*/
@PostMapping("/go-live")
@PreAuthorize("isAuthenticated()")
@@ -360,6 +574,21 @@ public class ProcurementController {
.orElse(null);
}
/**
* Best-effort client IP for the signature record: first X-Forwarded-For hop, else the peer.
*
* <p>Informational only. That header is client-set, so {@code signer_ip} is spoofable and is
* not evidence of where a signature came from — the document hash and version are what make the
* record trustworthy. Treat the address as a hint, never as proof.
*/
private static String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
/**
* Build the snapshot for a deal. {@code includeLicenseKey} is true only for the team leader; a
* member sees {@code licensed} but not the key itself (see {@link #snapshot}). Mutation
@@ -381,6 +610,10 @@ public class ProcurementController {
deal.getTrialExtensionsUsed(),
deal.getLicenseRef() != null,
includeLicenseKey ? deal.getLicenseRef() : null,
procurement.signedAgreementLabel(deal.getDealId()).orElse(null),
deal.getBusinessName(),
deal.getContactName(),
deal.getContactEmail(),
latest);
}
@@ -30,4 +30,17 @@ public class ProcurementConfigurationProperties {
* /go-live is a stand-in for the invoice.paid webhook and would let a leader activate unpaid.
*/
private boolean demoControlsEnabled = false;
/**
* Smallest annual fee, in minor units, that may be quoted. The pricing curve has no natural
* floor — a small enough committed volume rounds the meter to zero — and every registered user
* is the leader of their own team, so without this any signup could price a $0 enterprise
* quote, accept it, and be provisioned a committed licence. 12_000_00 is USD 12,000/yr, the
* self-hosted deploy fee, chosen so the floor cannot sit below a line item the quote itself can
* contain.
*
* <p>This is a commercial number, not a technical one: set it to whatever the smallest
* enterprise deal you will actually sign is. Zero disables the check.
*/
private long minAnnualNetMinor = 12_000_00L;
}
@@ -0,0 +1,278 @@
package stirling.software.saas.procurement.legal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.legal.LegalDocumentMeta;
import stirling.software.saas.legal.LegalDocumentRegistry;
import stirling.software.saas.procurement.model.ProcurementQuote;
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
import stirling.software.saas.procurement.pricing.QuoteConfig;
import stirling.software.saas.procurement.pricing.QuoteLineItem;
/**
* Builds the full Stirling Enterprise Agreement for a specific quote: the static MSA (Part A) and
* DPA (Part C) from the {@link LegalDocumentRegistry}, with the dynamic Order Form (Part B)
* generated from the quote and slotted where the manifest's {@code @order-form} part sits.
*
* <p>Only the Order Form varies per deal; the MSA and DPA bodies are rendered verbatim with token
* substitution. The set of values used is returned as {@code variablesJson} so a signature can pin
* exactly what was rendered.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AgreementAssembler {
public static final String DOC_ID = "enterprise-agreement";
private static final DateTimeFormatter DATE =
DateTimeFormatter.ofPattern("MMMM d, yyyy", Locale.US);
private static final String BLANK = "\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_";
private final LegalDocumentRegistry registry;
private final ProcurementPricingService pricing;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Render the agreement for a quote. {@code signing} is null for a preview (before signing) —
* the effective date and signature block then read as blanks / "On signature".
*/
public AssembledAgreement assemble(ProcurementQuote quote, AgreementSigning signing) {
LegalDocumentMeta meta =
registry.meta(DOC_ID)
.orElseThrow(
() ->
new IllegalStateException(
"Enterprise agreement not registered"));
Map<String, String> tokens = tokens(quote, signing, meta);
StringBuilder md = new StringBuilder();
for (String part : meta.parts()) {
if (md.length() > 0) md.append("\n\n");
if ("@order-form".equals(part)) {
md.append(LegalDocumentRegistry.fill(orderForm(quote, tokens), tokens));
} else {
md.append(LegalDocumentRegistry.fill(registry.readPart(meta, part), tokens));
}
}
String variablesJson;
try {
variablesJson = objectMapper.writeValueAsString(tokens);
} catch (Exception e) {
variablesJson = "{}";
}
return new AssembledAgreement(
meta.id(),
meta.version(),
meta.versionLabel(),
meta.displayName(),
meta.effectiveDate(),
meta.status(),
md.toString(),
variablesJson);
}
private Map<String, String> tokens(
ProcurementQuote quote, AgreementSigning signing, LegalDocumentMeta meta) {
QuoteConfig cfg = toConfig(quote);
boolean signed = signing != null;
String legalName =
signed && notBlank(signing.customerLegalName())
? signing.customerLegalName().trim()
: (notBlank(quote.getBusinessName())
? quote.getBusinessName().trim()
: "Customer");
Map<String, String> t = new LinkedHashMap<>(registry.commonTokens(meta));
t.put("effective_date", signed ? LocalDate.now().format(DATE) : "On signature");
t.put("customer_legal_name", cell(legalName));
t.put("quote_ref", nz(quote.getQuoteNumber()));
t.put("deployment", ProcurementPricingService.deploymentName(quote.getDeployment()));
t.put("committed_pdfs_yr", String.format(Locale.US, "%,d", Math.max(0, quote.getVolume())));
t.put("posture", ProcurementPricingService.postureName(quote.getIntensity()));
t.put("processes_per_pdf", String.valueOf(Math.max(1, quote.getIntensity())));
t.put("rate_per_pdf", String.format(Locale.US, "$%.4f", pricing.effectiveRatePerPdf(cfg)));
t.put("term_years", String.valueOf(quote.getTermYears()));
t.put("term_discount_pct", pricing.termDiscountPct(quote.getTermYears()) + "%");
t.put("sla_tier", slaTier(quote.getServiceLevel()));
t.put("annual_fee_y1", money(quote.getAnnualNetMinor()));
t.put("contract_total", money(quote.getTcvMinor()));
t.put("elected_or_not", quote.isIndemnification() ? "Elected" : "Not elected");
t.put("po_number", notBlank(quote.getPoNumber()) ? cell(quote.getPoNumber()) : "");
t.put(
"customer_signatory",
signed && notBlank(signing.signatoryName())
? cell(signing.signatoryName())
: BLANK);
t.put(
"customer_signatory_title",
signed && notBlank(signing.signatoryTitle())
? cell(signing.signatoryTitle())
: BLANK);
return t;
}
/**
* Make a buyer-supplied value safe to slot into a markdown table cell.
*
* <p>An unescaped {@code |} or newline splits the cell and breaks the Order Form's table. That
* matters beyond appearance: this markdown is what gets hashed into the signature record, so a
* value that restructures the table means the SHA-256 we keep as proof covers a document
* reading differently from the one the signatory saw.
*/
private static String cell(String raw) {
return raw.trim().replace("|", "\\|").replaceAll("\\s*\\R+\\s*", " ");
}
/** Part B — the Order Form. Generated from the quote; the only per-deal section. */
private String orderForm(ProcurementQuote quote, Map<String, String> t) {
String date = t.get("effective_date");
String signatory = t.get("customer_signatory");
String signatoryTitle = t.get("customer_signatory_title");
StringBuilder sb = new StringBuilder();
sb.append("## Part B — Order Form · {{quote_ref}}\n\n");
sb.append("| Term | Value |\n| --- | --- |\n");
row(sb, "Customer", "{{customer_legal_name}}");
row(sb, "Subscription", "Enterprise · {{deployment}}");
row(sb, "Purchase order", "{{po_number}}");
row(sb, "Committed Volume", "{{committed_pdfs_yr}} PDFs / year at the {{posture}} posture");
row(sb, "Committed rate", "{{rate_per_pdf}} per PDF");
row(sb, "Service level", "{{sla_tier}} (per SLA Exhibit)");
row(
sb,
"Term",
"{{term_years}} year(s) · term discount {{term_discount_pct}} on committed processing");
row(sb, "Itemized services", itemizedServices(quote));
row(sb, "Annual Fee (year 1)", "{{annual_fee_y1}}");
row(sb, "Total (paid in advance)", "{{contract_total}}");
row(sb, "Escalator", "+3% at each anniversary during the Term");
row(
sb,
"Payment",
"Full {{term_years}}-year term invoiced in advance on acceptance · net 30 · ACH,"
+ " wire, or check");
row(sb, "Overage", "Committed rate, billed quarterly in arrears");
row(
sb,
"Data schedule",
"First 25 MB per file included; each additional 25 MB or part thereof (decimal MB,"
+ " rounded up per file, measured once at ingestion) draws down 1 PDF Process."
+ " Frozen for the Term (MSA §3.5).");
row(
sb,
"Drawdown schedule",
"{{posture}}: {{processes_per_pdf}} PDF Processes per PDF (MSA §3.3, frozen for the Term)");
row(
sb,
"Enhanced IP Protection",
"{{elected_or_not}} — extends §7.3 to patent claims at the §8.2 super-cap");
row(sb, "Standard terms", "SSO, SCIM, RBAC, and audit logs included.");
sb.append(
"\n**Itemized services menu (include as elected):** Self-hosted deployment $12,000/yr"
+ " · Air-gapped deployment $36,000/yr · Dedicated SE/CSM $30,000/yr · Enhanced IP"
+ " Protection (patent coverage, Section 7.3) 5% of committed processing fees ·"
+ " Onboarding & training $7,500 one-time · Quarterly business reviews $8,000/yr."
+ " Baseline IP indemnification (copyright, trademark, trade secret) is included at"
+ " no charge.\n\n");
sb.append(
"**Signatures.** By signing, each signatory represents they have authority to bind"
+ " their Party. Signatures delivered electronically or in counterparts are"
+ " effective as originals.\n\n");
sb.append("| Provider | Customer |\n| --- | --- |\n");
sb.append("| Stirling PDF, Inc. | {{customer_legal_name}} |\n");
sb.append("| Name: Matt Joseph | Name: ").append(signatory).append(" |\n");
sb.append("| Title: CEO | Title: ").append(signatoryTitle).append(" |\n");
sb.append("| Date: ").append(date).append(" | Date: ").append(date).append(" |\n");
return sb.toString();
}
/**
* The elected add-on lines, taken from the quote's stored breakdown (excludes the base meter).
*/
private String itemizedServices(ProcurementQuote quote) {
List<QuoteLineItem> lines = parseLineItems(quote.getLineItemsJson());
List<String> elected = new ArrayList<>();
for (QuoteLineItem li : lines) {
if (li.key().equals("usage")
|| li.key().equals("seats")
|| li.key().equals("multi-year")) {
continue;
}
String suffix = li.kind() == QuoteLineItem.Kind.ONE_TIME ? " (one-time)" : "/yr";
elected.add(li.label() + " " + money(li.amountMinor()) + suffix);
}
return elected.isEmpty() ? "None elected" : String.join(" · ", elected);
}
private List<QuoteLineItem> parseLineItems(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(
json,
objectMapper
.getTypeFactory()
.constructCollectionType(List.class, QuoteLineItem.class));
} catch (Exception e) {
log.warn("[legal] could not parse quote line items for the order form", e);
return List.of();
}
}
private static QuoteConfig toConfig(ProcurementQuote q) {
int users = q.getSeats() == null ? 0 : q.getSeats();
return new QuoteConfig(
q.getVolume(),
users,
q.getIntensity(),
q.getSizeMult(),
q.getDeployment(),
q.getTermYears(),
q.getServiceLevel(),
q.isIndemnification(),
q.isTraining(),
q.isQbr(),
q.getCurrency());
}
private static void row(StringBuilder sb, String term, String value) {
sb.append("| ").append(term).append(" | ").append(value).append(" |\n");
}
private static String slaTier(String serviceLevel) {
if ("dedicated".equalsIgnoreCase(serviceLevel)) return "Dedicated";
if ("priority".equalsIgnoreCase(serviceLevel)) return "Priority";
return "Standard";
}
/** Minor units (cents) → whole-dollar display; the quote figures are whole dollars. */
private static String money(long minor) {
return String.format(Locale.US, "$%,d", minor / 100L);
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
private static String nz(String s) {
return s == null ? "" : s;
}
}
@@ -0,0 +1,71 @@
package stirling.software.saas.procurement.legal;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.commonmark.Extension;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.TempFileManager;
/**
* Renders an assembled agreement's markdown to a PDF, dogfooding Stirling's own conversion path:
* commonmark (markdown → HTML) then {@link FileToPdf#convertHtmlToPdf} (HTML → PDF via WeasyPrint),
* the same pipeline as the product's Markdown-to-PDF tool.
*
* <p>The signed PDF is a stored artifact, but it must never block signing: {@link #tryRender}
* returns {@code null} if the conversion runtime (WeasyPrint) is unavailable, so the signature is
* still recorded and the buyer keeps the on-the-fly download.
*/
@Service
@RequiredArgsConstructor
public class AgreementPdfRenderer {
private final RuntimePathConfig runtimePathConfig;
private final TempFileManager tempFileManager;
private final CustomHtmlSanitizer customHtmlSanitizer;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private static final List<Extension> EXTENSIONS = List.of(TablesExtension.create());
/** Render to PDF, or return null if the conversion runtime isn't available. */
public byte[] tryRender(String markdown) {
try {
return render(markdown);
} catch (Exception e) {
org.slf4j.LoggerFactory.getLogger(AgreementPdfRenderer.class)
.warn(
"[legal] agreement PDF render unavailable; recording signature without a"
+ " stored PDF: {}",
e.getMessage());
return null;
}
}
private byte[] render(String markdown) throws Exception {
Parser parser = Parser.builder().extensions(EXTENSIONS).build();
Node document = parser.parse(markdown);
HtmlRenderer renderer = HtmlRenderer.builder().extensions(EXTENSIONS).build();
String html = renderer.render(document);
byte[] pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
null,
html.getBytes(StandardCharsets.UTF_8),
"agreement.html",
tempFileManager,
customHtmlSanitizer);
return pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
}
}
@@ -0,0 +1,12 @@
package stirling.software.saas.procurement.legal;
/**
* The buyer-supplied inputs captured at the moment of signing the enterprise agreement: the legal
* entity name, the signatory's typed name and title, and their representation of authority to bind.
* Null when the agreement is rendered for preview (before signing).
*/
public record AgreementSigning(
String customerLegalName,
String signatoryName,
String signatoryTitle,
boolean authorityConfirmed) {}
@@ -0,0 +1,16 @@
package stirling.software.saas.procurement.legal;
/**
* A rendered enterprise agreement: the full markdown the buyer sees (MSA + Order Form + DPA, tokens
* filled), plus the registry metadata that pins it. {@code variablesJson} is the exact set of
* Order-Form values as rendered, stored alongside a signature so the document is reproducible.
*/
public record AssembledAgreement(
String docId,
String version,
String versionLabel,
String displayName,
String effectiveDate,
String status,
String markdown,
String variablesJson) {}
@@ -0,0 +1,86 @@
package stirling.software.saas.procurement.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* An immutable record of a signed enterprise agreement. Each signature pins the exact legal
* document it was signed against — {@code documentId} + {@code documentVersion} + a SHA-256 {@code
* contentHash} of the rendered markdown — plus the Order-Form variable snapshot and the typed
* signatory details, so the agreement stays reproducible even after the templates version up. The
* rendered PDF is stored when the conversion runtime is available.
*/
@Entity
@Table(name = "procurement_agreement_signature")
@NoArgsConstructor
@Getter
@Setter
public class ProcurementAgreementSignature implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "signature_id")
private Long signatureId;
@Column(name = "deal_id", nullable = false)
private Long dealId;
@Column(name = "quote_id", nullable = false)
private Long quoteId;
// Which legal document, and which version of it, was signed.
@Column(name = "document_id", nullable = false, length = 64)
private String documentId;
@Column(name = "document_version", nullable = false, length = 32)
private String documentVersion;
@Column(name = "document_label", length = 64)
private String documentLabel;
// SHA-256 (hex) of the exact rendered agreement markdown the buyer accepted.
@Column(name = "content_hash", nullable = false, length = 64)
private String contentHash;
// The Order-Form variable values as rendered, so the document can be reproduced.
@Column(name = "variables_json", columnDefinition = "text")
private String variablesJson;
@Column(name = "customer_legal_name", length = 255)
private String customerLegalName;
@Column(name = "signatory_name", nullable = false, length = 255)
private String signatoryName;
@Column(name = "signatory_title", length = 255)
private String signatoryTitle;
@Column(name = "authority_confirmed", nullable = false)
private boolean authorityConfirmed;
@Column(name = "signer_ip", length = 64)
private String signerIp;
// The rendered PDF artifact; null when the conversion runtime was unavailable at signing.
@Column(name = "pdf")
private byte[] pdf;
@CreationTimestamp
@Column(name = "signed_at", nullable = false, updatable = false)
private LocalDateTime signedAt;
}
@@ -34,6 +34,13 @@ public class ProcurementDeal implements Serializable {
private static final long serialVersionUID = 1L;
/**
* Interest, before any commitment: the account asked about enterprise but has not started a
* trial. Kept as a real stage so intent survives a refresh, so the enterprise surface is only
* shown to accounts that asked for it, and so drop-off at the cheapest step is measurable.
*/
public static final String STAGE_EXPLORING = "exploring";
public static final String STAGE_TRIAL = "trial";
public static final String STAGE_QUOTE = "quote";
public static final String STAGE_AGREEMENT = "security";
@@ -69,12 +76,38 @@ public class ProcurementDeal implements Serializable {
@Column(name = "trial_extensions_used", nullable = false)
private int trialExtensionsUsed;
// Captured at trial setup, so the buying entity is known before any quote exists — the quote's
// own copies seed from these and may then diverge (a deal can change hands mid-cycle).
// Nullable: trials started before this step, and older clients, supply none.
@Column(name = "business_name", length = 255)
private String businessName;
@Column(name = "contact_name", length = 255)
private String contactName;
@Column(name = "contact_email", length = 320)
private String contactEmail;
// Addresses the buyer named at setup. Kept as the record of what was asked for; the invitations
// themselves go out through the team-invite path when the trial starts.
@Column(name = "invite_emails", length = 2000)
private String inviteEmails;
@Column(name = "license_ref", length = 128)
private String licenseRef;
@Column(name = "subscription_id", length = 255)
private String subscriptionId;
/**
* The last Stripe invoice whose payment was applied to this deal. Distinguishes a redelivered
* {@code invoice.paid} for a payment already handled from a genuine renewal, which has to
* re-issue: the committed licence expires term years from issue, so a renewal that doesn't
* re-issue leaves the licence lapsing after the customer has paid.
*/
@Column(name = "last_paid_invoice_id", length = 255)
private String lastPaidInvoiceId;
@Column(name = "accepted_quote_id")
private Long acceptedQuoteId;
@@ -47,7 +47,11 @@ public class ProcurementQuote implements Serializable {
@Column(name = "deal_id", nullable = false)
private Long dealId;
@Column(name = "quote_number", nullable = false, length = 64)
/**
* Stripe's quote number, the deal's one buyer-facing reference. Null until the quote is issued:
* Stripe assigns it at finalisation, and the issue edge function writes it back then.
*/
@Column(name = "quote_number", length = 64)
private String quoteNumber;
@Column(name = "status", nullable = false, length = 24)
@@ -60,12 +60,7 @@ public class ProcurementPricingService {
rates.discountPerDoubling()
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
: 0.0;
double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc));
// File-size multiplier (D93): larger, image-heavy PDFs cost more OCR/compute/storage. Folds
// into the per-run rate after the floor, so it flows through the meter, TCV and renewal.
// QuoteConfig has already snapped it to a known tier, so a tampered request can't sneak a
// cheaper factor in.
rate *= cfg.sizeMult();
double rate = perRunRate(cfg, rates);
double termDisc = rates.termDiscount(cfg.termYears());
// The meter is a whole-dollar figure (the quote reads in dollars), then minor units.
@@ -160,6 +155,48 @@ public class ProcurementPricingService {
return new QuoteBreakdown(lines, annualNet, tcv, renewalAnnual, cfg.currency());
}
/**
* The per-run rate after the committed-volume curve, the half-cent floor, and the file-size
* multiplier — the same value {@link #price} meters against. Extracted so read-only callers
* (the Order Form) can quote it without re-deriving the curve.
*/
private static double perRunRate(QuoteConfig cfg, PricingRates rates) {
long runVol = Math.max(0, cfg.volume()) * (long) Math.max(1, cfg.intensity());
double volDisc =
runVol > RUN_CURVE_KNEE
? Math.min(
0.5,
rates.discountPerDoubling()
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
: 0.0;
return Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc))
* cfg.sizeMult();
}
/**
* The effective per-PDF rate at the chosen posture, in dollars (4-decimal quote figure). This
* is what the Order Form and quote copy speak in — never the per-run rate. Read-only; does not
* affect billing.
*/
public double effectiveRatePerPdf(QuoteConfig cfg) {
return perRunRate(cfg, PricingRates.defaults()) * Math.max(1, cfg.intensity());
}
/** The multi-year term discount as a whole-percent figure for the Order Form (0.05 → 5). */
public int termDiscountPct(int termYears) {
return (int) Math.round(PricingRates.defaults().termDiscount(termYears) * 100.0);
}
/** Buyer-facing posture name (Essentials / Governed / Regulated) for the given intensity. */
public static String postureName(int intensity) {
return postureLabel(intensity);
}
/** Buyer-facing deployment name (Stirling Cloud / Self-hosted / Air-gapped). */
public static String deploymentName(String deployment) {
return deploymentLabel(deployment);
}
/** The default CPI escalator (fraction) applied to the annual fee on each post-term renewal. */
public double cpiEscalator() {
return PricingRates.defaults().cpiEscalator();
@@ -0,0 +1,29 @@
package stirling.software.saas.procurement.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import stirling.software.saas.procurement.model.ProcurementAgreementSignature;
public interface ProcurementAgreementSignatureRepository
extends JpaRepository<ProcurementAgreementSignature, Long> {
Optional<ProcurementAgreementSignature> findFirstByDealIdOrderBySignedAtDesc(Long dealId);
Optional<ProcurementAgreementSignature> findFirstByQuoteIdOrderBySignedAtDesc(Long quoteId);
/**
* Version labels of a deal's signatures, newest first. Projects just the label column so the
* frequently-polled snapshot never loads the PDF bytes. A signature means the agreement is
* signed; the PDF is resolved (stored or re-rendered) at download time.
*/
@Query(
"SELECT s.documentLabel FROM ProcurementAgreementSignature s"
+ " WHERE s.dealId = :dealId"
+ " ORDER BY s.signedAt DESC")
List<String> findSignedLabels(@Param("dealId") Long dealId);
}
@@ -5,7 +5,6 @@ import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
@@ -20,16 +19,24 @@ import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
import stirling.software.saas.procurement.legal.AgreementAssembler;
import stirling.software.saas.procurement.legal.AgreementPdfRenderer;
import stirling.software.saas.procurement.legal.AgreementSigning;
import stirling.software.saas.procurement.legal.AssembledAgreement;
import stirling.software.saas.procurement.license.EnterpriseLicenseService;
import stirling.software.saas.procurement.license.LicenseEntitlements;
import stirling.software.saas.procurement.model.ProcurementAgreementSignature;
import stirling.software.saas.procurement.model.ProcurementDeal;
import stirling.software.saas.procurement.model.ProcurementQuote;
import stirling.software.saas.procurement.model.QuoteDetails;
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
import stirling.software.saas.procurement.pricing.QuoteBreakdown;
import stirling.software.saas.procurement.pricing.QuoteConfig;
import stirling.software.saas.procurement.repository.ProcurementAgreementSignatureRepository;
import stirling.software.saas.procurement.repository.ProcurementDealRepository;
import stirling.software.saas.procurement.repository.ProcurementQuoteRepository;
import stirling.software.saas.service.SaasTeamService;
import stirling.software.saas.util.LogRedactionUtils;
/**
* Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a
@@ -52,6 +59,12 @@ public class ProcurementService {
private final EnterpriseLicenseService licenses;
private final ProcurementConfigurationProperties config;
private final TeamMembershipRepository memberRepo;
private final AgreementAssembler agreementAssembler;
private final AgreementPdfRenderer agreementPdfRenderer;
private final ProcurementAgreementSignatureRepository signatureRepo;
// Trial-setup invitations run through the team-invite path, with its seat, role and
// rate-limit rules rather than a second implementation here.
private final SaasTeamService teams;
public ProcurementService(
ProcurementDealRepository dealRepo,
@@ -59,13 +72,21 @@ public class ProcurementService {
ProcurementPricingService pricing,
EnterpriseLicenseService licenses,
ProcurementConfigurationProperties config,
TeamMembershipRepository memberRepo) {
TeamMembershipRepository memberRepo,
AgreementAssembler agreementAssembler,
AgreementPdfRenderer agreementPdfRenderer,
ProcurementAgreementSignatureRepository signatureRepo,
SaasTeamService teams) {
this.dealRepo = dealRepo;
this.quoteRepo = quoteRepo;
this.pricing = pricing;
this.licenses = licenses;
this.config = config;
this.memberRepo = memberRepo;
this.agreementAssembler = agreementAssembler;
this.agreementPdfRenderer = agreementPdfRenderer;
this.signatureRepo = signatureRepo;
this.teams = teams;
}
/**
@@ -89,11 +110,45 @@ public class ProcurementService {
return dealRepo.findByTeamId(teamId);
}
/**
* Starting a trial is only legitimate before one exists, while the buyer is still exploring, or
* to restart within the trial itself. A null stage is a deal that has just been constructed.
*
* <p>Package-private so the policy can be tested without the service's ten dependencies. Adding
* a later stage here would let a leader replace a paying customer's committed licence.
*/
static boolean canStartTrial(String stage) {
return stage == null
|| ProcurementDeal.STAGE_EXPLORING.equals(stage)
|| ProcurementDeal.STAGE_TRIAL.equals(stage);
}
@Transactional(readOnly = true)
public List<ProcurementQuote> quotesForDeal(Long dealId) {
return quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId);
}
/**
* Record that the account is looking at enterprise. Creates the deal at {@code exploring} when
* there is none; an existing deal is returned untouched, so this can never walk a live deal
* backwards or restart a trial.
*/
@Transactional
public ProcurementDeal recordInterest(Long teamId) {
return dealRepo.findByTeamId(teamId)
.orElseGet(
() -> {
ProcurementDeal deal = new ProcurementDeal(teamId);
deal.setStage(ProcurementDeal.STAGE_EXPLORING);
ProcurementDeal saved = dealRepo.save(deal);
log.info(
"[procurement] interest recorded team={} deal={}",
teamId,
saved.getDealId());
return saved;
});
}
/**
* Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial
* window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the
@@ -101,10 +156,40 @@ public class ProcurementService {
* ({@code cloud}/{@code selfhost}/{@code airgap}) and seat count are captured here so the quote
* builder opens seeded to their environment; both are still editable when the quote is built.
*/
@Transactional
public ProcurementDeal startTrial(Long teamId, String deployment, int seats) {
return startTrial(teamId, deployment, seats, null, null, null, null);
}
/**
* Start (or restart) the trial, capturing the buying entity if the setup step collected it.
* Blank details are ignored rather than written, so a re-run without them keeps what is there.
*
* <p>Only from before the trial or during it. Past that, {@code licenseRef} points at the
* committed annual licence, and this method would replace it with a fresh 14-day trial key
* while Stripe kept billing — the same hazard {@link #extendTrial} guards against, one step
* worse because it re-issues rather than re-dates. It would also rewind the stage and reset the
* extension counter.
*
* <p>The transaction is declared here rather than on the 3-arg overload: that one only
* delegates, and Spring's proxy cannot intercept a self-invocation, so an annotation there does
* nothing for either path. This is the method the controller calls, and it reaches out to
* Keygen between the read and the write.
*/
@Transactional
public ProcurementDeal startTrial(
Long teamId,
String deployment,
int seats,
String businessName,
String contactName,
String contactEmail,
String inviteEmails) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
if (!canStartTrial(deal.getStage())) {
throw new IllegalStateException(
"Trial cannot be started from stage " + deal.getStage());
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime ends = now.plusDays(config.getTrialDurationDays());
deal.setStage(ProcurementDeal.STAGE_TRIAL);
@@ -113,6 +198,10 @@ public class ProcurementService {
deal.setTrialStartedAt(now);
deal.setTrialEndsAt(ends);
deal.setTrialExtensionsUsed(0);
if (isNotBlank(businessName)) deal.setBusinessName(businessName.trim());
if (isNotBlank(contactName)) deal.setContactName(contactName.trim());
if (isNotBlank(contactEmail)) deal.setContactEmail(contactEmail.trim());
if (isNotBlank(inviteEmails)) deal.setInviteEmails(inviteEmails.trim());
deal.setLicenseRef(licenses.issueTrialLicense(teamId, leaderEmail(teamId), ends));
deal = dealRepo.save(deal);
log.info(
@@ -128,6 +217,45 @@ public class ProcurementService {
/**
* Constrain a caller-supplied deployment to the known set; anything else falls back to cloud.
*/
/**
* Send the invitations named at trial setup. Best-effort per address: a rejection (already a
* member, an invitee with their own paid plan, the hourly rate limit) must not fail the trial,
* so each is logged and skipped rather than propagated.
*
* <p>Note the first accepted invitation converts a personal team into a shared one with
* unlimited seats — that is {@code inviteUserToTeam}'s own rule, and naming teammates here is
* the buyer asking for exactly that.
*/
public void sendTrialInvites(
Long teamId, stirling.software.proprietary.security.model.User inviter, String emails) {
if (inviter == null || !isNotBlank(emails)) return;
for (String raw : emails.split("[,;\s]+")) {
String email = raw.trim();
if (email.isEmpty()) continue;
try {
teams.inviteUserToTeam(teamId, email, inviter);
// Redacted: an invitee list is third-party PII, and these logs are the one place it
// would otherwise be written in full. LogRedactionUtils is what the rest of the
// SaaS
// module uses for the same reason.
log.info(
"[procurement] trial invite sent team={} to={}",
teamId,
LogRedactionUtils.redactEmail(email));
} catch (Exception e) {
log.warn(
"[procurement] trial invite skipped team={} to={}: {}",
teamId,
LogRedactionUtils.redactEmail(email),
e.getMessage());
}
}
}
private static boolean isNotBlank(String value) {
return value != null && !value.isBlank();
}
private static String normalizeDeployment(String deployment) {
if (deployment == null) return "cloud";
String d = deployment.trim().toLowerCase(Locale.ROOT);
@@ -172,16 +300,32 @@ public class ProcurementService {
}
// (Re)building a quote returns the deal to the quote stage and drops any prior acceptance,
// so a rebuild from security/payment can't leave a stale stage or accepted-quote pointer.
QuoteBreakdown breakdown = pricing.price(cfg);
// Enforced before anything is persisted, and server-side rather than in the builder: the
// pricing curve has no natural floor (a small enough committed volume rounds the meter to
// zero) and every registered user leads their own team, so without this any signup could
// price a $0 enterprise quote, accept it, and be provisioned a committed licence.
long floor = config.getMinAnnualNetMinor();
if (floor > 0 && breakdown.annualNetMinor() < floor) {
throw new IllegalStateException(
"Quoted annual fee "
+ breakdown.annualNetMinor()
+ " is below the minimum enterprise deal size "
+ floor);
}
deal.setStage(ProcurementDeal.STAGE_QUOTE);
deal.setAcceptedQuoteId(null);
deal = dealRepo.save(deal);
QuoteBreakdown breakdown = pricing.price(cfg);
ProcurementQuote quote = new ProcurementQuote();
quote.setDealId(deal.getDealId());
quote.setQuoteNumber(nextQuoteNumber(deal.getDealId()));
// Priced but not yet issued: the edge fn creates the Stripe Quote and flips this to SENT.
// No quote number here: the deal's one reference is Stripe's, and Stripe does not assign it
// until the quote is finalised. The edge fn creates the Stripe Quote, flips this to SENT,
// and
// writes the number back. Nothing displays a reference in between — the builder only shows
// one
// for an issued quote, and the agreement is not assembled until after issue.
quote.setStatus(ProcurementQuote.STATUS_DRAFT);
quote.setCurrency(cfg.currency());
quote.setVolume(cfg.volume());
@@ -210,10 +354,11 @@ public class ProcurementService {
quote.setLineItemsJson(writeLineItems(breakdown));
quote.setValidUntil(LocalDate.now().plusDays(30));
quote = quoteRepo.save(quote);
// Logged by id, not reference: a draft has no reference until Stripe issues it.
log.info(
"[procurement] quote built team={} quote={} annualNet={} tcv={}",
teamId,
quote.getQuoteNumber(),
quote.getQuoteId(),
quote.getAnnualNetMinor(),
quote.getTcvMinor());
return quote;
@@ -241,6 +386,164 @@ public class ProcurementService {
return deal;
}
/** The quote a team is currently transacting on: its accepted quote, else the most recent. */
@Transactional(readOnly = true)
public Optional<ProcurementQuote> currentQuote(Long teamId) {
return dealRepo.findByTeamId(teamId)
.flatMap(
deal -> {
if (deal.getAcceptedQuoteId() != null) {
Optional<ProcurementQuote> accepted =
quoteRepo.findById(deal.getAcceptedQuoteId());
if (accepted.isPresent()) return accepted;
}
return quoteRepo
.findByDealIdOrderByCreatedAtDesc(deal.getDealId())
.stream()
.findFirst();
});
}
/**
* The filled enterprise agreement for a team's current quote, rendered for review (unsigned).
*/
@Transactional(readOnly = true)
public Optional<AssembledAgreement> agreementDocument(Long teamId) {
return currentQuote(teamId).map(q -> agreementAssembler.assemble(q, null));
}
/**
* The current (unsigned) agreement rendered to PDF, for download at the sign step. Empty when
* there's no quote yet or the render runtime is unavailable. The signed PDF (with the signature
* block filled) is a separate artifact recorded at signing (see {@link #latestSignature}).
*/
@Transactional(readOnly = true)
public Optional<byte[]> agreementDocumentPdf(Long teamId) {
return currentQuote(teamId)
.map(q -> agreementAssembler.assemble(q, null))
.map(a -> agreementPdfRenderer.tryRender(a.markdown()));
}
/**
* Record a signed enterprise agreement: assemble the final document, hash it, render + store
* the PDF (best-effort), and persist an immutable signature pinned to the exact document
* version. Does not itself accept the quote into a subscription — the caller proceeds to accept
* as before.
*
* <p>Deliberately not {@code @Transactional}: rendering the PDF shells out to WeasyPrint, and
* holding the deal's row lock across an external process buys nothing here. The only write is a
* single insert, which {@code save} makes atomic on its own.
*/
public ProcurementAgreementSignature signAgreement(
Long teamId, AgreementSigning signing, String signerIp) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId)
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
ProcurementQuote quote =
currentQuote(teamId)
.orElseThrow(
() ->
new IllegalStateException(
"No quote to sign for team " + teamId));
// Signing is only meaningful against an issued quote at the agreement stage. Without this a
// direct API call could record a signature over a draft (whose quote_ref is still empty),
// or
// re-sign a deal that has already moved on.
if (!ProcurementDeal.STAGE_AGREEMENT.equals(deal.getStage())) {
throw new IllegalStateException(
"Deal is not at the agreement stage for team " + teamId);
}
if (!ProcurementQuote.STATUS_SENT.equals(quote.getStatus())) {
throw new IllegalStateException("Quote is not issued for team " + teamId);
}
AssembledAgreement assembled = agreementAssembler.assemble(quote, signing);
ProcurementAgreementSignature sig = new ProcurementAgreementSignature();
sig.setDealId(deal.getDealId());
sig.setQuoteId(quote.getQuoteId());
sig.setDocumentId(assembled.docId());
sig.setDocumentVersion(assembled.version());
sig.setDocumentLabel(assembled.versionLabel());
sig.setContentHash(sha256(assembled.markdown()));
sig.setVariablesJson(assembled.variablesJson());
sig.setCustomerLegalName(signing.customerLegalName());
sig.setSignatoryName(signing.signatoryName());
sig.setSignatoryTitle(signing.signatoryTitle());
sig.setAuthorityConfirmed(signing.authorityConfirmed());
sig.setSignerIp(signerIp);
sig.setPdf(agreementPdfRenderer.tryRender(assembled.markdown()));
sig = signatureRepo.save(sig);
log.info(
"[procurement] agreement signed team={} quote={} doc={} pdf={}",
teamId,
quote.getQuoteId(),
assembled.versionLabel(),
sig.getPdf() != null);
return sig;
}
/** The latest recorded signature for a team's deal, if any (for the signed-PDF download). */
@Transactional(readOnly = true)
public Optional<ProcurementAgreementSignature> latestSignature(Long teamId) {
return dealRepo.findByTeamId(teamId)
.flatMap(
deal ->
signatureRepo.findFirstByDealIdOrderBySignedAtDesc(
deal.getDealId()));
}
/**
* The version label of the deal's latest signed agreement, if any. Used to surface the
* "download signed agreement" action once a signature exists; the snapshot polls this, so it
* deliberately avoids loading the PDF bytes.
*
* <p>Deliberately not conditional on a stored PDF: download re-renders from the pinned document
* version on demand, so the action works whether or not the render succeeded at signing time.
*/
@Transactional(readOnly = true)
public Optional<String> signedAgreementLabel(Long dealId) {
return signatureRepo.findSignedLabels(dealId).stream().findFirst();
}
/**
* The signed agreement as a PDF for download: the artifact stored at signing, or — if the
* render runtime was unavailable then — re-rendered now from the signature's details. Empty
* when the team has no signature or the render runtime is still unavailable.
*/
@Transactional(readOnly = true)
public Optional<byte[]> signedAgreementPdf(Long teamId) {
return latestSignature(teamId)
.flatMap(
sig -> {
if (sig.getPdf() != null) return Optional.of(sig.getPdf());
return quoteRepo
.findById(sig.getQuoteId())
.map(
q ->
agreementAssembler.assemble(
q,
new AgreementSigning(
sig.getCustomerLegalName(),
sig.getSignatoryName(),
sig.getSignatoryTitle(),
sig.isAuthorityConfirmed())))
.map(a -> agreementPdfRenderer.tryRender(a.markdown()));
});
}
private static String sha256(String s) {
try {
byte[] digest =
java.security.MessageDigest.getInstance("SHA-256")
.digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(digest);
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
/**
* Provision on accept: upgrade the team's licence to the committed annual term (valid
* immediately), so the buyer can get going the moment they accept — before the invoice is paid.
@@ -260,22 +563,58 @@ public class ProcurementService {
}
/**
* Mark the deal fully live (advance to the active stage) once payment settles. In production
* this is the {@code invoice.paid} webhook; here it's the demo/manual stand-in. Re-affirms the
* Mark the deal fully live (advance to the active stage) once payment settles — driven by the
* {@code invoice.paid} webhook, and by the demo control when those are enabled. Re-affirms the
* annual licence in case provisioning didn't run at accept.
*
* <p>Idempotent per invoice rather than per stage, which matters because {@code invoice.paid}
* carries two different meanings. Stripe redelivers events, so the <em>same</em> invoice
* arriving twice must do nothing. But the renewal payment a year later is also an {@code
* invoice.paid}, and the committed licence expires term years from issue — so a
* <em>different</em> invoice has to re-issue, moving the expiry out, or the customer's licence
* lapses after they have paid. Keying on the stage alone couldn't tell those apart and treated
* every renewal as a duplicate.
*
* @param paidInvoiceId the Stripe invoice that was paid, or null when the caller has no invoice
* to identify the payment by (the demo control). Null keeps the old conservative behaviour:
* a live deal short-circuits, since there is nothing to tell a renewal from a repeat.
*/
@Transactional
public ProcurementDeal markLive(Long teamId) {
public ProcurementDeal markLive(Long teamId, String paidInvoiceId) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId)
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())
&& (paidInvoiceId == null || paidInvoiceId.equals(deal.getLastPaidInvoiceId()))) {
log.debug(
"[procurement] invoice.paid already applied team={} deal={} invoice={}",
teamId,
deal.getDealId(),
paidInvoiceId);
return deal;
}
boolean renewal = ProcurementDeal.STAGE_LIVE.equals(deal.getStage());
deal.setLicenseRef(issueOrUpgradeAnnual(deal));
if (paidInvoiceId != null) deal.setLastPaidInvoiceId(paidInvoiceId);
deal.setStage(ProcurementDeal.STAGE_LIVE);
deal = dealRepo.save(deal);
log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId());
log.info(
"[procurement] deal live team={} deal={} renewal={} invoice={}",
teamId,
deal.getDealId(),
renewal,
paidInvoiceId);
return deal;
}
/**
* Go live with no invoice reference — the demo control. See {@link #markLive(Long, String)}.
*/
@Transactional
public ProcurementDeal markLive(Long teamId) {
return markLive(teamId, null);
}
/**
* Issue or upgrade the committed annual licence from the deal's accepted (else latest) quote,
* stamping the full entitlement snapshot onto it and upgrading the trial licence in place when
@@ -327,15 +666,34 @@ public class ProcurementService {
* before paying — that's bounded: the trial licence carries {@code expiry = trialEndsAt}, so
* the file the verifier accepts self-expires at trial end. The buyer must re-download after
* provisioning to get the committed-term file (the portal warns about this).
*
* <p>Once a quote exists, the quote's deployment decides — not the deal's. They are two
* different values: the deal's is chosen free at trial setup, the quote's is the one carrying
* the air-gap deploy fee. Reading the deal's here meant selecting air-gapped in the trial and
* then buying a cloud quote still yielded the offline file, and after provisioning it was
* checked out against the committed annual licence — so the self-expiry above no longer bounded
* it.
*/
@Transactional(readOnly = true)
public Optional<String> offlineLicenseFile(Long teamId) {
ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElse(null);
if (deal == null || deal.getLicenseRef() == null) return Optional.empty();
if (!"airgap".equalsIgnoreCase(deal.getDeployment())) return Optional.empty();
if (!"airgap".equalsIgnoreCase(entitledDeployment(deal))) return Optional.empty();
return Optional.of(licenses.checkOutLicenseFile(deal.getLicenseRef()));
}
/**
* The deployment the team is actually entitled to: the priced quote's once one exists,
* otherwise the trial's self-selected target. Paid entitlements must follow what was quoted.
*/
private String entitledDeployment(ProcurementDeal deal) {
ProcurementQuote quote = currentQuote(deal.getTeamId()).orElse(null);
if (quote != null && quote.getDeployment() != null && !quote.getDeployment().isBlank()) {
return quote.getDeployment();
}
return deal.getDeployment();
}
/**
* Reset a team's procurement: delete the deal (quotes + activity cascade). For
* re-demos/testing.
@@ -346,12 +704,6 @@ public class ProcurementService {
log.info("[procurement] deal reset team={}", teamId);
}
private String nextQuoteNumber(Long dealId) {
int seq = quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId).size() + 1;
String token = UUID.randomUUID().toString().substring(0, 4).toUpperCase(Locale.ROOT);
return String.format(Locale.ROOT, "QT-%s-%04d", token, seq);
}
private String writeLineItems(QuoteBreakdown breakdown) {
try {
return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems());
@@ -0,0 +1,53 @@
## Part C — Data Processing Addendum
This DPA forms part of the Agreement and applies where Provider processes Personal Data on Customer's behalf.
### C1. Roles; scope; instructions
Customer is the controller (or a processor on behalf of its own controllers); Provider is a processor (or subprocessor, as applicable). Provider processes Personal Data only on Customer's documented instructions — including processing initiated by Customer's users, policies, pipelines, and API calls — unless required by law (in which case Provider informs Customer unless legally prohibited). **Provider will inform Customer without undue delay if, in Provider's opinion, an instruction infringes the GDPR, UK GDPR, or other applicable data-protection law.** Customer is responsible for the lawfulness of the Personal Data it submits and the instructions it gives; Customer's rights under this DPA include instruction, audit (C9), objection to subprocessors (C5), assistance (C6), and return or deletion of data (C10).
### C2. Details of processing
**Subject matter/nature:** PDF processing and governance (classification, redaction, routing, retention, conversion, signing, extraction, AI-assisted analysis). **Duration:** the Term plus the deletion period. **Categories of data:** any Personal Data contained in Customer files and metadata (names, contact details, identifiers, financial or health data if present in Customer files), account data of Customer users. **Data subjects:** Customer's employees, users, customers, and other persons appearing in Customer files. **Sensitive data:** may be present in Customer files at Customer's discretion; Customer is responsible for the lawful basis.
### C3. Confidentiality; personnel
Provider ensures persons authorized to process Personal Data are bound by confidentiality and receive security training. Zero-standing-access applies: content access is just-in-time, logged, and audited (MSA Section 4.2).
### C4. Security measures (Annex II summary)
Encryption in transit (TLS 1.2+) and at rest (AES-256); zero-standing-access with audited JIT elevation; role-based access control; SSO/SCIM; tenant isolation; vulnerability management and penetration testing; audit logging of processing events (including file name, hash, size, and operations); backup and recovery. For Self-hosted and Air-gapped deployments, Customer operates the runtime environment and is responsible for infrastructure-level controls; Provider's measures apply to license/metering services and support access.
### C5. Subprocessors
Customer generally authorizes the subprocessors listed at {{subprocessor_url}}: cloud infrastructure (Amazon Web Services), payment processing (Stripe, as independent controller for payment data), email delivery (Google), account infrastructure (Supabase), product telemetry (PostHog, EU-hosted; pseudonymous usage events, never file content), and AI model providers: **Anthropic** (Claude models — receives prompts and the document text or excerpts needed for the requested AI feature) and **Voyage AI** (embedding models — receives extracted text excerpts solely to generate embeddings where Customer enables Ingestion/RAG features). AI features are optional and may be disabled; where used, AI providers receive only the content needed for the requested feature. **Whole Customer files are never transmitted to any AI provider.** Neither AI provider trains on Customer data (verified against the signed provider agreements, Jul 10, 2026). Provider gives thirty (30) days' notice of new subprocessors; Customer may object on reasonable data-protection grounds, and if unresolved, may terminate the affected Services with a pro-rata refund. **Provider imposes data-protection obligations on each subprocessor by written contract that are at least as protective as this DPA, and remains fully responsible to Customer for each subprocessor's performance.**
### C6. Data subject requests; assistance
Taking into account the nature of the processing, Provider provides reasonable assistance (including through the Processor's search, redaction, and audit tools) for Customer's obligations under GDPR Articles 3236: security of processing, breach notification to authorities and data subjects, data protection impact assessments, and prior consultations with supervisory authorities, as well as responses to data subject requests. Provider forwards requests received directly to Customer and does not respond except as legally required. **Provider makes available to Customer all information necessary to demonstrate compliance with this DPA and allows for and contributes to audits, including inspections, per Section C9.**
### C7. Breach notification
Per MSA Section 5.3: without undue delay after becoming aware of a Personal Data Breach, and in any event within forty-eight (48) hours of awareness, with information provided in phases as available — including the nature of the breach, categories and approximate volumes affected, likely consequences, and measures taken or proposed.
### C8. International transfers
Where Personal Data subject to GDPR/UK GDPR is transferred to countries without adequacy, the Parties incorporate the EU Standard Contractual Clauses (Commission Decision 2021/914): **Module 2** (controller-to-processor) where Customer is a controller, and **Module 3** (processor-to-processor) where Customer acts as a processor, with the following selections — Clause 7 (docking): included; Clause 9(a): Option 2 (general written authorization, 30 days' notice per C5); Clause 11(a) optional language: not used; Clause 17: the law of Ireland; Clause 18: the courts of Ireland; competent supervisory authority: the Irish Data Protection Commission (per Annex I.C). Annex I (parties, description of transfer: as per Section C2), Annex II (technical and organizational measures: as per Section C4), and Annex III (subprocessors: as per Section C5 and {{subprocessor_url}}) are completed by reference to this DPA. For UK transfers, the UK International Data Transfer Addendum applies with its Tables completed by reference to the foregoing. Provider is not certified under the EU-U.S. Data Privacy Framework; the SCCs are the transfer mechanism.
**Note:** Provider does not currently offer contractual EU data residency for Stirling Cloud; residency is achieved via Self-hosted or Air-gapped deployment.
### C9. Audits
Provider's security reports and documentation (Section 5.1) are the ordinary means of demonstrating compliance. Customer may additionally audit — by itself or a mandated auditor — once per year on thirty (30) days' notice, and at any time where: (a) a security incident affecting Customer Personal Data has occurred; (b) provided documentation reveals a material deficiency; (c) a competent supervisory authority requires it; or (d) Customer reasonably suspects material noncompliance with this DPA. Audits are conducted during business hours, under confidentiality, at Customer's cost, with reasonable notice, without unreasonable interference with Provider's operations, and without access to other customers' data.
### C10. Return & deletion
On termination, at Customer's choice, Provider returns Customer file content and Personal Data (export of Customer files and the governed-record metadata) and/or deletes them — from live systems within thirty (30) days and from backups within ninety (90) days — except as retention is required by law, and certifies deletion on request. Where Customer uses HYOK, key destruction by Customer renders content cryptographically inaccessible immediately.
### C11. CCPA/CPRA
Provider is a "service provider" under the CCPA/CPRA. Provider: (a) processes Personal Information only for the business purposes specified in this Agreement — providing, securing, metering, and supporting the Services described in Section C2; (b) shall not sell or share Personal Information; (c) shall not retain, use, or disclose it for any purpose other than those business purposes, or outside the direct business relationship between the Parties; (d) shall not combine it with Personal Information received from other sources, except as permitted by CCPA regulations for the business purposes; (e) provides the same level of privacy protection required of businesses by the CCPA; (f) will notify Customer if it determines it can no longer meet its CCPA obligations; (g) grants Customer the right, upon reasonable notice, to take reasonable and appropriate steps to ensure Provider's use of Personal Information is consistent with Customer's obligations, and to stop and remediate any unauthorized use; and (h) flows these requirements down to its subprocessors per Section C5. Provider certifies that it understands these restrictions and will comply with them.
### C12. Liability
Liability under this DPA is subject to the MSA's limitations (Section 8).
@@ -0,0 +1,119 @@
# Stirling Enterprise Agreement
One signature executes all three parts: the Master Services Agreement (Part A), the Order Form (Part B), and the Data Processing Addendum (Part C). The Stirling EULA & Commercial Terms is incorporated by reference.
## Part A — Master Services Agreement
This Master Services Agreement (the "Agreement") is entered into as of {{effective_date}} (the "Effective Date") by and between **Stirling PDF, Inc.**, a Delaware corporation with offices at 548 Market Street PMB 887643, San Francisco, CA 94104 ("Provider"), and **{{customer_legal_name}}** ("Customer"). Each a "Party," together the "Parties."
### 1. Services & License
1.1 **The Services.** Provider will provide the Stirling PDF Processor (the "Processor") — the hosted or customer-deployed platform for distributing PDF editors and governing PDF processing, including policies, pipelines, the Stirling Agent, API access, and the administrative console — and the Stirling PDF Editor (the "Editor"), as described in the Order Form.
1.2 **License grants.** Provider grants Customer, for the Term: (a) a non-exclusive, non-transferable right to access and use the Processor for Customer's internal business operations, up to the Committed Volume; and (b) a non-exclusive right to deploy and distribute the Editor to Customer's authorized users without limit on user count. Open-source components of the Editor remain governed by their own licenses, which control for those components.
1.3 **Deployment.** The Services are delivered via the deployment stated in the Order Form (Stirling Cloud, Self-hosted, or Air-gapped). Self-hosted deployments validate their license and report metering data online; Air-gapped deployments verify a signed activation bundle offline and reconcile usage periodically as described in the Documentation. Customer shall not disable, circumvent, or falsify license validation or usage metering. **The metered rate does not vary by deployment**; deployment-specific services are priced as line items in the Order Form.
1.4 **Restrictions.** Customer shall not: resell or provide the Services to third parties as a service bureau; reverse engineer non-open-source components; use the Services to violate law; or exceed the scope of the Order Form other than through Overage (Section 3.4).
### 2. Term & Renewal
2.1 **Initial Term.** {{term_years}} year(s) from the Effective Date.
2.2 **Renewal.** The Agreement auto-renews for successive periods equal to the Initial Term unless either Party gives sixty (60) days' written notice of non-renewal before the end of the then-current term. The Annual Fee for each renewal year equals the immediately preceding year's Annual Fee increased by three percent (3%) — the same formula as Section 2.3. Itemized services escalate at the same rate unless restated in a superseding Order Form.
2.3 **In-term escalator.** The Annual Fee (including itemized services) increases by a fixed three percent (3%) at each anniversary of the Effective Date during the Term.
### 3. Fees & Payment
3.1 **Annual Fee.** Customer shall pay the Annual Fee stated in the Order Form, calculated as the Committed Volume ({{committed_pdfs_yr}} PDFs per year) at {{rate_per_pdf}} per PDF at the {{posture}} governance posture, plus the itemized services in the Order Form, less the term discount stated there.
3.2 **Invoicing.** Fees are invoiced annually in advance, due net thirty (30) days. Late amounts accrue interest at 1.5% per month or the maximum permitted by law, whichever is less. Fees are exclusive of taxes; Customer is responsible for all taxes other than Provider's income taxes.
3.3 **Committed Volume; measurement.** The Committed Volume is denominated in PDFs processed per year at the stated posture, and converts to a drawdown allowance in PDF Processes at the fixed conversion schedule below, which is frozen for the Term:
| Posture | PDF Processes per PDF |
| --- | --- |
| Essentials | 2 |
| Governed | 4 |
| Regulated | 7 |
A **"PDF Process"** is one policy execution, one pipeline run, or one Stirling Agent returned artifact, applied to one file, plus Data Processing increments under Section 3.5. For clarity: a pipeline run counts as one PDF Process regardless of the number of operations in its chain; a Stirling Agent artifact counts as one regardless of the number of messages that produced it; failed processes (those that do not complete) are not counted; reprocessing the same file and duplicate submissions are counted; counts are whole numbers (no rounding). The Processor's audit log records each PDF Process and is the system of record, subject to Section 3.7. Provider will make a per-file usage statement (file identifier, size, processes, drawdown) available for audit.
**Worked example.** At the Governed posture, a commitment of 90,000,000 PDFs/year provides a drawdown allowance of 360,000,000 PDF Processes. A 60 MB file that runs the four Governed policies draws down 4 PDF Processes plus 2 Data Processing increments (Section 3.5) = 6 PDF Processes. The allowance is a purchased quantity, not a feature limit: Customer may run any number of policies or pipelines; actual consumption simply draws the allowance down faster, and consumption beyond it bills as Overage (Section 3.4).
3.4 **Overage.** Consumption beyond the Committed Volume in a contract year is billed quarterly in arrears at the committed rate stated in the Order Form. Overage does not increase subsequent years' Committed Volume.
3.5 **Data Processing.** Each file includes its first twenty-five (25) megabytes (decimal, 1 MB = 1,000,000 bytes) at no additional drawdown. Each additional twenty-five (25) megabytes or part thereof (rounded up per file) draws down one (1) additional PDF Process. File size is measured once per file at ingestion, on the file as submitted. This schedule is stated here in full, is frozen for the Term, and is not subject to alteration through the Documentation.
3.6 **No refunds.** Except as expressly stated (Sections 7.1, 7.3, 10.3, and DPA Section C5), fees are non-refundable and Committed Volume does not roll over between contract years.
3.7 **Billing disputes.** Customer may dispute any invoice or metering record in good faith within sixty (60) days of the invoice date. Provider will investigate promptly, provide the relevant audit-log extracts and usage statements, and correct confirmed errors by credit or refund. The audit log is presumptively accurate but not conclusive; Customer may rebut it with reasonable evidence. Undisputed amounts remain payable when due.
### 4. Data Protection
4.1 The Data Processing Addendum at Part C (the "DPA") is incorporated into this Agreement and governs Provider's processing of Customer Personal Data, in compliance with the GDPR, UK GDPR, and CCPA/CPRA to the extent applicable.
4.2 **Zero-standing-access.** Customer file content is encrypted in transit and at rest. Provider personnel have no standing access to Customer file content; access is granted just-in-time under audited elevation, solely as necessary to provide the Services or as instructed by Customer. Document metadata is maintained to operate the governed record. Where the Order Form includes BYOK or HYOK key management, the key terms in the Documentation apply.
### 5. Security & Availability
5.1 **Security program.** Provider maintains a written information security program including access controls, encryption (TLS 1.2+ in transit, AES-256 at rest), audit logging, vulnerability management, and personnel security. Provider will provide its available security documentation (including its security program overview and penetration-test attestation) upon request under confidentiality.
5.2 **Availability.** For Stirling Cloud deployments, Provider targets 99.9% monthly uptime, excluding scheduled maintenance announced at least 48 hours in advance. The uptime figure is a target, not a credited commitment, and no service credits apply. Support response commitments for the {{sla_tier}} tier are set out in the SLA Exhibit referenced by the Order Form.
5.3 **Breach notice.** Provider will notify Customer without undue delay after becoming aware of a Personal Data Breach affecting Customer Personal Data, and in any event within forty-eight (48) hours of awareness. Provider may provide information in phases as it becomes available and will supplement its notice as investigation proceeds.
5.4 **Updates.** Provider will make security patches and product upgrades available to Customer at no additional charge for supported versions.
### 6. Confidentiality
6.1 Each Party shall protect the other's Confidential Information with at least the care it uses for its own similar information and no less than reasonable care, use it solely to perform under this Agreement, and disclose it only to personnel and advisors with a need to know who are bound by confidentiality obligations at least as protective. Confidential Information excludes information that is public without breach, independently developed, or rightfully received from a third party.
6.2 Compelled disclosure is permitted with prompt notice (where lawful) and reasonable cooperation to seek protective treatment.
6.3 Obligations survive three (3) years after termination; trade secrets survive as long as they remain trade secrets.
### 7. Warranties & Indemnification
7.1 **Performance warranty.** Provider warrants the Services will perform materially in accordance with the Documentation. Customer's exclusive remedy for breach is re-performance or, if Provider cannot re-perform within thirty (30) days, termination of the affected Services and a pro-rata refund of prepaid, unused fees for those Services.
7.2 **Mutual warranties.** Each Party warrants it has the authority to enter this Agreement and will comply with applicable law in its performance.
7.3 **IP indemnification.** Provider shall defend Customer against third-party claims that the Services, as provided and used per this Agreement, infringe a copyright or trademark or misappropriate a trade secret, and shall indemnify Customer for resulting damages finally awarded or agreed in settlement. Where **Enhanced IP Protection** is elected on the Order Form, this obligation extends to patent claims and carries the enhanced cap stated in Section 8.2. Exclusions: combinations with non-Provider materials, Customer content, modifications not made by Provider, and use after notice to stop. Provider may procure rights, modify, or replace the Services; if none is practicable, Provider may terminate the affected Services and refund prepaid, unused fees. This section states Customer's exclusive remedy for IP claims.
7.4 **Customer indemnification.** Customer shall defend and indemnify Provider against third-party claims arising from Customer content, Customer's breach of Section 1.4, or Customer's violation of law.
7.5 **Disclaimer.** EXCEPT AS EXPRESSLY STATED, THE SERVICES ARE PROVIDED WITHOUT OTHER WARRANTIES, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. AI-ASSISTED OUTPUTS (INCLUDING CLASSIFICATION, EXTRACTION, AND AGENT ARTIFACTS) ARE PROBABILISTIC; CUSTOMER IS RESPONSIBLE FOR HUMAN REVIEW WHERE OUTPUTS HAVE LEGAL OR REGULATORY EFFECT.
### 8. Limitation of Liability
8.1 NEITHER PARTY IS LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR LOST PROFITS OR REVENUE.
8.2 **General cap.** EACH PARTY'S AGGREGATE LIABILITY IS CAPPED AT THE FEES PAID OR PAYABLE UNDER THIS AGREEMENT IN THE TWELVE (12) MONTHS PRECEDING THE FIRST EVENT GIVING RISE TO LIABILITY. **Super-cap:** for breaches of Section 6 (Confidentiality), breaches of the DPA or Section 45 security obligations, and IP indemnification under Section 7.3, the cap is TWO TIMES (2x) such fees. **Uncapped:** fraud, willful misconduct, Customer's payment obligations, and Customer's indemnification under Section 7.4 for claims arising from Customer's willful violation of law.
8.3 All claims arising from the same event or series of connected events count as a single claim for the purposes of the caps in Section 8.2.
### 9. General
9.1 **Entire agreement; precedence.** This Agreement (Parts AC, the Order Form, the SLA Exhibit, and the Standard Contractual Clauses where applicable) is the entire agreement and supersedes prior proposals and quotes, including {{quote_ref}}. Only the following provisions of the Stirling EULA & Commercial Terms are incorporated: Section 3 (Definitions), Section 8 (AI features), Section 10 (Self-hosted and desktop software), and Section 11 (Fair use). The website Terms of Service do not apply to this Agreement; without limitation, their arbitration and class-waiver provisions, online auto-renewal rules, unilateral-amendment provision, self-serve pricing, and clickwrap acceptance mechanism are expressly excluded. Precedence: Order Form → Standard Contractual Clauses (for international transfers) → DPA → MSA → SLA Exhibit → incorporated EULA sections → Documentation.
9.2 **Governing law; venue.** Delaware law, excluding conflicts rules. Exclusive jurisdiction and venue in the state or federal courts located in San Francisco County, California, and the Parties consent to personal jurisdiction there.
9.3 **Assignment.** Neither Party may assign without the other's consent, except to a successor in a merger, acquisition, or sale of substantially all assets, with notice.
9.4 **Notices.** Written notices to the addresses on the Order Form; email permitted with confirmation of receipt.
9.5 **Force majeure; independent contractors; waiver; severability.** Standard terms apply: neither Party is liable for delay caused by events beyond reasonable control; the Parties are independent contractors; failure to enforce is not waiver; unenforceable provisions are severed with the remainder in effect.
9.6 **Publicity.** Neither Party may use the other's name or marks publicly without prior written consent, except Provider may identify Customer as a customer with Customer's prior approval of the specific use.
9.7 **Suspension.** Provider may suspend the Services for material breach that threatens the security or integrity of the Services, with notice and opportunity to cure where practicable. Undisputed unpaid fees more than thirty (30) days late are grounds for suspension after ten (10) days' notice.
### 10. Termination
10.1 Either Party may terminate for material breach uncured thirty (30) days after written notice, or immediately upon the other's insolvency.
10.2 On termination: Customer's access ends (self-hosted licenses expire per the license mechanism); each Party returns or destroys the other's Confidential Information; the DPA's deletion terms govern Customer Personal Data; Sections 3 (accrued fees), 6, 7, 8, 9, and 10 survive.
10.3 If Customer terminates for Provider's uncured material breach, Provider refunds prepaid fees for the unused remainder of the then-current contract year.
@@ -0,0 +1,75 @@
# Stirling EULA & Commercial Terms
This document fills the EULA slot the website Terms of Service §5 already contemplates ("If a separate end-user license (EULA) accompanies software, that license governs to the extent of any conflict"). It carries the commercial terms of the actual product: the PDF Process meter, spend limits, prepaid capacity, the free allotment, trials, self-hosted licensing, and AI features. For customers under a signed Stirling Enterprise Agreement, that agreement controls.
**Effective:** {{version_date}} · **Version:** {{version}}
## 1. Agreement; precedence
These EULA & Commercial Terms ("EULA") supplement the Stirling Terms of Service (stirling.com/legal/terms-of-service). If they conflict, this EULA controls for the software and the commercial terms below. Open-source components are governed by their own licenses, which control for those components. By clicking accept, creating a workspace, or using the software, you agree on behalf of yourself and, if applicable, the organization you represent ("you").
## 2. The products
**The Stirling PDF Editor** is free: manual editing, the tool catalog, and team administration (including SSO) carry no subscription fee or per-seat charge, whether used in the browser, as a desktop application, or self-hosted. Usage limits on automated processing, fair-use rules (Section 11), support levels, and the feature set may change over time, and third-party costs (such as your own hosting) are yours. The Editor's open-source components remain available under their own licenses independently of this EULA. **The Stirling PDF Processor** is the paid platform that processes PDFs automatically — policies, pipelines, the Stirling Agent, and API processing — billed on the meter below.
## 3. Definitions
**"PDF Process"** — one policy execution, one pipeline run (regardless of the number of operations in its chain), or one Stirling Agent returned artifact (a processed file or summary, regardless of the number of messages that produced it), applied to one file. **"Data Processing"** — the data-volume component of the meter: files carry their first 25 MB included per file; volume past that is billed per Section 4. **"file"** — a document processed by the Processor. Chatting with the Stirling Agent is free; only returned artifacts meter.
## 4. Metered billing (pay as you go)
4.1 **Rates.** 1¢ per PDF Process, plus Data Processing at 1¢ per 25 MB increment past the first 25 MB of each file. Megabytes are decimal (1 MB = 1,000,000 bytes); size is measured once per file as submitted; increments round up ("part thereof" counts — a 26 MB file incurs one Data Processing increment, a 60 MB file incurs two). Rates may change on thirty (30) days' notice; changes apply prospectively. **Example:** two policies on a 3 MB contract = 2¢; two policies on a 60 MB scan set = 2¢ + 2¢ data = 4¢.
4.2 **Free allotment.** New workspaces receive a one-time allotment of 500 PDF Processes. A file processed by two processes consumes two of the 500. When the allotment is exhausted, processing pauses until the Processor is switched on.
4.3 **Invoices.** Usage is invoiced monthly on the 1st for the prior cycle, charged to your payment method on file (card or ACH debit). You authorize these charges.
4.4 **Usage records.** The Processor's audit log is the system of record, subject to Section 4.5. Your Usage & Billing page shows consumption, and a per-file usage statement (name, size, processes, charge) is available for download.
4.5 **Billing disputes.** You may dispute a charge or metering record in good faith within sixty (60) days of the invoice or charge date. We will investigate, provide the relevant usage-statement detail, and correct confirmed errors by credit or refund. The audit log is presumptively accurate but not conclusive; reasonable contrary evidence will be considered. Undisputed amounts remain payable.
## 5. Spend limits
5.1 You may set a monthly spend limit. By default, processing pauses when usage reaches the limit; queued documents resume when you raise the limit or the cycle resets. Nothing already processed is lost.
5.2 If you enable **keep-processing** ("Keep processing if you hit your limit"), usage past the limit continues to accrue and be billed per Section 4; the limit then functions as a notification threshold. You can change the limit or the toggle at any time in Usage & Billing.
## 6. Cancellation; downgrade
You may revert to the free Editor plan at any time from Usage & Billing. Accrued usage remains payable. Your policies, configuration, and history are retained per the Terms of Service data-retention practices.
## 7. Prepaid capacity (self-serve annual)
7.1 **Offer.** You may prepay twelve (12) months of processing capacity for the price of ten (10) (the "12-for-10 rate"), sized at purchase. Payment by card, or by bank transfer against a generated invoice (net 30); prepaid capacity activates when payment clears.
7.2 **No renewal of prepaid capacity; automatic transition to pay-as-you-go.** Prepaid capacity does not renew for another prepaid term. At purchase, you affirmatively consent to the following transition, which is disclosed before you pay: when the term ends, metered billing (Section 4) applies automatically at then-current rates so processing does not pause. We remind you thirty (30) days before term end; the reminder states the metered rates that will apply and how to cancel or revert to the free Editor plan (one click in Usage & Billing).
7.3 **Consumption; overage; expiry.** Capacity draws down in PDF Processes. If you exhaust capacity mid-term, you may top up at the same 12-for-10 rate, or metered billing applies at list rates (with a card on file) or processing pauses (without one). Unused capacity expires at term end and is not refunded and does not roll over.
7.4 **Cap.** Self-serve prepaid capacity is limited to 1,000,000 PDF Processes per year; larger commitments are available under a Stirling Enterprise Agreement.
## 8. AI features
AI features — classification, extraction, redaction-assist, and the Stirling Agent — are optional. They run only when you invoke a feature that uses them, and an administrator can disable them for the workspace; the rest of the Processor works without them. When used, they call machine-learning models from the providers listed at {{subprocessor_url}}. Currently: **Anthropic** (Claude models), which receives prompts and the document text or excerpts needed to perform the requested task; and **Voyage AI** (embedding models), which receives extracted text excerpts solely to generate embeddings when you enable Ingestion/RAG features. Only the document text or excerpts needed for the requested feature are sent — not your whole files — and only when that feature runs. AI charges are included in the price of whatever runs — there is no separate AI surcharge. Your content is not used to train models, by us or by these providers (verified against our signed provider agreements). AI outputs are probabilistic; review outputs before relying on them where accuracy has legal effect.
## 9. Evaluations and trials
Enterprise trials run fourteen (14) days, require no payment method, and are provided for evaluation only, AS IS, without service level commitments. Either party may end an evaluation at any time; on expiry your workspace continues on the free Editor plan.
## 10. Self-hosted and desktop software
10.1 **License.** We grant you a non-exclusive, non-transferable license to install and run the Editor and, with an active plan, the self-hosted Processor, for your internal business use. Open-source components remain under their own licenses.
10.2 **License validation and metering.** Self-hosted Processor deployments validate their license online and transmit usage metering data (process counts, file sizes, and file hashes for billing integrity, and diagnostic data — never file content or file names) to Stirling. File names used for unique PDF identification remain on your server and are not transmitted. Air-gapped deployments verify a signed activation bundle offline and reconcile usage periodically. You will not disable, circumvent, or falsify validation or metering. **The meter is the same regardless of where the software runs.**
10.3 **Updates.** Security patches and upgrades are made available for supported versions; some updates may install automatically per Terms of Service §5.
10.4 **Authorized users and administration.** "Authorized Users" are your employees, and the employees of your affiliates and contractors working on your behalf, whom you provision through your workspace. You are responsible for your users' credentials, your administrators' actions, and your users' compliance with this EULA. One workspace serves one legal entity and its affiliates; serving unrelated third parties requires a separate agreement. You may not redistribute the Processor or offer it as a hosted service to others. On termination or downgrade, self-hosted Processor licenses expire per the license mechanism; installed Editor copies remain usable under the free plan. We may verify license compliance through the validation mechanism in Section 10.2.
## 11. Fair use
Free-tier and flat-price features are subject to fair use: we may throttle or decline usage patterns that abuse free processing (for example, automation disguised as manual editing) after notice where practicable.
## 12. Changes to this EULA
We may update this EULA. Material changes take effect thirty (30) days after notice. Changes that materially increase your price or reduce your rights take effect at your next billing cycle or prepaid term start, or upon your affirmative acceptance — whichever comes first — except changes strictly necessary for legal compliance or security, which may take effect sooner with notice. Continued use after the effective date is acceptance. Version history is available at {{eula_url}}.
@@ -0,0 +1,38 @@
{
"subprocessorUrl": "https://www.stirlingpdf.com/legal/subprocessors",
"eulaUrl": "https://www.stirlingpdf.com/legal/eula",
"documents": {
"enterprise-agreement": {
"label": "SEA",
"displayName": "Stirling Enterprise Agreement",
"version": "0.9.1",
"effectiveDate": "2026-07-10",
"status": "draft",
"parts": ["msa.md", "@order-form", "dpa.md"]
},
"eula": {
"label": "EULA",
"displayName": "Stirling EULA & Commercial Terms",
"version": "1.0.0",
"effectiveDate": "2026-07-10",
"status": "draft",
"parts": ["eula.md"]
},
"sla": {
"label": "SLA",
"displayName": "Stirling SLA Exhibit",
"version": "1.0.0",
"effectiveDate": "2026-07-10",
"status": "draft",
"parts": ["sla.md"]
},
"subprocessors": {
"label": "SUBP",
"displayName": "Stirling Subprocessors",
"version": "1.0.0",
"effectiveDate": "2026-07-10",
"status": "draft",
"parts": ["subprocessors.md"]
}
}
}

Some files were not shown because too many files have changed in this diff Show More