Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2df88881c1 | ||
|
|
4fde0fde89 |
@@ -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/**
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
@@ -180,13 +180,13 @@ 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:
|
||||
@@ -511,7 +511,7 @@ 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@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -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,7 +36,6 @@ 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@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
@@ -308,3 +308,70 @@ jobs:
|
||||
name: playwright-report-enterprise-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
|
||||
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
|
||||
multinode-e2e:
|
||||
needs: [pick, playwright-e2e-enterprise]
|
||||
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
|
||||
if: >-
|
||||
always() && needs.pick.outputs.is_fork != 'true'
|
||||
&& (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
|
||||
PREMIUM_ENABLED: "true"
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
MN_COMPOSE: docker-compose-multinode.yml
|
||||
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
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
- name: Install behave test deps
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
|
||||
- name: Build the multi-node image
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f "$MN_COMPOSE" build
|
||||
- name: Bring up the cluster and wait for both nodes healthy
|
||||
working-directory: testing/compose
|
||||
run: |
|
||||
docker compose -f "$MN_COMPOSE" up -d
|
||||
for i in $(seq 1 90); do
|
||||
h1=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-1 2>/dev/null || echo starting)
|
||||
h2=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-2 2>/dev/null || echo starting)
|
||||
if [ "$h1" = healthy ] && [ "$h2" = healthy ]; then echo "both nodes healthy"; exit 0; fi
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::nodes did not become healthy"
|
||||
docker compose -f "$MN_COMPOSE" logs --tail=200 stirling-1 stirling-2
|
||||
exit 1
|
||||
- name: Seed the cluster (teams, users, S3 connection, policy)
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f "$MN_COMPOSE" --profile seed run --rm seed
|
||||
- name: Run multi-node regression (implemented guarantees)
|
||||
working-directory: testing/cucumber
|
||||
# -e overrides behave.ini's exclusion of features/multinode; ~@known_gap skips any tracked-gap scenarios.
|
||||
run: python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
|
||||
- name: Run multi-node failover (destructive)
|
||||
working-directory: testing/cucumber
|
||||
run: python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
|
||||
- name: Dump node logs on failure
|
||||
if: failure()
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f "$MN_COMPOSE" logs --tail=400 stirling-1 stirling-2
|
||||
- name: Tear down
|
||||
if: always()
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f "$MN_COMPOSE" --profile seed down -v --remove-orphans
|
||||
|
||||
@@ -41,7 +41,6 @@ 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 }}
|
||||
@@ -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'
|
||||
|
||||
@@ -15,7 +15,7 @@ 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:
|
||||
|
||||
@@ -34,7 +34,7 @@ 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
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 }}
|
||||
|
||||
@@ -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,7 +23,7 @@ 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@ 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
|
||||
|
||||
@@ -21,7 +21,7 @@ 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@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -25,7 +25,7 @@ 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 }}
|
||||
@@ -49,7 +49,7 @@ 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
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@ 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@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -42,7 +42,7 @@ 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 }}
|
||||
@@ -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:
|
||||
@@ -639,7 +639,7 @@ 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:
|
||||
|
||||
@@ -19,7 +19,7 @@ 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@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -28,7 +28,7 @@ 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:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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,12 +214,12 @@ 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
|
||||
|
||||
@@ -26,12 +26,12 @@ 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
|
||||
@@ -154,7 +154,7 @@ 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 }}
|
||||
@@ -175,7 +175,7 @@ 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@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -209,7 +209,7 @@ 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:
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -90,6 +90,7 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+1
-8
@@ -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.
|
||||
|
||||
-61
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -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();
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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) {
|
||||
|
||||
|
||||
+71
@@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
}
|
||||
+76
@@ -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;
|
||||
}
|
||||
}
|
||||
+47
@@ -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;
|
||||
}
|
||||
+7
@@ -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> {}
|
||||
+32
-14
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+17
-38
@@ -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;
|
||||
|
||||
-35
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-35
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
@@ -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";
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
-85
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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<>();
|
||||
|
||||
+2
@@ -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
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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";
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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";
|
||||
|
||||
+2
@@ -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";
|
||||
|
||||
+2
@@ -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
-2
@@ -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();
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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";
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+7
-23
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+7
-26
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
+2
@@ -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
@@ -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;
|
||||
|
||||
+2
@@ -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";
|
||||
|
||||
+4
-2
@@ -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 {
|
||||
|
||||
|
||||
-135
@@ -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
-37
@@ -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));
|
||||
|
||||
+133
@@ -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);
|
||||
}
|
||||
}
|
||||
+146
@@ -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");
|
||||
}
|
||||
}
|
||||
+34
-15
@@ -14,6 +14,7 @@ import static org.mockito.Mockito.when;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -26,8 +27,10 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.proprietary.classification.ClassificationLabelProvider;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
@@ -39,16 +42,22 @@ import tools.jackson.databind.json.JsonMapper;
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ClassifyLabelControllerTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private PdfContentExtractor pdfContentExtractor;
|
||||
@Mock private PdfMetadataService pdfMetadataService;
|
||||
@Mock private AiEngineClient aiEngineClient;
|
||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||
|
||||
private final ObjectMapper objectMapper = JsonMapper.builder().build();
|
||||
private InProcessClassificationLabelStore labelStore;
|
||||
private ClassifyLabelController controller;
|
||||
|
||||
private void withLabels(List<ClassificationLabel> labels) {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
labelStore = new InProcessClassificationLabelStore();
|
||||
controller =
|
||||
new ClassifyLabelController(
|
||||
pdfDocumentFactory,
|
||||
@@ -57,8 +66,9 @@ class ClassifyLabelControllerTest {
|
||||
pdfMetadataService,
|
||||
aiEngineClient,
|
||||
objectMapper,
|
||||
ClassificationLabelProvider.withLabels(labels),
|
||||
null);
|
||||
null,
|
||||
labelStore,
|
||||
policyManagementAuthority);
|
||||
}
|
||||
|
||||
private void stubSinglePageDocument() throws Exception {
|
||||
@@ -88,7 +98,12 @@ class ClassifyLabelControllerTest {
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
|
||||
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
labelStore.save(
|
||||
TEAM,
|
||||
new ClassificationLabels(
|
||||
List.of(new ClassificationLabel("invoice", "Invoice", null))),
|
||||
"admin");
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
@@ -103,12 +118,16 @@ class ClassifyLabelControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_sendsLabelIdsAndNames() throws Exception {
|
||||
withLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
new ClassificationLabel("contract", "Contract", null),
|
||||
new ClassificationLabel("timesheet", "Timesheet", null)));
|
||||
void classifyAndLabel_sendsTeamLabelIdsAndNames() throws Exception {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
labelStore.save(
|
||||
TEAM,
|
||||
new ClassificationLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
new ClassificationLabel("contract", "Contract", null),
|
||||
new ClassificationLabel("timesheet", "Timesheet", null))),
|
||||
"admin");
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
@@ -133,13 +152,13 @@ class ClassifyLabelControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_skipsClassificationWhenNoLabels() throws Exception {
|
||||
withLabels(List.of());
|
||||
void classifyAndLabel_skipsClassificationWhenNothingStored() throws Exception {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
// No vocabulary, and the engine holds no default of its own, so the file is passed through
|
||||
// unlabelled: neither the engine nor the metadata write is invoked.
|
||||
// No team labels stored, and the engine holds no default of its own, so the file is passed
|
||||
// through unlabelled: neither the engine nor the metadata write is invoked.
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), any());
|
||||
verify(pdfMetadataService, never())
|
||||
.setClassificationMetadata(any(PDDocument.class), anyString());
|
||||
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
package stirling.software.proprietary.integration.crypto;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class LegacyDecryptStringConverterTest {
|
||||
|
||||
private final LegacyDecryptStringConverter converter = new LegacyDecryptStringConverter();
|
||||
|
||||
@BeforeAll
|
||||
static void initKey() throws Exception {
|
||||
KeyGenerator generator = KeyGenerator.getInstance("AES");
|
||||
generator.init(256);
|
||||
SecretKey key = generator.generateKey();
|
||||
CredentialEncryption.initialiseForTesting(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesPlaintext() {
|
||||
String json = "{\"bucket\":\"inbox\",\"mode\":\"consume\"}";
|
||||
|
||||
assertThat(converter.convertToDatabaseColumn(json)).isEqualTo(json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decryptsLegacyCiphertextOnRead() {
|
||||
String json = "{\"bucket\":\"inbox\"}";
|
||||
String legacyCiphertext = CredentialEncryption.encrypt(json);
|
||||
|
||||
assertThat(legacyCiphertext).isNotEqualTo(json);
|
||||
assertThat(converter.convertToEntityAttribute(legacyCiphertext)).isEqualTo(json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void passesPlaintextThroughOnRead() {
|
||||
String json = "{\"bucket\":\"inbox\",\"mode\":\"consume\"}";
|
||||
|
||||
assertThat(converter.convertToEntityAttribute(json)).isEqualTo(json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullsPassThrough() {
|
||||
assertThat(converter.convertToDatabaseColumn(null)).isNull();
|
||||
assertThat(converter.convertToEntityAttribute(null)).isNull();
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package stirling.software.proprietary.integration.crypto;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class LenientEncryptedStringConverterTest {
|
||||
|
||||
private final LenientEncryptedStringConverter converter = new LenientEncryptedStringConverter();
|
||||
|
||||
@BeforeAll
|
||||
static void initKey() throws Exception {
|
||||
KeyGenerator generator = KeyGenerator.getInstance("AES");
|
||||
generator.init(256);
|
||||
SecretKey key = generator.generateKey();
|
||||
CredentialEncryption.initialiseForTesting(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripsThroughCiphertext() {
|
||||
String json = "{\"bucket\":\"inbox\",\"secretAccessKey\":\"shh\"}";
|
||||
|
||||
String stored = converter.convertToDatabaseColumn(json);
|
||||
|
||||
assertThat(stored).isNotEqualTo(json).doesNotContain("shh");
|
||||
assertThat(converter.convertToEntityAttribute(stored)).isEqualTo(json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyPlaintextRowsPassThroughOnRead() {
|
||||
String legacy = "{\"bucket\":\"inbox\",\"mode\":\"consume\"}";
|
||||
|
||||
assertThat(converter.convertToEntityAttribute(legacy)).isEqualTo(legacy);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullsPassThrough() {
|
||||
assertThat(converter.convertToDatabaseColumn(null)).isNull();
|
||||
assertThat(converter.convertToEntityAttribute(null)).isNull();
|
||||
}
|
||||
}
|
||||
-127
@@ -1,127 +0,0 @@
|
||||
package stirling.software.proprietary.policy.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.annotation.AnnotatedElementUtils;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
|
||||
class PolicyRunRoutesTest {
|
||||
|
||||
private static boolean matchesUri(String uri) {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setRequestURI(uri);
|
||||
return PolicyRunRoutes.matches(req);
|
||||
}
|
||||
|
||||
private static boolean matchesPattern(String pattern) {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, pattern);
|
||||
return PolicyRunRoutes.matches(req);
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchesTheFourExecuteRoutes() {
|
||||
assertThat(matchesUri("/api/v1/policies/run")).isTrue();
|
||||
assertThat(matchesUri("/api/v1/policies/run/stream")).isTrue();
|
||||
assertThat(matchesUri("/api/v1/policies/pol-123/run")).isTrue();
|
||||
assertThat(matchesUri("/api/v1/policies/pol-123/trigger")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludesReadListAndCrudRoutes() {
|
||||
assertThat(matchesUri("/api/v1/policies")).isFalse(); // list + create
|
||||
assertThat(matchesUri("/api/v1/policies/runs")).isFalse();
|
||||
assertThat(matchesUri("/api/v1/policies/run/abc-run-id")).isFalse(); // GET /run/{runId}
|
||||
assertThat(matchesUri("/api/v1/policies/overview")).isFalse();
|
||||
assertThat(matchesUri("/api/v1/policies/triggers")).isFalse(); // NB: not "/trigger"
|
||||
assertThat(matchesUri("/api/v1/policies/order")).isFalse();
|
||||
assertThat(matchesUri("/api/v1/policies/pol-123")).isFalse();
|
||||
assertThat(matchesUri("/api/v1/policies/pol-123/processed-history")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSegmentAnchoredAndContextPathTolerant() {
|
||||
assertThat(matchesUri("/stirling/api/v1/policies/pol-123/run")).isTrue();
|
||||
assertThat(matchesUri("/api/v1/policies-x/pol-123/run"))
|
||||
.isFalse(); // sibling, not the segment
|
||||
assertThat(matchesUri("/api/v1/sources/pol/run")).isFalse();
|
||||
assertThat(matchesUri("/api/v1/misc/compress-pdf")).isFalse();
|
||||
}
|
||||
|
||||
/** Every request mapping on PolicyController, and whether it executes an automation. */
|
||||
private static final Map<String, Boolean> EXPECTED =
|
||||
Map.of(
|
||||
"/api/v1/policies", false, // base: list (GET) + create (POST)
|
||||
"/api/v1/policies/run", true,
|
||||
"/api/v1/policies/run/stream", true,
|
||||
"/api/v1/policies/run/{runId}", false,
|
||||
"/api/v1/policies/runs", false,
|
||||
"/api/v1/policies/order", false,
|
||||
"/api/v1/policies/overview", false,
|
||||
"/api/v1/policies/triggers", false,
|
||||
"/api/v1/policies/{policyId}", false, // GET + DELETE
|
||||
"/api/v1/policies/{policyId}/processed-history", false);
|
||||
|
||||
// Split out because Map.of caps at 10 entries; the execute {id} routes live here.
|
||||
private static final Map<String, Boolean> EXPECTED_ID_EXECUTES =
|
||||
Map.of(
|
||||
"/api/v1/policies/{policyId}/run", true,
|
||||
"/api/v1/policies/{policyId}/trigger", true);
|
||||
|
||||
/**
|
||||
* Fail-safe: this matcher is the sole billing gate, so an unmatched execute route would run
|
||||
* automations for free. Reconstruct every mapping on PolicyController and assert its
|
||||
* classification is declared above - a new/renamed route lands as "unclassified" and fails the
|
||||
* build until someone decides whether it executes an automation.
|
||||
*/
|
||||
@Test
|
||||
void everyControllerMappingIsClassified() {
|
||||
String base = classMapping();
|
||||
Arrays.stream(PolicyController.class.getDeclaredMethods())
|
||||
.filter(m -> AnnotatedElementUtils.hasAnnotation(m, RequestMapping.class))
|
||||
.forEach(
|
||||
m -> {
|
||||
String pattern = base + methodMapping(m);
|
||||
Boolean expected = expectedFor(pattern);
|
||||
assertThat(expected)
|
||||
.as(
|
||||
"unclassified PolicyController route %s - add it to"
|
||||
+ " PolicyRunRoutesTest.EXPECTED",
|
||||
pattern)
|
||||
.isNotNull();
|
||||
assertThat(matchesPattern(pattern))
|
||||
.as("PolicyRunRoutes classification of %s", pattern)
|
||||
.isEqualTo(expected);
|
||||
});
|
||||
}
|
||||
|
||||
private static Boolean expectedFor(String pattern) {
|
||||
if (EXPECTED.containsKey(pattern)) {
|
||||
return EXPECTED.get(pattern);
|
||||
}
|
||||
return EXPECTED_ID_EXECUTES.get(pattern);
|
||||
}
|
||||
|
||||
private static String classMapping() {
|
||||
RequestMapping rm =
|
||||
AnnotatedElementUtils.getMergedAnnotation(
|
||||
PolicyController.class, RequestMapping.class);
|
||||
return rm == null ? "" : firstOrEmpty(rm);
|
||||
}
|
||||
|
||||
private static String methodMapping(java.lang.reflect.Method m) {
|
||||
RequestMapping rm = AnnotatedElementUtils.getMergedAnnotation(m, RequestMapping.class);
|
||||
return rm == null ? "" : firstOrEmpty(rm);
|
||||
}
|
||||
|
||||
private static String firstOrEmpty(RequestMapping rm) {
|
||||
String[] paths = rm.path().length > 0 ? rm.path() : rm.value();
|
||||
return paths.length > 0 ? paths[0] : "";
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -166,8 +166,8 @@ class PolicyExecutorTest {
|
||||
new PipelineStep(
|
||||
createPdf,
|
||||
Map.of(
|
||||
"document",
|
||||
"{\"title\":\"PO\",\"sections\":[]}",
|
||||
"htmlContent",
|
||||
"<p>hi</p>",
|
||||
"filename",
|
||||
"purchase-order.pdf"))),
|
||||
PolicyInputs.of(List.of()),
|
||||
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
||||
|
||||
class AiDocumentHtmlRendererTest {
|
||||
|
||||
private final AiDocumentHtmlRenderer renderer = new AiDocumentHtmlRenderer();
|
||||
|
||||
private static AiDocument.Section section(String type) {
|
||||
AiDocument.Section s = new AiDocument.Section();
|
||||
s.setType(type);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static AiDocument document(String title, List<AiDocument.Section> sections) {
|
||||
AiDocument doc = new AiDocument();
|
||||
doc.setTitle(title);
|
||||
doc.setSections(sections);
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersAllSectionTypes() {
|
||||
AiDocument.Section text = section("text");
|
||||
text.setBody("Some prose text.");
|
||||
AiDocument.Section kv = section("key_value");
|
||||
kv.setPairs(List.of(List.of("Key", "Value")));
|
||||
AiDocument.Section items = section("line_items");
|
||||
items.setColumns(List.of("A", "B"));
|
||||
items.setRows(List.of(List.of("1", "2")));
|
||||
AiDocument.Section bullets = section("bullet_list");
|
||||
bullets.setItems(List.of("item one"));
|
||||
AiDocument.Section sign = section("signature");
|
||||
sign.setSignatories(List.of("Alice"));
|
||||
|
||||
String html = renderer.render(document("All", List.of(text, kv, items, bullets, sign)));
|
||||
|
||||
assertTrue(html.contains("<!DOCTYPE html>"));
|
||||
assertTrue(html.contains("Some prose text."));
|
||||
assertTrue(html.contains("Key") && html.contains("Value"));
|
||||
assertTrue(html.contains("<th>"));
|
||||
assertTrue(html.contains("item one"));
|
||||
assertTrue(html.contains("Alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersMarkupCharactersAsText() {
|
||||
AiDocument.Section text = section("text");
|
||||
text.setBody("a <b>x</b> & y");
|
||||
|
||||
String html = renderer.render(document("Doc", List.of(text)));
|
||||
|
||||
assertFalse(html.contains("<b>"));
|
||||
assertTrue(html.contains("<b>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void totalRowRenderedWhenPresent() {
|
||||
AiDocument.Section items = section("line_items");
|
||||
items.setColumns(List.of("Item", "Total"));
|
||||
items.setRows(List.of(List.of("Widget", "$10")));
|
||||
items.setTotalRow(List.of("Total", "$10"));
|
||||
|
||||
assertTrue(
|
||||
renderer.render(document("Table", List.of(items)))
|
||||
.contains("<tr class=\"total-row\">"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void totalRowAbsentWhenNotProvided() {
|
||||
AiDocument.Section items = section("line_items");
|
||||
items.setColumns(List.of("Item"));
|
||||
items.setRows(List.of(List.of("Widget")));
|
||||
|
||||
assertFalse(
|
||||
renderer.render(document("Table", List.of(items)))
|
||||
.contains("<tr class=\"total-row\">"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersSubtitleAndReference() {
|
||||
AiDocument doc = document("My Doc", List.of());
|
||||
doc.setSubtitle("Subtitle Here");
|
||||
doc.setReferenceNumber("REF-42");
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertTrue(html.contains("Subtitle Here"));
|
||||
assertTrue(html.contains("REF-42"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesHexColourOverride() {
|
||||
AiDocument doc = document("Styled", List.of());
|
||||
AiDocument.Style style = new AiDocument.Style();
|
||||
style.setPrimaryColor("#ff00ff");
|
||||
style.setBackgroundColor("#111111");
|
||||
doc.setStyle(style);
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertTrue(html.contains("--color-primary: #ff00ff"));
|
||||
assertTrue(html.contains("--color-bg: #111111"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresColourWithDisallowedCharacters() {
|
||||
AiDocument doc = document("Styled", List.of());
|
||||
AiDocument.Style style = new AiDocument.Style();
|
||||
style.setPrimaryColor("rgb(255, 0, 0)");
|
||||
doc.setStyle(style);
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertFalse(html.contains("rgb("));
|
||||
assertTrue(html.contains("<!DOCTYPE html>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresNonHexColour() {
|
||||
AiDocument doc = document("Styled", List.of());
|
||||
AiDocument.Style style = new AiDocument.Style();
|
||||
style.setPrimaryColor("magenta");
|
||||
style.setBackgroundColor("#fff");
|
||||
doc.setStyle(style);
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertFalse(html.contains("--color-primary: magenta"));
|
||||
assertFalse(html.contains("--color-bg: #fff;"));
|
||||
}
|
||||
}
|
||||
+5
-15
@@ -31,7 +31,6 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
@@ -47,11 +46,9 @@ import stirling.software.saas.util.AuthenticationUtils;
|
||||
*
|
||||
* <p>Scope: routes whose handler method (or bean type) carries either {@link AutoJobPostMapping}
|
||||
* (multipart tool POSTs) or {@link RequiresFeature} (AI controllers, future non-multipart gated
|
||||
* routes), plus two proprietary route families recognised by path since they can't carry the
|
||||
* annotation: AI document tools ({@link AiToolRoutes} gated on AI_SUPPORT) and policy execute
|
||||
* endpoints ({@link PolicyRunRoutes} gated on AUTOMATION). Admin / info / config endpoints are
|
||||
* excluded by the path-pattern in {@code PaygWebMvcConfig} and are additionally skipped here when
|
||||
* they carry no annotation and match no such family, so non-billable infra never trips the guard.
|
||||
* routes). Admin / info / config endpoints are excluded by the path-pattern in {@code
|
||||
* PaygWebMvcConfig} and are additionally skipped here when they carry neither annotation, so non-
|
||||
* billable infra never trips the guard.
|
||||
*
|
||||
* <p>Decision matrix:
|
||||
*
|
||||
@@ -140,20 +137,13 @@ public class EntitlementGuard implements HandlerInterceptor {
|
||||
// @RequiresFeature; recognise them by path so they're gated on AI_SUPPORT — see
|
||||
// AiToolRoutes and PaygChargeInterceptor, which classify the same routes as AI.
|
||||
boolean aiToolRoute = AiToolRoutes.matches(request);
|
||||
// Policy execute routes (/api/v1/policies/**/run etc.) are proprietary and can't carry
|
||||
// @RequiresFeature; recognise them by path and gate on AUTOMATION (mirrors aiToolRoute).
|
||||
boolean policyRunRoute = PolicyRunRoutes.matches(request);
|
||||
if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute && !policyRunRoute) {
|
||||
if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute) {
|
||||
skippedNoAnnotationCounter.increment();
|
||||
return true;
|
||||
}
|
||||
|
||||
FeatureGate[] required =
|
||||
aiToolRoute
|
||||
? new FeatureGate[] {FeatureGate.AI_SUPPORT}
|
||||
: policyRunRoute
|
||||
? new FeatureGate[] {FeatureGate.AUTOMATION}
|
||||
: resolveRequiredGates(hm);
|
||||
aiToolRoute ? new FeatureGate[] {FeatureGate.AI_SUPPORT} : resolveRequiredGates(hm);
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
boolean anonymous = isAnonymous(auth);
|
||||
|
||||
@@ -74,6 +74,10 @@ supabase.url=https://${app.supabase.project-ref}.supabase.co
|
||||
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://${app.supabase.project-ref}.supabase.co/auth/v1/.well-known/jwks.json
|
||||
spring.security.oauth2.resourceserver.jwt.audiences=${app.supabase.expected-aud}
|
||||
|
||||
# ---------- Policies ----------
|
||||
# Exposes the /api/v1/policies and /api/v1/sources controllers, engine, stores, and triggers.
|
||||
policies.enabled=true
|
||||
|
||||
# ---------- Multi-tenant scoping ----------
|
||||
# Restrict the signing user picker to the caller's team; SaaS must be 'team'
|
||||
# or unrelated tenants leak emails to each other.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Policy engine schema: persisted policies and the reusable input
|
||||
-- Policy engine schema (gated by policies.enabled): persisted policies and the reusable input
|
||||
-- connections ("sources") they reference by id. The whole policy/source lives as JSON in the
|
||||
-- *_json column (authoritative on read); the scalar columns are denormalized copies for querying,
|
||||
-- notably team_id so a caller's team can be loaded without scanning every team's rows. owner and
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
-- instead of scanning a source's whole bucket history - and so the
|
||||
-- hourly buckets can be pruned without losing it.
|
||||
--
|
||||
-- Hibernate ddl-auto would also create these, but the migration keeps the schema explicit for the
|
||||
-- Flyway-managed deployments.
|
||||
-- Gated by policies.enabled like the rest of the subsystem; Hibernate ddl-auto would also create
|
||||
-- these, but the migration keeps the schema explicit for the Flyway-managed deployments.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy_source_doc_counts (
|
||||
source_id VARCHAR(255) NOT NULL,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Classification labels: the flat multi-label vocabulary the document
|
||||
-- Classification labels (gated by policies.enabled): the flat multi-label vocabulary the document
|
||||
-- classifier runs against. One admin-editable row per team. The whole label set lives as JSON in
|
||||
-- labels_json (authoritative on read). team_id is a natural key and a plain value (not a foreign
|
||||
-- key) to stay decoupled from the security entities, so classification can be enabled or disabled
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
-- the policy's sources, so the table stays near the set of files
|
||||
-- currently present.
|
||||
--
|
||||
-- Hibernate ddl-auto would also create this, but the migration keeps the schema explicit for the
|
||||
-- Flyway-managed deployments.
|
||||
-- Gated by policies.enabled like the rest of the subsystem; Hibernate ddl-auto would also create
|
||||
-- this, but the migration keeps the schema explicit for the Flyway-managed deployments.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy_processed_files (
|
||||
policy_id VARCHAR(255) NOT NULL,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Classification labels are now a fixed, built-in set bundled with the app and sent to the engine
|
||||
-- per request (see ClassificationLabelProvider); the per-team classification_labels table (created
|
||||
-- in V30) is no longer read or written. Drop it.
|
||||
--
|
||||
-- Forward migration: V30 is kept so any DB that already applied it still validates. This runs after
|
||||
-- V30 in every case, so it drops the table whether V30 just created it (fresh DB) or it was created
|
||||
-- and populated on an earlier deploy. IF EXISTS only guards the edge case where the table is already
|
||||
-- absent, keeping the migration safe to apply regardless of prior state.
|
||||
|
||||
DROP TABLE IF EXISTS classification_labels;
|
||||
-91
@@ -198,97 +198,6 @@ class EntitlementGuardTest {
|
||||
assertThat(body.get("category").asText()).isEqualTo("AI");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Policy execute routes (proprietary; recognised by path, gated on AUTOMATION)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void policyRunRoute_noAnnotation_isInScopeAndGatedOnAutomation() throws Exception {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
|
||||
when(userRepository.findBySupabaseId(supabaseId))
|
||||
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
|
||||
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot());
|
||||
|
||||
HandlerMethod hm = handlerFor("plainEndpoint"); // no annotations
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setRequestURI("/api/v1/policies/pol-123/run");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isFalse();
|
||||
assertThat(res.getStatus()).isEqualTo(402);
|
||||
JsonNode body = json.readTree(res.getContentAsByteArray());
|
||||
assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED");
|
||||
assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AUTOMATION");
|
||||
verify(entitlementService).getSnapshot(42L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void policyRunRoute_anonymous_returns401WithAutomationCategory() throws Exception {
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new AnonymousAuthenticationToken(
|
||||
"key",
|
||||
"anonymousUser",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
|
||||
|
||||
HandlerMethod hm = handlerFor("plainEndpoint");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setRequestURI("/api/v1/policies/pol-123/trigger");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isFalse();
|
||||
assertThat(res.getStatus()).isEqualTo(401);
|
||||
JsonNode body = json.readTree(res.getContentAsByteArray());
|
||||
assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED");
|
||||
assertThat(body.get("category").asText()).isEqualTo("AUTOMATION");
|
||||
}
|
||||
|
||||
@Test
|
||||
void policyRunRoute_authenticatedFull_passesThrough() throws Exception {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
|
||||
when(userRepository.findBySupabaseId(supabaseId))
|
||||
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
|
||||
when(entitlementService.getSnapshot(42L)).thenReturn(fullSnapshot());
|
||||
|
||||
HandlerMethod hm = handlerFor("plainEndpoint");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setRequestURI("/api/v1/policies/run");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isTrue();
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void policyReadRoute_notGated_passesThroughEvenDegraded() throws Exception {
|
||||
// Listing policies must stay ungated so the UI can show them and prompt on use.
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new AnonymousAuthenticationToken(
|
||||
"key",
|
||||
"anonymousUser",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
|
||||
|
||||
HandlerMethod hm = handlerFor("plainEndpoint");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setRequestURI("/api/v1/policies");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isTrue();
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
Mockito.verifyNoInteractions(entitlementService);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Anonymous user
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@@ -36,8 +36,6 @@ ext {
|
||||
okhttpBomVersion = "5.3.2"
|
||||
gsonVersion = "2.14.0"
|
||||
guavaVersion = "33.6.0-jre"
|
||||
jinjavaVersion = "2.8.3"
|
||||
jackson2Version = "2.21.2"
|
||||
bucket4jVersion = "8.19.0"
|
||||
archunitVersion = "1.4.2"
|
||||
batikVersion = "1.19"
|
||||
@@ -224,13 +222,6 @@ subprojects {
|
||||
resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}"
|
||||
// CVE-2024-47554: commons-io DoS prevention
|
||||
resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}"
|
||||
// Jackson 2 is transitive-only here (jinjava, opensaml, jjwt request older versions);
|
||||
// pin the family to a current release and keep modules aligned.
|
||||
resolutionStrategy.force "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jackson2Version}"
|
||||
// Keep BouncyCastle modules aligned to avoid runtime linkage errors
|
||||
resolutionStrategy.force "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}"
|
||||
resolutionStrategy.force "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}"
|
||||
|
||||
@@ -5,12 +5,12 @@ description = "AI Document Engine"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi>=0.116.0",
|
||||
"jinja2>=3.1.0",
|
||||
"pgvector>=0.3.6",
|
||||
"psycopg[binary,pool]>=3.2",
|
||||
"pydantic>=2.0.0",
|
||||
# <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration
|
||||
"pydantic-ai>=1.99.0,<2.0.0",
|
||||
"pydantic-ai-slim[voyageai]>=1.99.0,<2.0.0",
|
||||
"pydantic-ai>=1.67.0",
|
||||
"pydantic-ai-slim[voyageai]>=1.67.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"sqlite-vec>=0.1.6",
|
||||
|
||||
@@ -116,8 +116,8 @@ class DocumentClassifierAgent:
|
||||
)
|
||||
|
||||
async def classify(self, request: ClassifyDocumentRequest) -> ClassifyDocumentResponse:
|
||||
# The caller (the backend) always supplies the allowed vocabulary — its
|
||||
# fixed built-in label set — so the engine holds no vocabulary of its own.
|
||||
# The caller (the backend) always supplies the allowed vocabulary — the
|
||||
# team's stored labels — so the engine holds no vocabulary of its own.
|
||||
allowed = request.labels
|
||||
window = select_window(request.pages)
|
||||
prompt = self._build_prompt(request.file_name, allowed, window)
|
||||
|
||||
@@ -10,7 +10,7 @@ Flow:
|
||||
4. SectionWriterAgents (smart_model) run in parallel via asyncio.gather.
|
||||
Each returns a WrittenSections with fully populated DocumentSection objects.
|
||||
5. The assembler collects sections in plan order → GeneratedDocument.
|
||||
6. The assembled document is emitted as structured fields. The LLM never writes HTML.
|
||||
6. Jinja renders the document to HTML. The LLM never writes HTML.
|
||||
|
||||
The planner is split into two calls (meta then sections) so each LLM output schema
|
||||
stays small enough for grammar compilation on all model tiers including Haiku.
|
||||
@@ -22,7 +22,9 @@ import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.output import NativeOutput
|
||||
|
||||
@@ -49,6 +51,8 @@ from stirling.services import AppRuntime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
# ── Token budget ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Conservative per-section token estimates mapped from planner-assigned depth.
|
||||
@@ -162,13 +166,10 @@ Analyse the user's request and produce a DocumentMeta with:
|
||||
document, if the user provides one. Leave empty if the user provides no such context.
|
||||
|
||||
- style_primary_color: accent and heading colour. Set ONLY when the user explicitly names a
|
||||
colour or colour scheme (e.g. "make it red", "use navy blue"). Express it as a 6-digit hex
|
||||
code in #RRGGBB format (map any named colour to its hex value yourself, e.g. "navy" →
|
||||
"#000080"). No other format is accepted. Leave null if no colour is stated.
|
||||
- style_background_color: page background colour, same #RRGGBB format. Set only if explicitly
|
||||
requested.
|
||||
- style_body_text_color: body text colour, same #RRGGBB format. Set only if explicitly
|
||||
requested.
|
||||
colour or colour scheme (e.g. "make it red", "use navy blue"). Use CSS named colours
|
||||
(e.g. "magenta", "navy", "crimson") or hex values. Leave null if no colour is stated.
|
||||
- style_background_color: page background colour. Set only if explicitly requested.
|
||||
- style_body_text_color: body text colour. Set only if explicitly requested.
|
||||
|
||||
- cannot_do_reason: set this ONLY when the request is not asking to create a document at all
|
||||
(e.g. a question, a greeting, an edit request to an existing document). Never set it
|
||||
@@ -298,6 +299,15 @@ def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str:
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_jinja_env() -> Environment:
|
||||
return Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
||||
autoescape=True,
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
|
||||
def _safe_filename(title: str) -> str:
|
||||
slug = re.sub(r"[^\w\s-]", "", title.lower())
|
||||
slug = re.sub(r"[\s_-]+", "-", slug).strip("-")
|
||||
@@ -310,6 +320,7 @@ def _safe_filename(title: str) -> str:
|
||||
class PdfCreateAgent:
|
||||
def __init__(self, runtime: AppRuntime) -> None:
|
||||
self.runtime = runtime
|
||||
self._jinja_env = _build_jinja_env()
|
||||
|
||||
self._meta_planner: Agent[None, DocumentMeta] = Agent(
|
||||
model=runtime.smart_model,
|
||||
@@ -390,12 +401,14 @@ class PdfCreateAgent:
|
||||
sections=all_sections,
|
||||
)
|
||||
|
||||
# ── Phase 6: emit ──────────────────────────────────────────────────────
|
||||
# ── Phase 6: render ────────────────────────────────────────────────────
|
||||
logger.info("[pdf-create] phase 6/6: rendering HTML")
|
||||
html = self._render(doc)
|
||||
filename = _safe_filename(plan.title)
|
||||
logger.info(
|
||||
"[pdf-create] done — filename=%r sections=%d",
|
||||
"[pdf-create] done — filename=%r html_bytes=%d",
|
||||
filename,
|
||||
len(all_sections),
|
||||
len(html),
|
||||
)
|
||||
|
||||
return EditPlanResponse(
|
||||
@@ -404,7 +417,7 @@ class PdfCreateAgent:
|
||||
ToolOperationStep(
|
||||
tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT,
|
||||
parameters=CreatePdfFromHtmlAgentParams(
|
||||
document=doc.model_dump_json(),
|
||||
html_content=html,
|
||||
filename=filename,
|
||||
),
|
||||
)
|
||||
@@ -424,3 +437,7 @@ class PdfCreateAgent:
|
||||
len(result.output.sections),
|
||||
)
|
||||
return result.output
|
||||
|
||||
def _render(self, doc: GeneratedDocument) -> str:
|
||||
template = self._jinja_env.get_template("document.html.jinja2")
|
||||
return template.render(doc=doc)
|
||||
|
||||
+19
-21
@@ -1,4 +1,3 @@
|
||||
{%- autoescape true -%}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -176,18 +175,18 @@
|
||||
color: var(--color-label);
|
||||
}
|
||||
</style>
|
||||
{%- if style_primary or style_background or style_body %}
|
||||
{%- if doc.style %}
|
||||
<style>
|
||||
:root {
|
||||
{%- if style_primary %}
|
||||
--color-primary: {{ style_primary }};
|
||||
{%- if doc.style.primary_color %}
|
||||
--color-primary: {{ doc.style.primary_color }};
|
||||
{%- endif %}
|
||||
{%- if style_background %}
|
||||
--color-bg: {{ style_background }};
|
||||
{%- if doc.style.background_color %}
|
||||
--color-bg: {{ doc.style.background_color }};
|
||||
{%- endif %}
|
||||
{%- if style_body %}
|
||||
--color-body: {{ style_body }};
|
||||
--color-label: {{ style_body }};
|
||||
{%- if doc.style.body_text_color %}
|
||||
--color-body: {{ doc.style.body_text_color }};
|
||||
--color-label: {{ doc.style.body_text_color }};
|
||||
{%- endif %}
|
||||
}
|
||||
</style>
|
||||
@@ -196,16 +195,16 @@
|
||||
<body>
|
||||
|
||||
<div class="doc-header">
|
||||
<div class="doc-title">{{ title }}</div>
|
||||
{%- if subtitle %}
|
||||
<div class="doc-subtitle">{{ subtitle }}</div>
|
||||
<div class="doc-title">{{ doc.title }}</div>
|
||||
{%- if doc.subtitle %}
|
||||
<div class="doc-subtitle">{{ doc.subtitle }}</div>
|
||||
{%- endif %}
|
||||
{%- if reference_number %}
|
||||
<div class="doc-reference">{{ reference_number }}</div>
|
||||
{%- if doc.reference_number %}
|
||||
<div class="doc-reference">{{ doc.reference_number }}</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
|
||||
{%- for section in sections %}
|
||||
{%- for section in doc.sections %}
|
||||
|
||||
{%- if section.type == "text" %}
|
||||
<section>
|
||||
@@ -213,8 +212,8 @@
|
||||
<h2>{{ section.heading }}</h2>
|
||||
{%- endif %}
|
||||
<div class="text-body">
|
||||
{%- for para in section.paragraphs %}
|
||||
<p>{{ para }}</p>
|
||||
{%- for para in section.body.split('\n\n') %}
|
||||
<p>{{ para | replace('\n', ' ') }}</p>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -226,10 +225,10 @@
|
||||
{%- endif %}
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
{%- for pair in section.pairs %}
|
||||
{%- for label, value in section.pairs %}
|
||||
<tr>
|
||||
<td class="kv-label">{{ pair.label }}</td>
|
||||
<td class="kv-value">{{ pair.value }}</td>
|
||||
<td class="kv-label">{{ label }}</td>
|
||||
<td class="kv-value">{{ value }}</td>
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
@@ -300,4 +299,3 @@
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{%- endautoescape %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user