Compare commits

..
247 changed files with 8530 additions and 6169 deletions
+3 -29
View File
@@ -1,34 +1,16 @@
# CI routing infra. Editing the top-level router (build.yml) or this filter
# config re-runs every area's jobs, so every job-gating filter below includes
# *ci. That makes a change to how jobs are dispatched actually exercise those
# jobs (self-testing), instead of a router edit only matching the project filter.
ci: &ci
- .github/workflows/build.yml
- .github/config/.files.yaml
build: &build
- *ci
- build.gradle
- app/(common|core|proprietary|saas)/build.gradle
- Taskfile.yml
- .taskfiles/backend.yml
- .github/workflows/check-licence.yml
openapi: &openapi
- *ci
- *build
- app/(common|core|proprietary|saas)/src/main/java/**
- .github/workflows/check-openapi.yml
docker-base: &docker-base
- docker/base/Dockerfile
# Dockerfiles only (base + embedded + unoserver). Gates the slow multi-arch
# (arm64) leg of the PR docker test build: arm64 is only rebuilt when a
# Dockerfile itself changes, not on every code PR.
dockerfiles: &dockerfiles
- docker/**/Dockerfile*
docker: &docker
- docker/embedded/Dockerfile
- docker/embedded/Dockerfile.fat
@@ -41,11 +23,13 @@ docker: &docker
- *docker-base
project: &project
- *ci
- app/(common|core|proprietary|saas)/src/(main|test)/java/**
- *build
- "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
- exampleYmlFiles/**
- gradle/**
- libs/**
- "testing/**/!(requirements*.txt|requirements*.in)*"
- *docker
- *docker-base
- gradle.properties
@@ -61,11 +45,8 @@ project: &project
- .taskfiles/docker.yml
- scripts/db-migration/**
- .github/workflows/db-migration-test.yml
- .github/workflows/docker-compose-tests.yml
- .github/workflows/test-build-docker.yml
frontend: &frontend
- *ci
- frontend/**
- .github/workflows/testdriver.yml
- testing/**
@@ -82,14 +63,10 @@ frontend: &frontend
- Taskfile.yml
- .taskfiles/frontend.yml
- .taskfiles/e2e.yml
- .github/workflows/frontend-validation.yml
- .github/workflows/e2e-stubbed.yml
- .github/workflows/e2e-live.yml
# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build
# job on changes to any of these.
tauri: &tauri
- *ci
- frontend/editor/src-tauri/**
- frontend/editor/src/desktop/**
- frontend/editor/tsconfig.desktop.vite.json
@@ -104,7 +81,6 @@ tauri: &tauri
# the engine validation job on changes to engine sources or to the Java
# tool surfaces it generates models from.
engine: &engine
- *ci
- engine/**
- app/(common|core|proprietary|saas)/src/main/java/**
- .github/workflows/ai-engine.yml
@@ -117,7 +93,6 @@ engine: &engine
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
generated-models: &generated-models
- *ci
- *openapi
- frontend/editor/scripts/generate-tool-api-types.mts
- frontend/editor/src/core/types/toolApiTypes.ts
@@ -140,7 +115,6 @@ licenses-backend: &licenses-backend
# Files that can affect premium / enterprise behaviour. Gate the enterprise
# Playwright job on changes to any of these on PRs.
proprietary: &proprietary
- *ci
- app/proprietary/**
- frontend/editor/src/proprietary/**
- frontend/editor/src/core/tests/enterprise/**
+8 -7
View File
@@ -29,7 +29,7 @@ jobs:
check-pr:
if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch'
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
outputs:
should_deploy: ${{ steps.decide.outputs.should_deploy }}
is_fork: ${{ steps.resolve.outputs.is_fork }}
@@ -39,7 +39,7 @@ jobs:
pr_ref: ${{ steps.resolve.outputs.ref }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -102,7 +102,7 @@ jobs:
deploy-v2-pr:
needs: [pick, check-pr]
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true')
# Concurrency control - only one deployment per PR at a time
concurrency:
@@ -114,7 +114,7 @@ jobs:
pull-requests: write
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
# Single source of truth for whether this preview embeds the admin portal:
# drives the image build-arg and the deployment comment.
@@ -122,7 +122,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -297,6 +297,7 @@ jobs:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
POLICIES_ENABLED: "true"
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
@@ -475,7 +476,7 @@ jobs:
cleanup-v2-deployment:
if: github.event.action == 'closed'
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
permissions:
contents: read
issues: write
@@ -483,7 +484,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -39,7 +39,7 @@ jobs:
check-comment:
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
permissions:
issues: write
if: |
@@ -74,7 +74,7 @@ jobs:
enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -180,18 +180,18 @@ jobs:
deploy-pr:
needs: [pick, check-comment]
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
permissions:
issues: write
pull-requests: write
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -511,10 +511,10 @@ jobs:
handle-label-commands:
if: ${{ github.event.issue.pull_request != null }}
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+18 -42
View File
@@ -5,11 +5,6 @@ name: _runner-pick
# can pick a runner class without each one duplicating the 200-char gate
# expression in their own `runs-on:`.
#
# It also owns the single Depot kill-switch (use_depot). Depot is currently
# disabled repo-wide; downstream jobs gate their Depot runner/build usage on
# use_depot so nothing has to be deleted to turn Depot off. Flip DEPOT_ENABLED
# in the decide step to switch Depot back on.
#
# Caller pattern:
#
# jobs:
@@ -18,15 +13,12 @@ name: _runner-pick
#
# real-work:
# needs: pick
# runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-8' || 'ubuntu-latest' }}
# runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
# steps: [...]
#
# Outputs:
# is_fork: "true" when the trigger is a pull_request from a fork or an
# untrusted author_association, "false" otherwise. Use this for
# trust gating (skipping secret-dependent jobs on forks).
# use_depot: "true" when downstream jobs should use Depot runners/builders.
# Currently forced "false" (Depot disabled repo-wide).
# Output:
# is_fork: "true" when the trigger is a pull_request from a fork or an
# untrusted author_association, "false" otherwise.
on:
workflow_call:
@@ -34,9 +26,6 @@ on:
is_fork:
description: '"true" if the trigger is an untrusted fork PR.'
value: ${{ jobs.pick.outputs.is_fork }}
use_depot:
description: '"true" when downstream jobs should use Depot. Currently forced off.'
value: ${{ jobs.pick.outputs.use_depot }}
permissions:
contents: read
@@ -47,10 +36,9 @@ jobs:
timeout-minutes: 1
outputs:
is_fork: ${{ steps.decide.outputs.is_fork }}
use_depot: ${{ steps.decide.outputs.use_depot }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -62,33 +50,21 @@ jobs:
AUTHOR_ASSOC: ${{ github.event.pull_request.author_association }}
run: |
set -eu
# Depot kill-switch. Depot is disabled repo-wide: no job uses Depot
# runners or the Depot build actions while this is false. All the
# Depot wiring is left in place - set DEPOT_ENABLED=true to switch it
# back on (it then activates on trusted, non-fork triggers as before).
DEPOT_ENABLED=false
if [ -z "${PR_NUMBER:-}" ]; then
# Not a pull_request event at all (push, schedule, workflow_dispatch,
# workflow_call from a non-PR trigger) -> trusted by default.
is_fork=false
elif [ "${HEAD_REPO_FORK}" = "true" ]; then
is_fork=true
else
case "${AUTHOR_ASSOC}" in
OWNER|MEMBER|COLLABORATOR) is_fork=false ;;
*) is_fork=true ;;
esac
echo "is_fork=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Depot only ever ran on trusted triggers, so gate it on both the
# kill-switch and is_fork.
if [ "${DEPOT_ENABLED}" = "true" ] && [ "${is_fork}" = "false" ]; then
use_depot=true
else
use_depot=false
if [ "${HEAD_REPO_FORK}" = "true" ]; then
echo "is_fork=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "is_fork=${is_fork}" >> "$GITHUB_OUTPUT"
echo "use_depot=${use_depot}" >> "$GITHUB_OUTPUT"
case "${AUTHOR_ASSOC}" in
OWNER|MEMBER|COLLABORATOR)
echo "is_fork=false" >> "$GITHUB_OUTPUT"
;;
*)
echo "is_fork=true" >> "$GITHUB_OUTPUT"
;;
esac
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -70,7 +70,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
build:
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-8' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
strategy:
@@ -34,7 +34,7 @@ jobs:
flavor: [core, proprietary, saas]
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
+2 -2
View File
@@ -54,7 +54,7 @@ jobs:
# (nor DEPOT_TOKEN), so the suite can't boot premium and would fail. See the
# header comment. GitHub reports the skipped reusable workflow as success.
if: needs.pick.outputs.is_fork != 'true'
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
timeout-minutes: 45
env:
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
@@ -63,7 +63,7 @@ jobs:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
+2 -11
View File
@@ -41,14 +41,13 @@ jobs:
openapi: ${{ steps.changes.outputs.openapi }}
frontend: ${{ steps.changes.outputs.frontend }}
docker-base: ${{ steps.changes.outputs.docker-base }}
dockerfiles: ${{ steps.changes.outputs.dockerfiles }}
tauri: ${{ steps.changes.outputs.tauri }}
engine: ${{ steps.changes.outputs.engine }}
generated-models: ${{ steps.changes.outputs.generated-models }}
proprietary: ${{ steps.changes.outputs.proprietary }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
@@ -154,7 +153,6 @@ jobs:
secrets: inherit
with:
docker-base-changed: ${{ needs.files-changed.outputs.docker-base }}
dockerfiles-changed: ${{ needs.files-changed.outputs.dockerfiles }}
tauri-build:
if: needs.files-changed.outputs.tauri == 'true'
@@ -164,13 +162,6 @@ jobs:
pull-requests: write
uses: ./.github/workflows/tauri-build.yml
secrets: inherit
# PR smoke build: Linux only (fastest + cheapest to compile), unsigned,
# deb-only, no AppImage. The full signed multi-OS matrix runs on release;
# nightly still warms the Rust cache with all-OS defaults.
with:
platform: linux
sign: false
minimal: true
ai-engine:
if: needs.files-changed.outputs.engine == 'true'
@@ -258,7 +249,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+2 -2
View File
@@ -15,12 +15,12 @@ jobs:
check-generate-openapi-docs:
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+2 -2
View File
@@ -34,11 +34,11 @@ jobs:
aggregate:
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
timeout-minutes: 15
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
+2 -2
View File
@@ -17,13 +17,13 @@ jobs:
migration-test:
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-8' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
timeout-minutes: 30
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+3 -3
View File
@@ -15,7 +15,7 @@ jobs:
deploy-v2-on-push:
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
concurrency:
group: deploy-v2-push-V2
cancel-in-progress: true
@@ -23,12 +23,12 @@ jobs:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
docker-compose-tests:
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '4') || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '4') }}
permissions:
actions: write
contents: read
@@ -36,7 +36,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+2 -2
View File
@@ -21,11 +21,11 @@ jobs:
playwright-e2e-live:
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
timeout-minutes: 30
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
+2 -2
View File
@@ -21,10 +21,10 @@ jobs:
playwright-e2e:
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
@@ -25,14 +25,14 @@ jobs:
files-changed:
name: detect what files changed
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
timeout-minutes: 3
outputs:
licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }}
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -49,14 +49,14 @@ jobs:
if: needs.files-changed.outputs.licenses-frontend == 'true'
name: Generate Frontend License Report
needs: [pick, files-changed]
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
permissions:
contents: write
pull-requests: write
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -320,7 +320,7 @@ jobs:
if: needs.files-changed.outputs.licenses-backend == 'true'
needs: [pick, files-changed]
name: Generate Backend License Report
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
permissions:
contents: write
pull-requests: write
@@ -329,7 +329,7 @@ jobs:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
frontend-validation:
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+7 -7
View File
@@ -42,13 +42,13 @@ jobs:
determine-matrix:
if: ${{ vars.CI_PROFILE != 'lite' }}
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -113,7 +113,7 @@ jobs:
build-jars:
needs: [pick, determine-matrix]
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
strategy:
@@ -133,7 +133,7 @@ jobs:
file_suffix: "-server"
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -198,7 +198,7 @@ jobs:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
allowed-endpoints: >
@@ -639,12 +639,12 @@ jobs:
collect-and-release:
needs: [pick, determine-matrix, build, build-jars]
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
permissions:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+2 -2
View File
@@ -19,10 +19,10 @@ jobs:
playwright-all-browsers:
name: Playwright (chromium + firefox + webkit)
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -79,7 +79,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+2 -2
View File
@@ -28,12 +28,12 @@ jobs:
push:
if: ${{ vars.CI_PROFILE != 'lite' }}
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+92
View File
@@ -0,0 +1,92 @@
name: Sync Portal Docs
# Regenerates the portal Developer Docs manifest from the Stirling docs repo and
# opens a PR when it changes. Runs weekly, on manual dispatch, or when the docs
# repo fires a `docs-updated` repository_dispatch.
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
inputs:
ref:
description: "Docs repo ref (branch or tag) to sync from"
required: false
default: "main"
repository_dispatch:
types: [docs-updated]
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions:
contents: read
jobs:
sync:
name: Sync docs manifest
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
env:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
run: npm ci --ignore-scripts --audit=false --fund=false
- name: Regenerate docs manifest
working-directory: frontend
env:
DOCS_REF: ${{ github.event.inputs.ref || github.event.client_payload.ref || 'main' }}
GITHUB_TOKEN: ${{ steps.setup-bot.outputs.token }}
run: npm run docs:sync
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Sync portal docs from docs repo"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync-portal-docs
base: main
title: "Sync portal docs from docs repo"
body: |
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot].
Regenerates `frontend/editor/src/portal/generated/docsManifest.json`
from the Stirling docs repo via `npm run docs:sync`.
labels: documentation,github-actions,frontend
add-paths: frontend/editor/src/portal/generated/docsManifest.json
delete-branch: true
sign-commits: true
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+13 -24
View File
@@ -21,11 +21,6 @@ on:
required: false
type: boolean
default: true
minimal:
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass. Used by PR builds."
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
platform:
@@ -43,11 +38,6 @@ on:
required: false
default: true
type: boolean
minimal:
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass."
required: false
default: false
type: boolean
permissions:
contents: read
@@ -61,7 +51,7 @@ jobs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -109,7 +99,7 @@ jobs:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -396,10 +386,10 @@ jobs:
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
# AppImage runs in its own continue-on-error step below so its
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
# Linux: build deb+rpm only here. AppImage runs in its own
# continue-on-error step below so its persistent linuxdeploy
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
- name: Build Tauri app (unsigned)
if: ${{ !inputs.sign }}
@@ -416,16 +406,15 @@ jobs:
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
# AppImage runs in its own continue-on-error step below so its
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
# Linux: build deb+rpm only here. AppImage runs in its own
# continue-on-error step below so its persistent linuxdeploy
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
# AppImage is decoupled so its linuxdeploy run gets a fresh process
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
# Skipped on minimal smoke builds (flaky + slow, deb is enough to verify).
- name: Build Tauri app (Linux AppImage)
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
if: matrix.platform == 'ubuntu-22.04'
continue-on-error: true
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
@@ -617,7 +606,7 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -708,7 +697,7 @@ jobs:
if: always()
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+7 -21
View File
@@ -12,11 +12,6 @@ on:
required: false
type: string
default: "false"
dockerfiles-changed:
description: "Whether any Dockerfile changed (forwarded from files-changed). Gates the slow arm64 build leg."
required: false
type: string
default: "false"
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
required: false
@@ -46,12 +41,12 @@ jobs:
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
test-build-docker-images:
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' && inputs.docker-base-changed != 'true' }}
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
strategy:
fail-fast: false
@@ -68,7 +63,7 @@ jobs:
cache-scope: stirling-pdf-fat
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -151,22 +146,13 @@ jobs:
# GITHUB_EVENT_NAME is already provided by the runner.
env:
DOCKER_BASE_CHANGED: ${{ inputs.docker-base-changed }}
DOCKERFILES_CHANGED: ${{ inputs.dockerfiles-changed }}
run: |
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$DOCKER_BASE_CHANGED" = "true" ]; then
# Base Dockerfile changed: build against the locally-built base,
# which only exists for amd64.
echo "base_image=stirling-pdf-base:pr-test" >> "$GITHUB_OUTPUT"
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
elif [ "$DOCKERFILES_CHANGED" = "true" ]; then
# A Dockerfile changed: also verify the arm64 build (slow QEMU leg).
else
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
else
# No Dockerfile change: amd64 only. arm64 is exercised on the base
# image publish and on release, not on every code PR.
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
fi
# Base-changed PRs build the embedded image with the local docker driver
@@ -228,16 +214,16 @@ jobs:
test-build-unoserver-image:
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' && inputs.docker-base-changed != 'true' }}
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+9 -9
View File
@@ -26,16 +26,16 @@ jobs:
deploy:
if: ${{ vars.CI_PROFILE != 'lite' }}
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -154,13 +154,13 @@ jobs:
if: always()
name: detect what files changed
needs: pick
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
timeout-minutes: 3
outputs:
frontend: ${{ steps.changes.outputs.frontend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -175,10 +175,10 @@ jobs:
test:
if: needs.files-changed.outputs.frontend == 'true'
needs: [pick, deploy, files-changed]
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
@@ -209,12 +209,12 @@ jobs:
cleanup:
needs: [pick, deploy, test]
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
if: always()
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
+4 -2
View File
@@ -26,6 +26,7 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
POLICIES_ENABLED: '{{.POLICIES_ENABLED}}'
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
@@ -40,12 +41,13 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
POLICIES_ENABLED: '{{.POLICIES_ENABLED | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
+3 -14
View File
@@ -203,19 +203,6 @@ tasks:
cmds:
- task: lint:eslint
- task: lint:dpdm
- task: lint:colors
lint:colors:
desc: "Enforce theme tokens — colours in core/theme route through the palette"
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs
contrast:
desc: "Report low-contrast theme token pairs (warning only, never blocks)"
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs contrast
lint:eslint:
desc: "Run ESLint linting"
@@ -262,8 +249,10 @@ tasks:
typecheck:_run:
internal: true
env:
CI: '{{ .CI | default "false" }}'
cmds:
- 'npx tsc --noEmit --project {{.PROJECT}}'
- '{{ if eq .CI "true" }}npx tsc{{ else }}npx tsgo{{ end }} --noEmit --project {{.PROJECT}}'
typecheck:core:
desc: "Typecheck core build variant"
-2
View File
@@ -155,8 +155,6 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/editor/src/core/theme/README.md — it explains the palette/`--c-*` token system and the rule that literal colours live only in `primitives.css`.
```typescript
// ✅ CORRECT - Use @app/* for all imports
import { AppLayout } from "@app/components/AppLayout";
+1
View File
@@ -90,6 +90,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:proprietary
vars:
PORT: '{{.EDITOR_PORT}}'
-12
View File
@@ -208,18 +208,6 @@
"moduleName": ".*",
"moduleLicense": "The W3C License"
},
{
"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"
@@ -206,6 +206,11 @@ public class ApplicationProperties {
@Data
public static class Policies {
/**
* Master switch for the policy + sources subsystem (the PAYG-metered automation surface).
*/
private boolean enabled = false;
/**
* Absolute directories that policy folder input sources and output sinks may read from or
* write to. Empty (the default) disables folder access entirely, so a policy can never be
-14
View File
@@ -66,20 +66,6 @@ dependencies {
implementation "com.google.code.gson:gson:${gsonVersion}"
// jinjava/jjwt transitively request older Jackson 2 versions; declare the current
// version directly so it is selected consistently (root build.gradle pins are the fallback).
runtimeOnly "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
runtimeOnly "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
implementation("com.hubspot.jinjava:jinjava:${jinjavaVersion}") {
// Compile-time-only annotation artifacts (class-retention annotations, not needed at
// runtime) whose declared licences (LGPL / none) fail the licence compatibility check.
exclude group: 'com.google.code.findbugs', module: 'annotations'
exclude group: 'org.derive4j', module: 'derive4j-annotation'
exclude group: 'com.hubspot.immutables', module: 'hubspot-style'
exclude group: 'com.hubspot.immutables', module: 'immutable-collection-encodings'
}
api 'io.micrometer:micrometer-registry-prometheus'
api "io.jsonwebtoken:jjwt-api:${jwtVersion}"
@@ -35,7 +35,6 @@ import stirling.software.proprietary.billing.ContentHasher;
import stirling.software.proprietary.billing.DocumentUnitCalculator;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
@@ -85,13 +84,7 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
instanceof ApiKeyAuthenticationToken;
BillingCategory category = BillableOperationClassifier.categorize(request, apiKey);
request.setAttribute(ATTR_CATEGORY, category);
// A policy run kicks off billable automation, so block it up front when unentitled
// rather than after its first tool. It carries no automation header itself (category
// BYPASSED), so it's gated here but metered only via its dispatched sub-steps - keeping
// the BYPASSED meter category avoids double-counting.
boolean billable =
category != BillingCategory.BYPASSED || PolicyRunRoutes.matches(request);
decision = gate.evaluate(billable);
decision = gate.evaluate(category != BillingCategory.BYPASSED);
} catch (RuntimeException e) {
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
// turn into a hard block on billable work.
@@ -1,61 +0,0 @@
package stirling.software.proprietary.classification;
import java.io.InputStream;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import tools.jackson.databind.ObjectMapper;
/**
* Supplies the classification vocabulary the classify tool sends to the AI engine. The set is a
* fixed, built-in list bundled with the application ({@code
* classification/classification-labels.json}) and shared by everyone — there is no per-team
* customization or database. Loaded once at startup.
*/
@Slf4j
@Component
public class ClassificationLabelProvider {
private static final String RESOURCE = "classification/classification-labels.json";
private final List<ClassificationLabel> labels;
// Explicit @Autowired: the class has a second (private) constructor for tests, so Spring
// can't infer which to use without it.
@Autowired
public ClassificationLabelProvider(ObjectMapper objectMapper) {
this(load(objectMapper));
}
private ClassificationLabelProvider(List<ClassificationLabel> labels) {
this.labels = List.copyOf(labels);
}
/** Build a provider with an explicit label set (tests). */
public static ClassificationLabelProvider withLabels(List<ClassificationLabel> labels) {
return new ClassificationLabelProvider(labels);
}
/** The built-in vocabulary, in file order. */
public List<ClassificationLabel> labels() {
return labels;
}
private static List<ClassificationLabel> load(ObjectMapper objectMapper) {
try (InputStream in = new ClassPathResource(RESOURCE).getInputStream()) {
ClassificationLabels parsed = objectMapper.readValue(in, ClassificationLabels.class);
return parsed.labels();
} catch (Exception e) {
log.error("Failed to load classification labels from {}", RESOURCE, e);
return List.of();
}
}
}
@@ -0,0 +1,136 @@
package stirling.software.proprietary.classification;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import stirling.software.proprietary.classification.model.LabelsValidator;
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
import stirling.software.proprietary.classification.store.TeamLabelsEntity;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Read/write the team's classification label set — the flat vocabulary the document classifier runs
* against. Shared and team-scoped exactly like policies: every user reads their own team's labels,
* and only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
* {@link PolicyManagementAuthority}) may change it — gated only when login is enabled, since
* single-user deployments trust the local operator. A team with no stored labels reads as {@code
* 204}; that team has no vocabulary, so its documents are not classified (there is no built-in
* default on the backend or the engine — the label data lives only in the frontend).
*/
@RestController
@RequestMapping("/api/v1/classification/labels")
@Hidden
@RequiredArgsConstructor
@Tag(name = "Classification", description = "Team-scoped document-classification labels")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class ClassificationLabelsController {
private final ClassificationLabelStore labelStore;
private final PolicyManagementAuthority policyManagementAuthority;
private final ApplicationProperties applicationProperties;
private final UserServiceInterface userService;
@GetMapping
@Operation(
summary = "Get the team's classification labels",
description =
"Returns the caller's team label set, or 204 when the team has none (its"
+ " documents are then not classified).")
public ResponseEntity<ClassificationLabels> getTeamLabels() {
return labelStore
.findByTeam(currentTeamId())
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.noContent().build());
}
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Save the team's classification labels",
description =
"Validates and stores the label set for the caller's team, shared by everyone"
+ " on the team. Requires the policy-editor role for the team.")
public ResponseEntity<ClassificationLabels> saveTeamLabels(
@RequestBody ClassificationLabels labels) {
requireEditingAllowed();
validate(labels);
ClassificationLabels saved = labelStore.save(currentTeamId(), labels, currentUsername());
return ResponseEntity.ok(saved);
}
@DeleteMapping
@Operation(
summary = "Reset the team's classification labels",
description =
"Removes the team's stored label set; its documents are then not classified"
+ " until labels are saved again. Requires the policy-editor role for the"
+ " team.")
public ResponseEntity<Void> resetTeamLabels() {
requireEditingAllowed();
labelStore.deleteByTeam(currentTeamId());
return ResponseEntity.noContent().build();
}
private static void validate(ClassificationLabels labels) {
try {
LabelsValidator.validate(labels);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
/**
* Editing the team labels requires the editor role for the caller's team — the same gate
* policies use (team leader on SaaS, global admin self-hosted). Single-user deployments (login
* disabled) have no such role, so they trust the local operator.
*/
private void requireEditingAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"The team classification labels may only be changed by a team leader");
}
}
/**
* The caller's team key. With login disabled the single operator owns the {@link
* TeamLabelsEntity#NO_TEAM} sentinel row; with login enabled a caller with no resolvable team
* is an error rather than being dropped into the shared sentinel bucket (which would let
* unteamed users read and overwrite each other's "team" labels).
*/
private Long currentTeamId() {
Long teamId = policyManagementAuthority.currentUserTeamId();
if (teamId != null) {
return teamId;
}
if (!applicationProperties.getSecurity().isEnableLogin()) {
return TeamLabelsEntity.NO_TEAM;
}
throw new ResponseStatusException(
HttpStatus.UNAUTHORIZED, "Could not resolve the current user's team");
}
private String currentUsername() {
return userService == null ? null : userService.getCurrentUsername();
}
}
@@ -3,10 +3,10 @@ package stirling.software.proprietary.classification.model;
import java.util.List;
/**
* A flat multi-label classification vocabulary — the set of labels a document may be assigned. The
* classifier runs against these label names. The vocabulary is a fixed, built-in set shared by
* everyone (see {@link stirling.software.proprietary.classification.ClassificationLabelProvider});
* this record is the JSON parse target for that bundled resource.
* A flat multi-label classification vocabulary — the set of labels a document may be assigned.
* Stored per team (admin-edited, shared by everyone on the team); the classifier runs against these
* label names. A team with no stored set has no vocabulary, so its documents are not classified —
* neither the backend nor the engine holds a default of its own.
*/
public record ClassificationLabels(List<ClassificationLabel> labels) {
@@ -0,0 +1,71 @@
package stirling.software.proprietary.classification.model;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Structural validation for a user- or admin-supplied label set, run before it is stored so a
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
* non-blank ids and names, each unique within the set (ids exactly, names case-insensitively).
*/
public final class LabelsValidator {
private LabelsValidator() {}
// Generous upper bounds so a legitimate label set is never blocked, but a single team or user
// can't store an unbounded blob that would bloat the row, balloon the classifier prompt, or
// exhaust memory on deserialize.
static final int MAX_LABELS = 500;
static final int MAX_TEXT_LENGTH = 128;
// Icon is a Material Symbols key (lowercase, digits, hyphens). Enforce the SHAPE server-side —
// the exact allowlist lives in the frontend — so a client bypassing the UI can't store
// arbitrary
// text that would render as garbage (or worse) in every teammate's sidebar.
private static final Pattern ICON_KEY = Pattern.compile("^[a-z0-9-]+$");
/**
* @throws IllegalArgumentException with a human-readable message when the label set is invalid.
*/
public static void validate(ClassificationLabels labels) {
if (labels == null || labels.labels() == null) {
throw new IllegalArgumentException("Labels are required");
}
if (labels.labels().size() > MAX_LABELS) {
throw new IllegalArgumentException("Too many labels (max " + MAX_LABELS + ")");
}
Set<String> ids = new HashSet<>();
Set<String> names = new HashSet<>();
for (ClassificationLabel label : labels.labels()) {
requireText(label.id(), "Label id");
requireText(label.name(), "Label name");
if (label.icon() != null && !label.icon().isEmpty()) {
if (label.icon().length() > MAX_TEXT_LENGTH) {
throw new IllegalArgumentException(
"Label icon is too long (max " + MAX_TEXT_LENGTH + " characters)");
}
if (!ICON_KEY.matcher(label.icon()).matches()) {
throw new IllegalArgumentException("Invalid label icon: " + label.icon());
}
}
if (!ids.add(label.id().trim())) {
throw new IllegalArgumentException("Duplicate label id: " + label.id());
}
if (!names.add(label.name().trim().toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException("Duplicate label name: " + label.name());
}
}
}
private static void requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
if (value.trim().length() > MAX_TEXT_LENGTH) {
throw new IllegalArgumentException(
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
}
}
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.classification.store;
import java.util.Optional;
import stirling.software.proprietary.classification.model.ClassificationLabels;
/**
* Stores one {@link ClassificationLabels} set per team. A {@code null} teamId addresses the
* unteamed set (login disabled / no resolvable team), mirroring how the policy store treats a null
* team.
*/
public interface ClassificationLabelStore {
/** The team's stored labels, or empty when it has none (callers then skip classification). */
Optional<ClassificationLabels> findByTeam(Long teamId);
/** Create or replace the team's labels. Returns the stored value. */
ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy);
/** Remove the team's labels (reset to default). Returns whether a set existed. */
boolean deleteByTeam(Long teamId);
}
@@ -0,0 +1,36 @@
package stirling.software.proprietary.classification.store;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import stirling.software.proprietary.classification.model.ClassificationLabels;
/**
* In-memory {@link ClassificationLabelStore} for tests and any future no-database mode. {@link
* JpaClassificationLabelStore} is the runtime bean.
*/
public class InProcessClassificationLabelStore implements ClassificationLabelStore {
private final Map<Long, ClassificationLabels> byTeam = new ConcurrentHashMap<>();
@Override
public Optional<ClassificationLabels> findByTeam(Long teamId) {
return Optional.ofNullable(byTeam.get(key(teamId)));
}
@Override
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
byTeam.put(key(teamId), labels);
return labels;
}
@Override
public boolean deleteByTeam(Long teamId) {
return byTeam.remove(key(teamId)) != null;
}
private static long key(Long teamId) {
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
}
}
@@ -0,0 +1,76 @@
package stirling.software.proprietary.classification.store;
import java.time.Instant;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* Durable {@link ClassificationLabelStore} backed by JPA; the runtime store. Gated on {@code
* policies.enabled} — stored labels only matter when the Classification policy can run — so it
* shares the policy subsystem's on/off switch. Each label set is persisted as JSON via {@link
* TeamLabelsEntity}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaClassificationLabelStore implements ClassificationLabelStore {
private final TeamLabelsRepository teamRepository;
private final ObjectMapper objectMapper;
@Override
public Optional<ClassificationLabels> findByTeam(Long teamId) {
return teamRepository
.findById(key(teamId))
.flatMap(entity -> parse(entity.getLabelsJson(), "team " + teamId));
}
@Override
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
TeamLabelsEntity entity = new TeamLabelsEntity();
entity.setTeamId(key(teamId));
entity.setLabelsJson(objectMapper.writeValueAsString(labels));
entity.setUpdatedAt(Instant.now());
entity.setUpdatedBy(updatedBy);
teamRepository.save(entity);
return labels;
}
@Override
public boolean deleteByTeam(Long teamId) {
long id = key(teamId);
if (!teamRepository.existsById(id)) {
return false;
}
teamRepository.deleteById(id);
return true;
}
private Optional<ClassificationLabels> parse(String json, String owner) {
try {
return Optional.of(objectMapper.readValue(json, ClassificationLabels.class));
} catch (JacksonException e) {
// A stored label set that no longer parses (corruption / manual DB edit) must not break
// classification: drop it so the caller treats the team as having no labels (and skips
// classification) rather than surfacing a 500 on every upload.
log.warn("Discarding unparseable stored labels for {}: {}", owner, e.getMessage());
return Optional.empty();
}
}
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
private static long key(Long teamId) {
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
}
}
@@ -0,0 +1,47 @@
package stirling.software.proprietary.classification.store;
import java.io.Serializable;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* JPA row for a team's classification labels — one row per team. The label set lives as JSON in
* {@code labelsJson} (authoritative on read). {@code teamId} is the natural key; the sentinel
* {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case,
* since a primary key can't be null (policies store a nullable {@code team_id}, but this table is
* keyed one-per-team). Kept decoupled from the security entities — {@code teamId} is a plain value,
* not a foreign key — so classification can be enabled or disabled without touching them.
*/
@Entity
@Table(name = "classification_labels")
@NoArgsConstructor
@Getter
@Setter
public class TeamLabelsEntity implements Serializable {
private static final long serialVersionUID = 1L;
/** Sentinel key for the unteamed label set (login disabled / no resolvable team). */
public static final long NO_TEAM = 0L;
@Id
@Column(name = "team_id")
private long teamId;
@Column(name = "labels_json", columnDefinition = "text")
private String labelsJson;
@Column(name = "updated_at")
private Instant updatedAt;
@Column(name = "updated_by")
private String updatedBy;
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.classification.store;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TeamLabelsRepository extends JpaRepository<TeamLabelsEntity, Long> {}
@@ -31,9 +31,10 @@ import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.classification.ClassificationLabelProvider;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
import stirling.software.proprietary.model.api.ai.AiPageText;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.PdfContentExtractor;
@@ -45,7 +46,7 @@ import tools.jackson.databind.node.ObjectNode;
* Dispatchable tool that classifies a PDF and writes the result into its metadata.
*
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
* engine to classify the document against the built-in label set, and stores the engine's JSON
* engine to classify the document against the caller's team label set, and stores the engine's JSON
* answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key
* {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct
* client use.
@@ -71,9 +72,13 @@ public class ClassifyLabelController {
private final UserServiceInterface userService;
/**
* The fixed, built-in vocabulary shared by everyone — see {@link ClassificationLabelProvider}.
* Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and
* team authority are gated on it. Null otherwise, in which case there are no team labels to
* classify against and the document is passed through unlabelled.
*/
private final ClassificationLabelProvider labelProvider;
private final ClassificationLabelStore labelStore;
private final PolicyManagementAuthority policyManagementAuthority;
public ClassifyLabelController(
CustomPDFDocumentFactory pdfDocumentFactory,
@@ -82,16 +87,18 @@ public class ClassifyLabelController {
PdfMetadataService pdfMetadataService,
AiEngineClient aiEngineClient,
ObjectMapper objectMapper,
ClassificationLabelProvider labelProvider,
@Autowired(required = false) UserServiceInterface userService) {
@Autowired(required = false) UserServiceInterface userService,
@Autowired(required = false) ClassificationLabelStore labelStore,
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.tempFileManager = tempFileManager;
this.pdfContentExtractor = pdfContentExtractor;
this.pdfMetadataService = pdfMetadataService;
this.aiEngineClient = aiEngineClient;
this.objectMapper = objectMapper;
this.labelProvider = labelProvider;
this.userService = userService;
this.labelStore = labelStore;
this.policyManagementAuthority = policyManagementAuthority;
}
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -109,9 +116,9 @@ public class ClassifyLabelController {
List<EngineLabel> allowed = resolveAllowedLabels();
if (allowed.isEmpty()) {
// No vocabulary to classify against: pass the file through unlabelled rather than
// ask the engine to classify against nothing.
log.debug("[classify-and-label] {} has no labels; skipping", fileName);
// No vocabulary to classify against (the team stored no labels): pass the file
// through unlabelled rather than ask the engine to classify against nothing.
log.debug("[classify-and-label] {} has no team labels; skipping", fileName);
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
}
@@ -168,13 +175,24 @@ public class ClassifyLabelController {
}
/**
* The built-in vocabulary as {@code {id, name}} pairs, de-duplicated by id. The engine shows
* the model the names and returns the ids (icons are presentational and never sent). The engine
* holds no default vocabulary of its own, so this bundled set is the only source.
* The allowed labels for the caller's team as {@code {id, name}} pairs, de-duplicated by id.
* The engine shows the model the names and returns the ids (icons are presentational and never
* sent). Returns an empty list — the caller then skips classification — when the policy
* subsystem is disabled (no store) or the team has no stored labels. The engine holds no
* default vocabulary of its own, so a team's stored labels are the only source.
*/
private List<EngineLabel> resolveAllowedLabels() {
if (labelStore == null) {
return List.of();
}
Long teamId =
policyManagementAuthority == null
? null
: policyManagementAuthority.currentUserTeamId();
Map<String, EngineLabel> byId = new LinkedHashMap<>();
collectLabels(labelProvider.labels(), byId);
labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId));
return List.copyOf(byId.values());
}
@@ -8,14 +8,12 @@ import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Hidden;
@@ -26,24 +24,18 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.model.api.ai.create.AiDocument;
import stirling.software.proprietary.service.AiDocumentHtmlRenderer;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* Dispatchable tool that converts an AI-generated document model to a PDF via WeasyPrint.
* Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint.
*
* <p>Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The engine supplies the document as
* structured fields; the HTML is built here from a fixed template.
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
* template so sanitization is intentionally skipped.
*/
@Slf4j
@Hidden
@@ -56,9 +48,6 @@ public class CreatePdfAgentController {
private final TempFileManager tempFileManager;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final AiDocumentHtmlRenderer htmlRenderer;
/**
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
@@ -85,42 +74,32 @@ public class CreatePdfAgentController {
value = "/create-pdf-from-html-agent",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Convert an AI-generated document to a PDF",
summary = "Convert AI-generated HTML to a PDF",
description =
"Accepts a structured document as a JSON parameter and returns a PDF. This"
+ " endpoint is dispatched by the AI workflow orchestrator as a plan"
+ " step; it is not intended for direct client use.")
public ResponseEntity<Resource> createPdf(
@RequestParam("document") String document, @RequestParam("filename") String filename)
"Accepts an HTML document as a plain-text parameter and returns a PDF."
+ " This endpoint is dispatched by the AI workflow orchestrator as a"
+ " plan step; it is not intended for direct client use.")
public ResponseEntity<Resource> createPdfFromHtml(
@RequestParam("htmlContent") String htmlContent,
@RequestParam("filename") String filename)
throws Exception {
if (!applicationProperties.getAiEngine().isEnabled()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
AiDocument model;
try {
model = objectMapper.readValue(document, AiDocument.class);
} catch (JacksonException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
String html = htmlRenderer.render(model);
log.info(
"[create-pdf-agent] converting document to PDF via WeasyPrint — html_bytes={}",
html.length());
"[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
htmlContent.length());
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
Files.writeString(htmlFile.getPath(), html, StandardCharsets.UTF_8);
Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getWeasyPrintPath());
command.add("-e");
command.add("utf-8");
command.add("-v");
// SSRF: the HTML is self-contained and the engine validates style colours, so no
// external url() reaches WeasyPrint. For full isolation, run it network-isolated.
command.add(htmlFile.getAbsolutePath());
command.add(pdfFile.getAbsolutePath());
@@ -147,8 +126,8 @@ public class CreatePdfAgentController {
// avoids materialising the whole document as a byte[] twice (read-all + re-serialise),
// which matters for large generated documents.
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
try (PDDocument pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) {
pdDocument.save(tempOut.getPath().toFile());
try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
document.save(tempOut.getPath().toFile());
} catch (Exception e) {
tempOut.close();
throw e;
@@ -1,35 +0,0 @@
package stirling.software.proprietary.integration.crypto;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* For columns that were once whole-blob encrypted but no longer hold secrets.
*
* <p>Writes plaintext, so the value is readable by any instance regardless of the per-installation
* encryption key. Any value that isn't our ciphertext (already-plaintext JSON, or ciphertext from a
* key we don't hold) is returned as-is; the latter is the caller's to reject.
*/
@Converter
public class LegacyDecryptStringConverter implements AttributeConverter<String, String> {
@Override
public String convertToDatabaseColumn(String attribute) {
return attribute;
}
@Override
public String convertToEntityAttribute(String dbData) {
// Plaintext JSON can never be our Base64 ciphertext ('{' is not in the Base64 alphabet), so
// skip the decrypt attempt for it.
if (dbData == null || dbData.stripLeading().startsWith("{")) {
return dbData;
}
try {
return CredentialEncryption.decrypt(dbData);
} catch (IllegalArgumentException | IllegalStateException e) {
// Legacy ciphertext we can't read (key we don't hold) - the caller's to reject.
return dbData;
}
}
}
@@ -0,0 +1,31 @@
package stirling.software.proprietary.integration.crypto;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* {@link EncryptedStringConverter} for columns that held plaintext before encryption shipped:
* writes are always encrypted, but a stored value that is not valid ciphertext is returned as-is,
* so pre-encryption rows keep loading and become encrypted on their next save. The discrimination
* is exact for JSON payloads, which can never be mistaken for ciphertext ('{' is not in the Base64
* alphabet). The trade-off is that a genuinely corrupted ciphertext surfaces as garbage to the
* caller's parser instead of failing here.
*/
@Converter
public class LenientEncryptedStringConverter implements AttributeConverter<String, String> {
@Override
public String convertToDatabaseColumn(String attribute) {
return CredentialEncryption.encrypt(attribute);
}
@Override
public String convertToEntityAttribute(String dbData) {
try {
return CredentialEncryption.decrypt(dbData);
} catch (IllegalArgumentException | IllegalStateException e) {
// Not ciphertext: legacy plaintext from before encryption shipped.
return dbData;
}
}
}
@@ -1,35 +0,0 @@
package stirling.software.proprietary.model.api.ai.create;
import java.util.List;
import lombok.Data;
@Data
public class AiDocument {
private String title;
private String subtitle;
private String referenceNumber;
private Style style;
private List<Section> sections;
@Data
public static class Style {
private String primaryColor;
private String backgroundColor;
private String bodyTextColor;
}
@Data
public static class Section {
private String type;
private String heading;
private String body;
private List<List<String>> pairs;
private List<String> columns;
private List<List<String>> rows;
private List<String> totalRow;
private List<String> items;
private List<String> signatories;
}
}
@@ -6,6 +6,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
@@ -29,6 +30,7 @@ import stirling.software.proprietary.policy.source.SourceStore;
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
*/
@Component
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderAccessGuard {
public static final String FOLDER_TYPE = "folder";
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.config;
import java.util.List;
import java.util.Objects;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -22,6 +23,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyAccessGuard {
private final UserServiceInterface userService;
@@ -7,6 +7,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
@@ -84,6 +85,7 @@ import stirling.software.proprietary.util.SecretMasker;
@Hidden
@RequiredArgsConstructor
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyController {
private final PolicyRunner policyRunner;
@@ -1,85 +0,0 @@
package stirling.software.proprietary.policy.controller;
import org.springframework.web.servlet.HandlerMapping;
import jakarta.servlet.http.HttpServletRequest;
/**
* Policy execute-route namespace under {@code /api/v1/policies} - the paths that actually run an
* automation ({@code /run}, {@code /run/stream}, {@code /{id}/run}, {@code /{id}/trigger}).
*
* <p>Single source of truth for both PAYG entitlement gates, so a caller without billing is blocked
* at the start of a run rather than partway through: the saas {@code EntitlementGuard} gates these
* on {@code FeatureGate.AUTOMATION}, and the self-hosted account-link {@code
* InstanceEntitlementInterceptor} treats them as billable. Read/list policy endpoints are
* deliberately excluded so the UI can still show policies and prompt on use.
*
* <p>This is the sole gate between an unentitled caller and a billable run, so the match is exact
* (not a loose suffix) and segment-anchored. {@code PolicyRunRoutesTest} asserts it against every
* mapping on {@code PolicyController}, so a new execute route that isn't classified here fails the
* build rather than silently running for free.
*/
public final class PolicyRunRoutes {
private static final String BASE = "/api/v1/policies";
private PolicyRunRoutes() {}
/**
* True when the request resolved to a policy execute endpoint. Prefers the matched route
* pattern (context-path independent, set by Spring MVC) and falls back to the raw request URI.
*/
public static boolean matches(HttpServletRequest request) {
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
String path = pattern instanceof String s ? s : request.getRequestURI();
String rel = relativeToBase(path);
return rel != null && isExecuteRoute(rel);
}
/**
* The path relative to {@code /api/v1/policies}, or null if the request isn't under that base.
* Segment-anchored (the char after the base must be {@code /} or end-of-string) so a sibling
* like {@code /api/v1/policies-x/...} never matches; tolerates a leading context path.
*/
private static String relativeToBase(String path) {
if (path == null) {
return null;
}
int base = path.indexOf(BASE);
if (base < 0) {
return null;
}
int end = base + BASE.length();
if (end < path.length() && path.charAt(end) != '/') {
return null;
}
return path.substring(end);
}
/**
* The execute routes only: {@code /run}, {@code /run/stream}, and the single-segment {@code
* /{id}/run} / {@code /{id}/trigger} (template or concrete id). Read/list/CRUD routes - {@code
* /run/{runId}}, {@code /runs}, {@code /overview}, {@code /triggers}, {@code /{id}}, {@code
* /order}, {@code /{id}/processed-history}, the base list/create - are all excluded.
*/
private static boolean isExecuteRoute(String rel) {
return rel.equals("/run")
|| rel.equals("/run/stream")
|| isSingleIdRoute(rel, "run")
|| isSingleIdRoute(rel, "trigger");
}
/**
* True for exactly {@code /{oneSegment}/<verb>} (the id being a template or a concrete value).
*/
private static boolean isSingleIdRoute(String rel, String verb) {
String suffix = "/" + verb;
if (!rel.endsWith(suffix)) {
return false;
}
String idSegment = rel.substring(0, rel.length() - suffix.length());
return idSegment.length() > 1
&& idSegment.charAt(0) == '/'
&& idSegment.indexOf('/', 1) < 0;
}
}
@@ -9,6 +9,7 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import org.slf4j.MDC;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -56,6 +57,7 @@ import stirling.software.proprietary.service.DownstreamEntitlementError;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyEngine {
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
@@ -9,6 +9,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
@@ -28,6 +29,7 @@ import stirling.software.proprietary.policy.model.PolicyRun;
*/
@Slf4j
@Service
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyRunRegistry {
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();
@@ -5,6 +5,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -34,6 +35,7 @@ import stirling.software.proprietary.policy.source.SourceStore;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyRunner {
private final PolicyEngine policyEngine;
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.engine;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -24,6 +25,7 @@ import stirling.software.proprietary.policy.trigger.PolicyTrigger;
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyValidator {
private final List<PolicyTrigger> triggers;
@@ -14,6 +14,7 @@ import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Stream;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@@ -41,6 +42,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderInputSource implements InputSource {
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -6,6 +6,7 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.AbstractResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@@ -48,6 +49,7 @@ import software.amazon.awssdk.services.s3.model.S3Object;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3InputSource implements InputSource {
private static final String TYPE = "s3";
@@ -7,6 +7,7 @@ import java.util.Map;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.dao.DataIntegrityViolationException;
@@ -23,6 +24,7 @@ import lombok.extern.slf4j.Slf4j;
*/
@Slf4j
@Service
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaProcessedLedger implements ProcessedLedger {
private static final int STAMP_CHUNK = 500;
@@ -15,6 +15,7 @@ import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -40,6 +41,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderOutputSink implements PolicyOutputSink {
static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -5,6 +5,7 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -22,6 +23,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class InlineOutputSink implements PolicyOutputSink {
private static final String TYPE = "inline";
@@ -13,6 +13,7 @@ import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -53,6 +54,7 @@ import software.amazon.awssdk.services.s3.model.S3Exception;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3OutputSink implements PolicyOutputSink {
private static final String TYPE = "s3";
@@ -5,6 +5,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -28,6 +29,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyOverviewService {
private final PolicyStore policyStore;
@@ -4,6 +4,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@@ -31,8 +32,8 @@ import tools.jackson.databind.ObjectMapper;
* One-time, idempotent extraction of legacy embedded S3 credentials into stored connections:
* sources and policy outputs written before connections shipped carry bucket/credentials in their
* own options; this rewrites each to reference a (deduplicated) S3 {@link IntegrationConfig} and
* keeps only per-use options (prefix, mode). MUST be programmatic - it parses and rewrites the
* option JSON (and decrypts any legacy ciphertext row on read), which no SQL migration can do.
* keeps only per-use options (prefix, mode). MUST be programmatic - the option JSON is encrypted at
* the application layer, so no SQL migration can read it.
*
* <p>Idempotent by construction: rewritten rows no longer embed credentials, so re-runs find
* nothing to do. Connections are deduplicated against both this run's extractions and existing S3
@@ -43,6 +44,7 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class EmbeddedS3CredentialMigration {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@@ -4,6 +4,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -22,6 +23,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck {
private final SourceStore sourceStore;
@@ -6,6 +6,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
@@ -31,6 +32,7 @@ import software.amazon.awssdk.services.s3.S3Configuration;
* users rather than the operator.
*/
@Service
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3ConnectionPool {
private final ApplicationProperties applicationProperties;
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.s3;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
@@ -38,6 +39,7 @@ import tools.jackson.databind.ObjectMapper;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3ConnectionResolver {
static final String CONNECTION_ID_OPTION = "connectionId";
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.s3;
import java.net.URI;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -20,6 +21,7 @@ import stirling.software.proprietary.integration.service.IntegrationConfigValida
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3IntegrationValidator implements IntegrationConfigValidator {
private final ApplicationProperties applicationProperties;
@@ -10,6 +10,7 @@ import java.util.function.IntSupplier;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@@ -23,6 +24,7 @@ import org.springframework.stereotype.Service;
* table stays bounded (~one row per source per active hour, for at most 30 days).
*/
@Service
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaSourceDocCounter implements SourceDocCounter {
private final SourceDocCountRepository countRepository;
@@ -4,10 +4,10 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import tools.jackson.databind.ObjectMapper;
@@ -15,9 +15,9 @@ import tools.jackson.databind.ObjectMapper;
* Durable {@link SourceStore} backed by JPA; the runtime store. Sources are persisted as JSON via
* {@link SourceEntity}, with scalar columns kept in sync for querying.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaSourceStore implements SourceStore {
private final SourceRepository repository;
@@ -53,20 +53,17 @@ public class JpaSourceStore implements SourceStore {
@Override
public Optional<Source> get(String id) {
return repository.findById(id).flatMap(this::toSource);
return repository.findById(id).map(this::toSource);
}
@Override
public List<Source> all() {
return repository.findAll().stream().map(this::toSource).flatMap(Optional::stream).toList();
return repository.findAll().stream().map(this::toSource).toList();
}
@Override
public List<Source> findByTeam(Long teamId) {
return repository.findByTeam(teamId).stream()
.map(this::toSource)
.flatMap(Optional::stream)
.toList();
return repository.findByTeam(teamId).stream().map(this::toSource).toList();
}
@Override
@@ -78,20 +75,7 @@ public class JpaSourceStore implements SourceStore {
return true;
}
// Skip (don't fail) rows whose JSON can't be read - e.g. written by another app version/key.
// One unreadable row must never abort a bulk read or crash startup.
private Optional<Source> toSource(SourceEntity entity) {
try {
return Optional.of(objectMapper.readValue(entity.getSourceJson(), Source.class));
} catch (Exception e) {
log.error(
"Skipping unreadable policy source id={} name={}: stored JSON could not be"
+ " parsed ({}). Likely written by a different app version or"
+ " encryption key.",
entity.getId(),
entity.getName(),
e.getMessage());
return Optional.empty();
}
private Source toSource(SourceEntity entity) {
return objectMapper.readValue(entity.getSourceJson(), Source.class);
}
}
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Objects;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -19,6 +20,7 @@ import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceAccessGuard {
private final UserServiceInterface userService;
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -42,6 +43,7 @@ import stirling.software.proprietary.util.SecretMasker;
@Hidden
@RequiredArgsConstructor
@Tag(name = "Sources", description = "Reusable policy input connections")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceController {
private final SourceStore sourceStore;
@@ -12,7 +12,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.integration.crypto.LegacyDecryptStringConverter;
import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter;
/**
* JPA row for a {@link Source}. The whole source lives as JSON in {@code sourceJson} (authoritative
@@ -48,9 +48,9 @@ public class SourceEntity implements Serializable {
@Column(name = "enabled")
private boolean enabled;
// Plaintext at rest: the S3 credentials that used to live here now sit in a referenced
// IntegrationConfig connection (still encrypted). Decrypts legacy ciphertext on read.
@Convert(converter = LegacyDecryptStringConverter.class)
// Encrypted at rest: source options carry user-supplied credentials (e.g. an S3 secret
// access key). Lenient so rows written before encryption shipped still load.
@Convert(converter = LenientEncryptedStringConverter.class)
@Column(name = "source_json", columnDefinition = "text")
private String sourceJson;
}
@@ -6,6 +6,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -23,6 +24,7 @@ import stirling.software.proprietary.util.SecretMasker;
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceOverviewService {
private final SourceStore sourceStore;
@@ -5,11 +5,11 @@ import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.policy.model.Policy;
@@ -19,9 +19,9 @@ import tools.jackson.databind.ObjectMapper;
* Durable {@link PolicyStore} backed by JPA; the runtime store. Policies are persisted as JSON via
* {@link PolicyEntity}, with scalar columns kept in sync for querying.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaPolicyStore implements PolicyStore {
private final PolicyRepository repository;
@@ -98,30 +98,23 @@ public class JpaPolicyStore implements PolicyStore {
@Override
public Optional<Policy> get(String id) {
return repository.findById(id).flatMap(this::toPolicy);
return repository.findById(id).map(this::toPolicy);
}
@Override
public List<Policy> all() {
return repository.findAllOrdered().stream()
.map(this::toPolicy)
.flatMap(Optional::stream)
.toList();
return repository.findAllOrdered().stream().map(this::toPolicy).toList();
}
@Override
public List<Policy> findByTeam(Long teamId) {
return repository.findByTeam(teamId).stream()
.map(this::toPolicy)
.flatMap(Optional::stream)
.toList();
return repository.findByTeam(teamId).stream().map(this::toPolicy).toList();
}
@Override
public List<Policy> findByTriggerType(String triggerType) {
return repository.findByTriggerTypeAndEnabledTrue(triggerType).stream()
.map(this::toPolicy)
.flatMap(Optional::stream)
.toList();
}
@@ -134,19 +127,7 @@ public class JpaPolicyStore implements PolicyStore {
return true;
}
// Skip (don't fail) rows whose JSON can't be read - e.g. written by another app version/key.
// One unreadable row must never abort a bulk read or crash startup.
private Optional<Policy> toPolicy(PolicyEntity entity) {
try {
return Optional.of(objectMapper.readValue(entity.getPolicyJson(), Policy.class));
} catch (Exception e) {
log.error(
"Skipping unreadable policy id={} name={}: stored JSON could not be parsed"
+ " ({}). Likely written by a different app version or encryption key.",
entity.getId(),
entity.getName(),
e.getMessage());
return Optional.empty();
}
private Policy toPolicy(PolicyEntity entity) {
return objectMapper.readValue(entity.getPolicyJson(), Policy.class);
}
}
@@ -12,7 +12,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.integration.crypto.LegacyDecryptStringConverter;
import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter;
/**
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives
@@ -58,9 +58,9 @@ public class PolicyEntity implements Serializable {
@Column(name = "sort_order")
private Integer sortOrder;
// Plaintext at rest: the S3 credentials that used to live here now sit in a referenced
// IntegrationConfig connection (still encrypted). Decrypts legacy ciphertext on read.
@Convert(converter = LegacyDecryptStringConverter.class)
// Encrypted at rest: output options carry user-supplied credentials (e.g. an S3 secret
// access key). Lenient so rows written before encryption shipped still load.
@Convert(converter = LenientEncryptedStringConverter.class)
@Column(name = "policy_json", columnDefinition = "text")
private String policyJson;
}
@@ -20,6 +20,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -49,6 +50,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderWatchTrigger implements PolicyTrigger {
private static final String TYPE = "folder-watch";
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.trigger;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Service;
@@ -12,6 +13,7 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyTriggerManager implements SmartLifecycle {
private final List<PolicyTrigger> triggers;
@@ -10,6 +10,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -31,6 +32,7 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class ScheduleTrigger implements PolicyTrigger {
private static final String TYPE = "schedule";
@@ -37,7 +37,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.ledger",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.repository",
"stirling.software.proprietary.integration.repository"
"stirling.software.proprietary.integration.repository",
"stirling.software.proprietary.classification.store"
})
@EntityScan({
"stirling.software.proprietary.security.model",
@@ -49,7 +50,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.ledger",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.model",
"stirling.software.proprietary.integration.model"
"stirling.software.proprietary.integration.model",
"stirling.software.proprietary.classification.store"
})
public class DatabaseConfig {
@@ -1,135 +0,0 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import com.hubspot.jinjava.Jinjava;
import com.hubspot.jinjava.JinjavaConfig;
import stirling.software.proprietary.model.api.ai.create.AiDocument;
/** Renders an {@link AiDocument} to HTML using a Jinja template loaded from the classpath. */
@Component
public class AiDocumentHtmlRenderer {
private static final String TEMPLATE_PATH = "templates/ai/create/document.html.jinja2";
private static final Pattern SAFE_COLOR = Pattern.compile("^#[0-9a-fA-F]{6}$");
private final Jinjava jinjava;
private final String template;
public AiDocumentHtmlRenderer() {
JinjavaConfig config =
JinjavaConfig.newBuilder().withNestedInterpretationEnabled(false).build();
this.jinjava = new Jinjava(config);
this.template = loadTemplate();
}
public String render(AiDocument doc) {
return jinjava.render(template, buildContext(doc));
}
private static Map<String, Object> buildContext(AiDocument doc) {
Map<String, Object> context = new LinkedHashMap<>();
context.put("title", doc.getTitle());
context.put("subtitle", doc.getSubtitle());
context.put("reference_number", doc.getReferenceNumber());
AiDocument.Style style = doc.getStyle();
if (style != null) {
context.put("style_primary", safeColor(style.getPrimaryColor()));
context.put("style_background", safeColor(style.getBackgroundColor()));
context.put("style_body", safeColor(style.getBodyTextColor()));
}
List<Map<String, Object>> sections = new ArrayList<>();
if (doc.getSections() != null) {
for (AiDocument.Section section : doc.getSections()) {
if (section != null && section.getType() != null) {
sections.add(buildSection(section));
}
}
}
context.put("sections", sections);
return context;
}
private static Map<String, Object> buildSection(AiDocument.Section section) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("type", section.getType());
node.put("heading", section.getHeading());
switch (section.getType()) {
case "text" -> node.put("paragraphs", paragraphs(section.getBody()));
case "key_value" -> node.put("pairs", pairs(section.getPairs()));
case "line_items" -> {
node.put("columns", orEmpty(section.getColumns()));
node.put("rows", orEmptyRows(section.getRows()));
node.put("total_row", emptyToNull(section.getTotalRow()));
}
case "bullet_list" -> node.put("items", orEmpty(section.getItems()));
case "signature" -> node.put("signatories", orEmpty(section.getSignatories()));
default -> {}
}
return node;
}
private static List<String> paragraphs(String body) {
String text = body == null ? "" : body;
List<String> out = new ArrayList<>();
for (String paragraph : text.split("\n\n")) {
out.add(paragraph.replace("\n", " "));
}
return out;
}
private static List<Map<String, String>> pairs(List<List<String>> pairs) {
List<Map<String, String>> out = new ArrayList<>();
if (pairs != null) {
for (List<String> pair : pairs) {
Map<String, String> node = new LinkedHashMap<>();
node.put("label", pair.isEmpty() ? "" : pair.get(0));
node.put("value", pair.size() < 2 ? "" : pair.get(1));
out.add(node);
}
}
return out;
}
private static List<String> orEmpty(List<String> values) {
return values == null ? List.of() : values;
}
private static List<List<String>> orEmptyRows(List<List<String>> rows) {
return rows == null ? List.of() : rows;
}
private static List<String> emptyToNull(List<String> values) {
return values == null || values.isEmpty() ? null : values;
}
private static String safeColor(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return SAFE_COLOR.matcher(trimmed).matches() ? trimmed : null;
}
private static String loadTemplate() {
try {
return new ClassPathResource(TEMPLATE_PATH).getContentAsString(StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -155,43 +155,6 @@ class InstanceEntitlementInterceptorTest {
verifyNoInteractions(entitlementCache);
}
@Test
void gatesPolicyRunUpFrontEvenWithoutAutomationHeader() throws Exception {
// The policy /run call carries no automation header, but must be blocked up front (not
// after its first tool) when the instance is unlinked.
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.block(GateDecision.Reason.NOT_LINKED));
InstanceEntitlementInterceptor interceptor = interceptor();
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/api/v1/policies/pol-1/run");
MockHttpServletResponse resp = new MockHttpServletResponse();
assertFalse(interceptor.preHandle(req, resp, new Object()));
assertEquals(HttpStatus.PAYMENT_REQUIRED.value(), resp.getStatus());
assertTrue(resp.getContentAsString().contains("ACCOUNT_LINK_REQUIRED"));
verify(gate).evaluate(true); // gated as billable despite no automation header
}
@Test
void doesNotMeterThePolicyRunEndpointItself() throws Exception {
// Gated up front, but metered only via its dispatched tool sub-steps (category BYPASSED
// here), so the /run request itself never accrues usage.
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
UsageMeterService meter = mock(UsageMeterService.class);
when(meterProvider.getIfAvailable()).thenReturn(meter);
InstanceEntitlementInterceptor interceptor = interceptor();
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/api/v1/policies/pol-1/run");
MockHttpServletResponse resp = new MockHttpServletResponse();
interceptor.preHandle(req, resp, new Object());
interceptor.afterCompletion(req, resp, new Object(), null);
verifyNoInteractions(meter);
}
private static InstanceEntitlement entitled(UnitCalcPolicy policy, LocalDateTime period) {
return new InstanceEntitlement(
true, 0, 0, 100L, EntitlementState.OK, policy, period, period.plusMonths(1));
@@ -0,0 +1,133 @@
package stirling.software.proprietary.classification;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
@ExtendWith(MockitoExtension.class)
@DisplayName("ClassificationLabelsController")
class ClassificationLabelsControllerTest {
private static final Long TEAM = 7L;
@Mock private PolicyManagementAuthority policyManagementAuthority;
@Mock private UserServiceInterface userService;
private ClassificationLabelStore store;
private ApplicationProperties applicationProperties;
private ClassificationLabelsController controller;
@BeforeEach
void setUp() {
store = new InProcessClassificationLabelStore();
applicationProperties = new ApplicationProperties();
controller =
new ClassificationLabelsController(
store, policyManagementAuthority, applicationProperties, userService);
}
private static ClassificationLabels sample() {
return new ClassificationLabels(
List.of(
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
new ClassificationLabel("contract", "Contract", null)));
}
private void loginEnabled(boolean enabled) {
applicationProperties.getSecurity().setEnableLogin(enabled);
}
@Test
@DisplayName("GET returns 204 when the team has no labels")
void getEmpty() {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
ResponseEntity<ClassificationLabels> response = controller.getTeamLabels();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
@DisplayName("PUT then GET round-trips the team's labels (login disabled)")
void saveThenGet() {
loginEnabled(false);
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
controller.saveTeamLabels(sample());
ResponseEntity<ClassificationLabels> got = controller.getTeamLabels();
assertThat(got.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(got.getBody()).isNotNull();
assertThat(got.getBody().labels()).hasSize(2);
assertThat(got.getBody().labels().getFirst().name()).isEqualTo("Invoice");
assertThat(got.getBody().labels().getFirst().icon()).isEqualTo("receipt-long");
}
@Test
@DisplayName("PUT is scoped per team")
void perTeam() {
loginEnabled(false);
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
controller.saveTeamLabels(sample());
when(policyManagementAuthority.currentUserTeamId()).thenReturn(99L);
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
@DisplayName("PUT is rejected for a non-editor when login is enabled")
void putForbiddenForNonEditor() {
loginEnabled(true);
when(policyManagementAuthority.canEditPolicies()).thenReturn(false);
assertThatThrownBy(() -> controller.saveTeamLabels(sample()))
.isInstanceOf(ResponseStatusException.class)
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.FORBIDDEN);
}
@Test
@DisplayName("PUT rejects an invalid label set with 400")
void putInvalid() {
loginEnabled(false);
ClassificationLabels duplicate =
new ClassificationLabels(
List.of(
new ClassificationLabel("invoice", "Invoice", null),
new ClassificationLabel("invoice", "Invoice", null)));
assertThatThrownBy(() -> controller.saveTeamLabels(duplicate))
.isInstanceOf(ResponseStatusException.class)
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("DELETE resets the team back to no stored labels")
void deleteResets() {
loginEnabled(false);
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
controller.saveTeamLabels(sample());
ResponseEntity<Void> response = controller.resetTeamLabels();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}
@@ -0,0 +1,146 @@
package stirling.software.proprietary.classification.model;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import java.util.Locale;
import java.util.stream.IntStream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@DisplayName("LabelsValidator")
class LabelsValidatorTest {
private static ClassificationLabels labels(ClassificationLabel... labels) {
return new ClassificationLabels(List.of(labels));
}
private static ClassificationLabel label(String name) {
return new ClassificationLabel(slug(name), name, null);
}
private static String slug(String name) {
return name.trim()
.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
}
@Test
@DisplayName("accepts a well-formed label set")
void acceptsValid() {
ClassificationLabels set =
labels(
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
label("Contract"));
assertThatCode(() -> LabelsValidator.validate(set)).doesNotThrowAnyException();
}
@Test
@DisplayName("accepts an empty label set (reads as: use the default)")
void acceptsEmpty() {
assertThatCode(() -> LabelsValidator.validate(labels())).doesNotThrowAnyException();
}
@Test
@DisplayName("rejects a null label set")
void rejectsNull() {
assertThatThrownBy(() -> LabelsValidator.validate(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Labels are required");
}
@Test
@DisplayName("rejects duplicate names (distinct ids)")
void rejectsDuplicateNames() {
ClassificationLabels set =
labels(
new ClassificationLabel("invoice-a", "Invoice", null),
new ClassificationLabel("invoice-b", "Invoice", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Duplicate label name");
}
@Test
@DisplayName("rejects duplicate names differing only by case")
void rejectsDuplicateNamesCaseInsensitive() {
ClassificationLabels set =
labels(
new ClassificationLabel("invoice-a", "Invoice", null),
new ClassificationLabel("invoice-b", "INVOICE", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Duplicate label name");
}
@Test
@DisplayName("rejects duplicate ids")
void rejectsDuplicateIds() {
ClassificationLabels set =
labels(
new ClassificationLabel("invoice", "Invoice", null),
new ClassificationLabel("invoice", "Sales invoice", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Duplicate label id");
}
@Test
@DisplayName("rejects a blank name")
void rejectsBlankName() {
ClassificationLabels set = labels(new ClassificationLabel("blank", " ", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Label name must not be blank");
}
@Test
@DisplayName("rejects a blank id")
void rejectsBlankId() {
ClassificationLabels set = labels(new ClassificationLabel(" ", "Invoice", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Label id must not be blank");
}
@Test
@DisplayName("rejects an over-long name")
void rejectsOverLongName() {
ClassificationLabels set =
labels(
new ClassificationLabel(
"x", "x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1), null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("too long");
}
@Test
@DisplayName("rejects an over-long icon (a null icon is fine)")
void rejectsOverLongIcon() {
ClassificationLabels set =
labels(
new ClassificationLabel(
"invoice",
"Invoice",
"x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1)));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("icon is too long");
}
@Test
@DisplayName("rejects more labels than the cap")
void rejectsTooManyLabels() {
List<ClassificationLabel> tooMany =
IntStream.rangeClosed(0, LabelsValidator.MAX_LABELS)
.mapToObj(i -> label("label" + i))
.toList();
assertThatThrownBy(() -> LabelsValidator.validate(new ClassificationLabels(tooMany)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Too many labels");
}
}

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