Merge branch 'main' into feat/auto-form-detection

This commit is contained in:
Anthony Stirling
2026-08-24 21:46:46 +01:00
71 changed files with 1046 additions and 1923 deletions
+8
View File
@@ -103,6 +103,14 @@ app/core/pipeline/
__pycache__/
**/__pycache__/
# Python virtualenvs. Large, platform-specific, and their symlinks break the build.
.venv/
**/.venv/
venv/
**/venv/
*.egg-info/
**/*.egg-info/
# Local env
.env
.env.*
+3 -3
View File
@@ -39,7 +39,7 @@ jobs:
pr_ref: ${{ steps.resolve.outputs.ref }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -121,7 +121,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -475,7 +475,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -70,7 +70,7 @@ jobs:
enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -177,7 +177,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -482,7 +482,7 @@ jobs:
issues: write # add/remove labels, delete the command comment
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
is_fork: ${{ steps.decide.outputs.is_fork }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
issues: write # labels are applied through the issues API
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
flavor: [core, proprietary, saas]
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Checkout repository
+2 -2
View File
@@ -58,7 +58,7 @@ jobs:
SYSTEM_ENABLEANALYTICS: "false"
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Checkout repository
@@ -351,7 +351,7 @@ jobs:
MN_COMPOSE: docker-compose-multinode.yml
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Checkout repository
+2 -2
View File
@@ -49,7 +49,7 @@ jobs:
proprietary: ${{ steps.changes.outputs.proprietary }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Checkout repository
@@ -298,7 +298,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Checkout repository
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Checkout repository
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
project: stubbed-webkit
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Checkout repository
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Checkout repository
@@ -28,7 +28,7 @@ jobs:
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -56,7 +56,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -334,7 +334,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Checkout repository
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Checkout repository
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+4 -4
View File
@@ -48,7 +48,7 @@ jobs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -140,7 +140,7 @@ jobs:
file_suffix: "-server"
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -207,7 +207,7 @@ jobs:
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
allowed-endpoints: >
@@ -715,7 +715,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+3 -3
View File
@@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -74,7 +74,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -140,7 +140,7 @@ jobs:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -80,7 +80,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
pull-requests: write # pulls.get/list plus add/remove the label on PRs
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+128 -1
View File
@@ -18,6 +18,16 @@ on:
required: false
type: boolean
default: false
build_engine:
description: "Build & push the standalone stirling-engine image."
required: false
type: boolean
default: true
force_engine_rebuild:
description: "Rebuild stirling-engine even if its source hash is unchanged."
required: false
type: boolean
default: false
push:
branches:
- release
@@ -50,9 +60,10 @@ jobs:
env:
RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }}
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
RUN_ENGINE: ${{ github.event_name != 'workflow_dispatch' || inputs.build_engine }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -387,3 +398,119 @@ jobs:
else
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping unoserver image signing"
fi
# Standalone AI engine image, same shape as the unoserver image above.
- name: Compute engine image source hash
id: engineHash
if: env.RUN_ENGINE == 'true'
run: |
set -eu
hash=$( { cat engine/Dockerfile engine/pyproject.toml engine/uv.lock engine/.env; \
find engine/src -type f -print0 | sort -z | xargs -0 cat; } \
| sha256sum | cut -d' ' -f1)
echo "hash=${hash}" >> "$GITHUB_OUTPUT"
echo "Engine source hash: ${hash}"
- name: Decide whether to publish engine image
id: engineDecision
if: env.RUN_ENGINE == 'true'
env:
ENGINE_VERSION: ${{ steps.versionNumber.outputs.versionNumber }}
ENGINE_HASH: ${{ steps.engineHash.outputs.hash }}
ENGINE_IMAGE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-engine
ENGINE_HASH_ANNOTATION: org.stirlingpdf.engine-source-hash
FORCE_REBUILD: ${{ inputs.force_engine_rebuild }}
GH_REF: ${{ github.ref }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -eu
mode="skip"
tags=""
read_published_hash() {
local ref="$1"
docker buildx imagetools inspect "$ref" --raw 2>/dev/null \
| jq -r --arg key "$ENGINE_HASH_ANNOTATION" \
'.annotations[$key] // empty' \
2>/dev/null || true
}
# Manual dispatch from any branch routes to the :alpha publish path.
EFFECTIVE_REF="$GH_REF"
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
EFFECTIVE_REF="refs/heads/testMain"
fi
case "$EFFECTIVE_REF" in
refs/heads/release)
if [ "${FORCE_REBUILD}" = "true" ]; then
echo "force_engine_rebuild=true — building stable regardless"
mode="stable"
tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest"
elif docker manifest inspect "${ENGINE_IMAGE}:${ENGINE_VERSION}" >/dev/null 2>&1; then
echo "stirling-engine:${ENGINE_VERSION} already on GHCR — skipping"
else
echo "stirling-engine:${ENGINE_VERSION} is new — will publish"
mode="stable"
tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest"
fi
;;
refs/heads/main|refs/heads/testMain)
published_hash=$(read_published_hash "${ENGINE_IMAGE}:alpha")
if [ "${FORCE_REBUILD}" = "true" ]; then
echo "force_engine_rebuild=true — rebuilding :alpha regardless"
mode="alpha"
tags="${ENGINE_IMAGE}:alpha"
elif [ -n "$published_hash" ] && [ "$published_hash" = "$ENGINE_HASH" ]; then
echo "Published :alpha source hash matches (${published_hash}) — skipping"
else
if [ -z "$published_hash" ]; then
echo ":alpha has no source-hash annotation (first publish) — will publish"
else
echo "Source hash changed (was ${published_hash}, now ${ENGINE_HASH}) — will publish"
fi
mode="alpha"
tags="${ENGINE_IMAGE}:alpha"
fi
;;
*)
echo "Branch ${GH_REF} does not publish engine image"
;;
esac
echo "mode=${mode}" >> "$GITHUB_OUTPUT"
echo "tags=${tags}" >> "$GITHUB_OUTPUT"
- name: Build and push engine image
id: build-push-engine
if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode != 'skip'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./engine/Dockerfile
push: true
cache-from: type=gha,scope=stirling-engine
cache-to: type=gha,mode=max,scope=stirling-engine
tags: ${{ steps.engineDecision.outputs.tags }}
# Manifest annotation read by the decision step above to detect drift.
annotations: |
index:org.stirlingpdf.engine-source-hash=${{ steps.engineHash.outputs.hash }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign engine image
if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode == 'stable'
env:
DIGEST: ${{ steps.build-push-engine.outputs.digest }}
TAGS: ${{ steps.engineDecision.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
if [ -n "$COSIGN_PRIVATE_KEY" ]; then
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
done
else
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping engine image signing"
fi
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+4 -4
View File
@@ -72,7 +72,7 @@ jobs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -134,7 +134,7 @@ jobs:
SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -703,7 +703,7 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -795,7 +795,7 @@ jobs:
if: always()
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+3 -3
View File
@@ -34,7 +34,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -77,7 +77,7 @@ jobs:
cache-scope: stirling-pdf-fat
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -217,7 +217,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
@@ -91,24 +91,32 @@ public class ControllerAuditAspect {
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
Method method = sig.getMethod();
// Fast path: check if auditing is enabled before doing any work
// This avoids all data collection if auditing is disabled
if (!auditService.shouldAudit(method, auditConfig)) {
// Resolve the event type up front so the enterprise gate can be type-aware: document
// processing events (the Documents tab's data source) are audited without an Enterprise
// license, while the rest of the audit log stays Enterprise-only. resolveEventType is cheap
// (annotation / class / path checks), so it's safe on the pre-record fast path.
Audited auditedAnnotation = method.getAnnotation(Audited.class);
String path = getRequestPath(method, httpMethod);
AuditEventType eventType =
auditService.resolveEventType(
method,
joinPoint.getTarget().getClass(),
path,
httpMethod,
auditedAnnotation);
// Fast path: skip all data collection when this event won't be recorded.
if (!auditService.shouldAudit(eventType, method, auditConfig)) {
return joinPoint.proceed();
}
// Check if method is explicitly annotated with @Audited
Audited auditedAnnotation = method.getAnnotation(Audited.class);
AuditLevel level = auditConfig.getAuditLevel();
// If @Audited annotation is present, respect its level setting
if (auditedAnnotation != null) {
// Use the level from annotation if it's stricter than global level
level = auditedAnnotation.level();
}
String path = getRequestPath(method, httpMethod);
// Skip static GET resources
if ("GET".equals(httpMethod)) {
HttpServletRequest maybe = auditService.getCurrentRequest();
@@ -209,15 +217,6 @@ public class ControllerAuditAspect {
// the body ran, so it must happen here rather than with the pre-proceed HTTP data).
auditService.addAutomationContext(data, req);
// Resolve the event type using the unified method
AuditEventType eventType =
auditService.resolveEventType(
method,
joinPoint.getTarget().getClass(),
path,
httpMethod,
auditedAnnotation);
// Add result only if operation result capture is explicitly enabled
// Skip result for UI_DATA events to avoid storing large response bodies
if (auditService.shouldCaptureOperationResults()
@@ -0,0 +1,13 @@
package stirling.software.proprietary.audit;
import org.springframework.stereotype.Component;
/** Self-hosted default: any portal user sees the whole-server documents queue. */
@Component
public class DefaultPortalDocumentsScopeResolver implements PortalDocumentsScopeResolver {
@Override
public PortalAuditScope resolve() {
return PortalAuditScope.server();
}
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.audit;
/** Resolves which slice of the documents queue a portal user may see. */
public interface PortalDocumentsScopeResolver {
PortalAuditScope resolve();
}
@@ -2,6 +2,7 @@ package stirling.software.proprietary.controller.api;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -11,19 +12,25 @@ import lombok.RequiredArgsConstructor;
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
import stirling.software.proprietary.audit.PortalAuditScope;
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
import stirling.software.proprietary.audit.PortalDocumentsScopeResolver;
import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
import stirling.software.proprietary.service.PortalDocumentsService;
/** Serves the portal Documents review queue, derived from real audit data and scoped per caller. */
/**
* Serves the portal Documents review queue, derived from real audit data and scoped per caller.
*
* <p>Open to every portal user (not Enterprise-gated): the Documents tab is a core Processor
* feature. Access is enforced by {@code @resourceAccess.canUsePortal()}; visibility is then
* resolved per deployment - self-hosted portal users see the whole server, SaaS users see their
* team (see {@link PortalDocumentsScopeResolver}).
*/
@ProprietaryUiDataApi
@RequiredArgsConstructor
@EnterpriseEndpoint
@PreAuthorize("@resourceAccess.canUsePortal()")
public class PortalDocumentsController {
private final PortalDocumentsService portalDocumentsService;
private final PortalAuditScopeResolver auditScopeResolver;
private final PortalDocumentsScopeResolver documentsScopeResolver;
// tier accepted for mock-seam symmetry; ignored (queue isn't tier-scoped).
@GetMapping("/documents")
@@ -32,8 +39,9 @@ public class PortalDocumentsController {
description = "Files processed through the org, derived from the audit trail.")
public ResponseEntity<PortalDocumentsResponseDto> getDocuments(
@RequestParam(value = "tier", required = false) String tier) {
PortalAuditScope scope = auditScopeResolver.resolve();
PortalAuditScope scope = documentsScopeResolver.resolve();
if (!scope.allowed()) {
// SaaS caller with no team has nothing to show; surface an empty tab, not a 500.
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
PortalDocumentsResponseDto body =
@@ -5,13 +5,13 @@ import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.config.AuditConfigurationProperties;
@@ -20,15 +20,31 @@ import stirling.software.proprietary.repository.PersistentAuditEventRepository;
/** Service to periodically clean up old audit events based on retention policy. */
@Slf4j
@Service
@RequiredArgsConstructor
public class AuditCleanupService {
private final PersistentAuditEventRepository auditRepository;
private final AuditConfigurationProperties auditConfig;
private final boolean runningEE;
// Default batch size for deletions
private static final int BATCH_SIZE = 10000;
/**
* Maximum audit retention on non-Enterprise instances. Audit events feed the Documents tab on
* every instance, but longer history is an Enterprise feature - so non-EE deployments keep a
* shorter window ("infinite" included), bounding the always-on trail off-license.
*/
private static final int NON_EE_MAX_RETENTION_DAYS = 30;
public AuditCleanupService(
PersistentAuditEventRepository auditRepository,
AuditConfigurationProperties auditConfig,
@Qualifier("runningEE") boolean runningEE) {
this.auditRepository = auditRepository;
this.auditConfig = auditConfig;
this.runningEE = runningEE;
}
/**
* Scheduled task that runs daily to clean up old audit events. The retention period is
* configurable in settings.yml.
@@ -39,7 +55,7 @@ public class AuditCleanupService {
return;
}
int retentionDays = auditConfig.getRetentionDays();
int retentionDays = effectiveRetentionDays();
if (retentionDays <= 0) {
return;
}
@@ -58,6 +74,20 @@ public class AuditCleanupService {
}
}
/**
* The retention window actually applied. Enterprise uses the configured value (0 = infinite);
* non-Enterprise is clamped to {@link #NON_EE_MAX_RETENTION_DAYS}.
*/
int effectiveRetentionDays() {
int configured = auditConfig.getRetentionDays();
if (runningEE) {
return configured;
}
return configured <= 0
? NON_EE_MAX_RETENTION_DAYS
: Math.min(configured, NON_EE_MAX_RETENTION_DAYS);
}
/**
* Performs batch deletion of events to prevent long-running transactions and potential database
* locks.
@@ -87,10 +87,7 @@ public class AuditService {
* @param level The minimum audit level required for this event to be logged
*/
public void audit(AuditEventType type, Map<String, Object> data, AuditLevel level) {
// Skip auditing if this level is not enabled or if not Enterprise edition
if (!auditConfig.isEnabled()
|| !auditConfig.getAuditLevel().includes(level)
|| !runningEE) {
if (!shouldRecord(type, level)) {
return;
}
@@ -126,8 +123,7 @@ public class AuditService {
*/
public void audit(
String principal, AuditEventType type, Map<String, Object> data, AuditLevel level) {
// Skip auditing if this level is not enabled or if not Enterprise edition
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
if (!shouldRecord(type, level)) {
return;
}
@@ -156,8 +152,7 @@ public class AuditService {
* @param level The minimum audit level required for this event to be logged
*/
public void audit(String type, Map<String, Object> data, AuditLevel level) {
// Skip auditing if this level is not enabled or if not Enterprise edition
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
if (!shouldRecord(type, level)) {
return;
}
@@ -192,8 +187,7 @@ public class AuditService {
* @param level The minimum audit level required for this event to be logged
*/
public void audit(String principal, String type, Map<String, Object> data, AuditLevel level) {
// Skip auditing if this level is not enabled or if not Enterprise edition
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
if (!shouldRecord(type, level)) {
return;
}
@@ -223,9 +217,7 @@ public class AuditService {
AuditEventType type,
Map<String, Object> data,
AuditLevel level) {
if (!auditConfig.isEnabled()
|| !auditConfig.getAuditLevel().includes(level)
|| !runningEE) {
if (!shouldRecord(type, level)) {
return;
}
@@ -250,9 +242,7 @@ public class AuditService {
String type,
Map<String, Object> data,
AuditLevel level) {
if (!auditConfig.isEnabled()
|| !auditConfig.getAuditLevel().includes(level)
|| !runningEE) {
if (!shouldRecord(type, level)) {
return;
}
@@ -626,6 +616,55 @@ public class AuditService {
return auditConfig.getAuditLevel().includes(requiredLevel);
}
/**
* Type-aware variant used by the controller aspect, which resolves the event type before
* deciding whether to record. Document-processing events feed the Documents tab (available to
* every Processor user), so they audit without an Enterprise license; the rest of the audit log
* stays Enterprise-only.
*/
public boolean shouldAudit(
AuditEventType eventType, Method method, AuditConfigurationProperties auditConfig) {
if (!auditConfig.isEnabled() || !isLicensedToRecord(eventType)) {
return false;
}
Audited auditedAnnotation = method.getAnnotation(Audited.class);
AuditLevel requiredLevel =
(auditedAnnotation != null) ? auditedAnnotation.level() : AuditLevel.BASIC;
return auditConfig.getAuditLevel().includes(requiredLevel);
}
/**
* Whether an event of this type and level should be persisted: the configured audit level must
* include it and the current license must permit recording it.
*/
private boolean shouldRecord(AuditEventType type, AuditLevel level) {
return auditConfig.isLevelEnabled(level) && isLicensedToRecord(type);
}
private boolean shouldRecord(String type, AuditLevel level) {
return auditConfig.isLevelEnabled(level) && isLicensedToRecord(type);
}
/**
* Whether the current license permits recording this event type. Enterprise records everything;
* without it only document-processing events (PDF_PROCESS, FILE_OPERATION) are captured,
* because they back the Documents tab that is open to every Processor user (still subject to
* audit being enabled at a level that includes them). Everything else stays Enterprise-only.
*/
private boolean isLicensedToRecord(AuditEventType type) {
return runningEE
|| type == AuditEventType.PDF_PROCESS
|| type == AuditEventType.FILE_OPERATION;
}
private boolean isLicensedToRecord(String type) {
return runningEE
|| AuditEventType.PDF_PROCESS.name().equals(type)
|| AuditEventType.FILE_OPERATION.name().equals(type);
}
/**
* Add timing and response status data to the audit record
*
@@ -76,7 +76,8 @@ class ControllerAuditAspectTest {
@DisplayName("shouldAudit false proceeds without recording")
void skipsWhenShouldAuditFalse() throws Throwable {
ProceedingJoinPoint jp = joinPointFor("getEndpoint");
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(false);
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
.thenReturn(false);
when(jp.proceed()).thenReturn("ok");
Object result = aspect.auditGetMethod(jp);
@@ -102,7 +103,8 @@ class ControllerAuditAspectTest {
@DisplayName("records success outcome and returns result")
void recordsSuccess() throws Throwable {
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
.thenReturn(true);
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
@@ -134,7 +136,8 @@ class ControllerAuditAspectTest {
MDC.put("auditPrincipal", "fromMdc");
MDC.put("auditOrigin", "API");
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
.thenReturn(true);
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
.thenReturn(new HashMap<>());
when(auditService.resolveEventType(
@@ -166,7 +169,8 @@ class ControllerAuditAspectTest {
@DisplayName("records failure outcome and rethrows")
void recordsFailureAndRethrows() throws Throwable {
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
.thenReturn(true);
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
@@ -204,7 +208,8 @@ class ControllerAuditAspectTest {
@DisplayName("annotated method proceeds without double-auditing")
void annotatedMethodSkips() throws Throwable {
ProceedingJoinPoint jp = joinPointFor("annotatedEndpoint");
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
.thenReturn(true);
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
when(jp.proceed()).thenReturn("ok");
@@ -232,7 +237,8 @@ class ControllerAuditAspectTest {
@DisplayName("captures result when enabled and non-UI type")
void capturesResult() throws Throwable {
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
.thenReturn(true);
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
@@ -262,7 +268,8 @@ class ControllerAuditAspectTest {
@DisplayName("UI_DATA result is not captured")
void uiDataResultSkipped() throws Throwable {
ProceedingJoinPoint jp = joinPointFor("getEndpoint");
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
.thenReturn(true);
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
@@ -0,0 +1,55 @@
package stirling.software.proprietary.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
class AuditCleanupServiceTest {
private final PersistentAuditEventRepository repository =
mock(PersistentAuditEventRepository.class);
private AuditCleanupService service(boolean runningEE, int retentionDays) {
ApplicationProperties props = new ApplicationProperties();
var audit = props.getPremium().getEnterpriseFeatures().getAudit();
audit.setEnabled(true);
audit.setRetentionDays(retentionDays);
return new AuditCleanupService(
repository, new AuditConfigurationProperties(props), runningEE);
}
@Test
@DisplayName("Enterprise keeps the configured retention, including infinite")
void enterpriseUsesConfigured() {
assertThat(service(true, 90).effectiveRetentionDays()).isEqualTo(90);
assertThat(service(true, 365).effectiveRetentionDays()).isEqualTo(365);
assertThat(service(true, 0).effectiveRetentionDays()).isEqualTo(0);
}
@Test
@DisplayName("non-Enterprise caps retention at 30 days")
void nonEnterpriseCapsHigherValues() {
assertThat(service(false, 90).effectiveRetentionDays()).isEqualTo(30);
assertThat(service(false, 365).effectiveRetentionDays()).isEqualTo(30);
}
@Test
@DisplayName("non-Enterprise respects a shorter configured retention")
void nonEnterpriseRespectsLower() {
assertThat(service(false, 14).effectiveRetentionDays()).isEqualTo(14);
assertThat(service(false, 7).effectiveRetentionDays()).isEqualTo(7);
}
@Test
@DisplayName("non-Enterprise cannot retain forever (<= 0 becomes the cap)")
void nonEnterpriseNoInfinite() {
assertThat(service(false, 0).effectiveRetentionDays()).isEqualTo(30);
assertThat(service(false, -1).effectiveRetentionDays()).isEqualTo(30);
}
}
@@ -166,6 +166,42 @@ class AuditServiceTest {
verify(repository, never()).add(any(AuditEvent.class));
}
@Test
@DisplayName("records document-processing events even without EE (Documents feed)")
void recordsDocumentEventsWithoutEE() {
AuditService nonEe =
new AuditService(
repository, auditConfig, false, pdfDocumentFactory, jwtService);
authenticateAs("alice");
nonEe.audit(AuditEventType.PDF_PROCESS, new HashMap<>(), AuditLevel.BASIC);
nonEe.audit(AuditEventType.FILE_OPERATION, new HashMap<>(), AuditLevel.BASIC);
org.mockito.ArgumentCaptor<AuditEvent> captor =
org.mockito.ArgumentCaptor.forClass(AuditEvent.class);
verify(repository, org.mockito.Mockito.times(2)).add(captor.capture());
assertThat(captor.getAllValues())
.extracting(AuditEvent::getType)
.containsExactly(
AuditEventType.PDF_PROCESS.name(),
AuditEventType.FILE_OPERATION.name());
}
@Test
@DisplayName("type-aware shouldAudit lets doc events through without EE, blocks others")
void typeAwareShouldAuditWithoutEE() throws Exception {
AuditService nonEe =
new AuditService(
repository, auditConfig, false, pdfDocumentFactory, jwtService);
Method m = Object.class.getMethod("toString");
assertThat(nonEe.shouldAudit(AuditEventType.PDF_PROCESS, m, auditConfig)).isTrue();
assertThat(nonEe.shouldAudit(AuditEventType.FILE_OPERATION, m, auditConfig)).isTrue();
assertThat(nonEe.shouldAudit(AuditEventType.USER_LOGIN, m, auditConfig)).isFalse();
// With EE, non-doc events at/under the configured level audit too.
assertThat(service.shouldAudit(AuditEventType.USER_LOGIN, m, auditConfig)).isTrue();
}
@Test
@DisplayName("skips when audit disabled")
void skipsWhenDisabled() {
@@ -0,0 +1,46 @@
package stirling.software.saas.security;
import java.util.List;
import java.util.Objects;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.audit.PortalAuditScope;
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
import stirling.software.proprietary.audit.PortalDocumentsScopeResolver;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
/**
* SaaS documents visibility: platform admins see the whole server; every other portal user sees
* their own team's documents (by member email).
*/
@Component
@Primary
@Profile("saas")
@RequiredArgsConstructor
public class SaasPortalDocumentsScopeResolver implements PortalDocumentsScopeResolver {
private final TeamSecurityExpressions teamSecurity;
private final TeamMembershipRepository membershipRepository;
@Override
public PortalAuditScope resolve() {
if (PortalAuditScopeResolver.hasAdminAuthority()) {
return PortalAuditScope.server();
}
Long teamId = teamSecurity.currentUserTeamId();
if (teamId == null) {
return PortalAuditScope.denied();
}
List<String> memberEmails =
membershipRepository.findByTeamId(teamId).stream()
.map(m -> m.getUser() == null ? null : m.getUser().getEmail())
.filter(Objects::nonNull)
.toList();
return PortalAuditScope.team("team:" + teamId, memberEmails);
}
}
+36
View File
@@ -96,6 +96,30 @@ RUN apt-get update \
&& sh /tmp/slim-onnxruntime.sh /layers/dependencies
# Stage 2b: AI engine. Built at its final path so the venv resolves after the copy, on uv's
# managed CPython because the runtime base ships Python 3.12.
FROM ghcr.io/astral-sh/uv:bookworm-slim@sha256:22334efe746f1b69217d455049b484d7b8cacfb2d5f42555580b62415a98e0a3 AS engine-build
ENV UV_PYTHON_INSTALL_DIR=/opt/stirling-engine/python
WORKDIR /opt/stirling-engine
COPY engine/pyproject.toml engine/uv.lock ./
# One layer: trimming in a second RUN would cache the untrimmed copy too, and this build
# exports every layer to a GHA cache that is capped repo-wide. Trimming saves ~20MB.
RUN --mount=type=cache,target=/root/.cache/uv \
set -eux; \
apt-get update && apt-get install -y --no-install-recommends binutils; \
uv python install 3.13; \
uv sync --frozen --no-dev --no-install-project --group engine --python-preference only-managed; \
P="$(ls -d /opt/stirling-engine/python/cpython-*)"; \
rm -rf "$P/share" "$P/include" \
"$P/lib/python3.13/idlelib" "$P/lib/python3.13/tkinter" \
"$P/lib/python3.13/ensurepip" "$P/lib/python3.13/pydoc_data" \
"$P/lib/python3.13/test" "$P/lib/python3.13/lib2to3"; \
find /opt/stirling-engine -name '__pycache__' -type d -prune -exec rm -rf {} + ; \
find /opt/stirling-engine \( -name '*.so' -o -name '*.so.*' \) -print0 \
| xargs -0 -r strip --strip-unneeded 2>/dev/null || true; \
apt-get purge -y binutils; apt-get autoremove -y; rm -rf /var/lib/apt/lists/*
# Stage 3: Final runtime image on top of pre-built base
FROM ${BASE_IMAGE}
@@ -113,6 +137,12 @@ COPY --link --from=app-build --chown=1000:1000 \
/app/build/libs/restart-helper.jar /restart-helper.jar
COPY --link --chown=1000:1000 scripts/ /scripts/
# init-without-ocr.sh starts the engine when this directory exists, so other images are unaffected.
COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/python /opt/stirling-engine/python
COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/.venv /opt/stirling-engine/.venv
COPY --link --chown=1000:1000 engine/.env /opt/stirling-engine/.env
COPY --link --chown=1000:1000 engine/src/ /opt/stirling-engine/src/
# Pre-bundled default detection model; FormDetectionModelManager seeds it into /configs on startup.
COPY --link --from=app-build --chown=1000:1000 /preinstalled-models/ /opt/stirling/preinstalled-models/
@@ -129,6 +159,8 @@ RUN set -eux; \
ln -s /storage /app/storage; \
chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \
chown stirlingpdfuser:stirlingpdfgroup /app; \
mkdir -p /opt/stirling-engine/data; \
chown -R stirlingpdfuser:stirlingpdfgroup /opt/stirling-engine/data; \
chmod 750 /tmp/stirling-pdf; \
chmod 750 /tmp/stirling-pdf/heap_dumps; \
fc-cache -f
@@ -149,6 +181,10 @@ ENV VERSION_TAG=$VERSION_TAG \
PGID=1000 \
UMASK=022 \
FAT_DOCKER=true \
AIENGINE_ENABLED=true \
STIRLING_ENGINE_HOME=/opt/stirling-engine \
STIRLING_ENGINE_PORT=5001 \
STIRLING_ENGINE_WORKERS=2 \
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
+29 -21
View File
@@ -1,35 +1,43 @@
# syntax=docker/dockerfile:1.5
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca
ARG TASK_VERSION=3.52.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& ARCH=$(dpkg --print-architecture) \
&& curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_${TASK_VERSION}_linux_${ARCH}.deb" -o /tmp/task.deb \
&& dpkg -i /tmp/task.deb \
&& rm /tmp/task.deb \
&& rm -rf /var/lib/apt/lists/*
# uv resolves the venv here so its ~52MB binary stays out of the runtime image.
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca AS builder
# Source under /app/engine/ to match root Taskfile's `includes.engine.dir: engine`.
WORKDIR /app/engine
COPY engine/pyproject.toml engine/uv.lock engine/.env ./
COPY engine/scripts/ ./scripts/
COPY engine/pyproject.toml engine/uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --group engine
uv sync --frozen --no-dev --no-install-project --group engine
COPY engine/src/ ./src/
WORKDIR /app
COPY Taskfile.yml ./
COPY .taskfiles/ ./.taskfiles/
FROM python:3.13-slim-bookworm@sha256:00faa2debb87529f9f0764e9491d8ba400a3678976616c3bd7cb193745ac20d1 AS runtime
# Created before the COPYs so they land owned; a later chown -R duplicates the venv layer.
RUN set -eux; \
groupadd --system --gid 1000 stirling; \
useradd --system --uid 1000 --gid 1000 --home /app/engine stirling; \
mkdir -p /app/engine/data; \
chown stirling:stirling /app/engine /app/engine/data
WORKDIR /app/engine
COPY --from=builder --chown=stirling:stirling /app/engine/.venv ./.venv
# settings.py resolves ENGINE_ROOT to /app/engine, so .env must sit here.
COPY --chown=stirling:stirling engine/.env ./
COPY --chown=stirling:stirling engine/src/ ./src/
ENV PATH="/app/engine/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
ENV STIRLING_ENGINE_WORKERS=4
# Container runs on a fixed port; skip the host-only free-port probe (its script
# is not shipped in the image). engine:run honours these.
ENV ENGINE_PORT_PROBE=false
ENV STIRLING_ENGINE_PORT=5001
# Fail closed: without a secret the document routes trust caller-supplied X-User-Id.
# Set STIRLING_ENGINE_SHARED_SECRET (the backend sends it as X-Engine-Auth), or false to opt out.
ENV STIRLING_ENGINE_REQUIRE_AUTH=true
# `stirling` resolves from the working directory.
WORKDIR /app/engine/src
USER stirling
EXPOSE 5001
CMD ["task", "engine:run"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD ["python", "-c", "import os,sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:%s/health' % os.environ.get('STIRLING_ENGINE_PORT','5001'), timeout=4).status==200 else 1)"]
CMD ["sh", "-c", "exec uvicorn stirling.api.app:app --host 0.0.0.0 --port ${STIRLING_ENGINE_PORT:-5001} --workers ${STIRLING_ENGINE_WORKERS:-4}"]
+3 -2
View File
@@ -16,8 +16,9 @@ engine = [
"psycopg[binary,pool]>=3.3.4",
"pydantic>=2.13.4",
# <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration.
"pydantic-ai>=1.107.2,<2.0.0",
"pydantic-ai-slim[voyageai]>=1.107.2,<2.0.0",
# Explicit extras: the `pydantic-ai` meta-package pulls all 20 providers (~230MB).
# No `voyageai` extra either; stirling.documents.voyage speaks its API directly.
"pydantic-ai-slim[anthropic,openai]>=1.107.2,<2.0.0",
"pydantic-settings>=2.15.0",
"python-dotenv>=1.2.2",
"sqlite-vec>=0.1.9",
+13
View File
@@ -70,6 +70,19 @@ Provider credentials (and any local overrides) go in the uncommitted
VOYAGE_API_KEY=your-key
```
### Embedding providers
`STIRLING_RAG_EMBEDDING_MODEL` is a `provider:model` string. Any OpenAI-compatible
`/v1/embeddings` endpoint (vLLM, Ollama, TEI, llama.cpp) works by pointing a base URL
at it. Note `OPENAI_BASE_URL` is global and also redirects chat completions; push
`provider`/`api_key`/`base_url` through admin AI settings to move embeddings only.
Ollama reads `OLLAMA_BASE_URL`, and omitting it fails the first embed call, not startup.
```
STIRLING_RAG_EMBEDDING_MODEL=ollama:nomic-embed-text
OLLAMA_BASE_URL=http://ollama:11434/v1
```
## Backends
**`sqlite`** - Embedded sqlite-vec. Single `.db` file, zero ops. Ideal for dev
+11 -1
View File
@@ -6,6 +6,7 @@ from pydantic_ai.providers.openai import OpenAIProvider
from stirling.documents.chunker import chunk_text
from stirling.documents.store import Document
from stirling.documents.voyage import build_voyage_model
# Keep each upstream embed request under every major provider's per-call limit while
# still batching large enough that a book-sized document ingests in a reasonable number
@@ -14,6 +15,9 @@ from stirling.documents.store import Document
DEFAULT_EMBED_BATCH_SIZE = 256
VOYAGE_PROVIDER = "voyageai"
def _build_embedder(
model_name: str,
*,
@@ -23,11 +27,17 @@ def _build_embedder(
) -> Embedder:
"""Construct an :class:`Embedder`; explicit provider/api_key/base_url is the config-push path, else env form."""
if not provider and not api_key and not base_url:
# Env form is a "provider:model" string; Voyage needs the SDK-free adapter.
env_provider, sep, env_model = model_name.partition(":")
if sep and env_provider.lower() == VOYAGE_PROVIDER:
return Embedder(build_voyage_model(env_model))
return Embedder(model_name)
provider_name = (provider or "").lower()
key = api_key or None
if provider_name in ("voyageai", "openai"):
if provider_name == VOYAGE_PROVIDER:
return Embedder(build_voyage_model(model_name, api_key=key, base_url=base_url or None))
if provider_name == "openai":
return Embedder(f"{provider_name}:{model_name}")
if provider_name in ("ollama", "custom"):
openai_provider = OpenAIProvider(base_url=base_url or None, api_key=key or "ollama")
@@ -2,9 +2,11 @@ from __future__ import annotations
import asyncio
import json
import logging
import math
import re
import sqlite3
import time
from datetime import UTC, datetime
from pathlib import Path
@@ -19,11 +21,31 @@ _READ_PERMISSION = "read"
# write lock. With multiple worker processes opening the same file, they collide on
# startup schema-init and get "database is locked". Wait for the lock instead.
_BUSY_TIMEOUT_MS = 5000
# journal_mode answers SQLITE_BUSY without consulting the busy handler, so it needs its own retry.
_WAL_SWITCH_ATTEMPTS = 10
_WAL_RETRY_DELAY_S = 0.1
# sqlite stores TIMESTAMP as TEXT. We normalise to UTC ISO 8601 ``YYYY-MM-DD HH:MM:SS``
# so lexicographic comparison against ``datetime('now')`` matches chronological order.
_SQLITE_DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
logger = logging.getLogger(__name__)
def _enable_wal(conn: sqlite3.Connection) -> None:
"""Switch the connection to WAL, tolerating workers racing to do the same."""
for _ in range(_WAL_SWITCH_ATTEMPTS):
try:
conn.execute("PRAGMA journal_mode=WAL")
return
except sqlite3.OperationalError:
row = conn.execute("PRAGMA journal_mode").fetchone()
if row is not None and str(row[0]).lower() == "wal":
return # another worker won the race and already switched it
time.sleep(_WAL_RETRY_DELAY_S)
logger.warning("Could not switch the document store to WAL; continuing on the default journal mode.")
def _to_sqlite_utc(dt: datetime | None) -> str | None:
if dt is None:
return None
@@ -58,7 +80,7 @@ class SqliteVecStore(DocumentStore):
if self._db_path is not None:
# Set before the WAL switch below: that pragma also takes the lock.
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
conn.execute("PRAGMA journal_mode=WAL")
_enable_wal(conn)
self._conn = conn
self._lock = asyncio.Lock()
+57
View File
@@ -0,0 +1,57 @@
"""VoyageAI embeddings over its OpenAI-shaped REST API.
The `voyageai` SDK pulls PIL, numpy, tokenizers and langchain at import for multimodal,
chunking and local-inference features the engine never uses (~207MB).
"""
from __future__ import annotations
import os
from collections.abc import Sequence
from pydantic_ai.embeddings import EmbeddingResult, EmbeddingSettings
from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
from pydantic_ai.embeddings.result import EmbedInputType
from pydantic_ai.providers.openai import OpenAIProvider
VOYAGE_BASE_URL = "https://api.voyageai.com/v1"
VOYAGE_API_KEY_ENV = "VOYAGE_API_KEY"
# Keeps a keyless engine bootable, and stops the client falling back to OPENAI_API_KEY.
_MISSING_API_KEY = "stirling-voyage-api-key-not-configured"
class VoyageEmbeddingModel(OpenAIEmbeddingModel):
"""Voyage embeddings spoken over the OpenAI wire format."""
async def embed(
self,
inputs: str | Sequence[str],
*,
input_type: EmbedInputType,
settings: EmbeddingSettings | None = None,
) -> EmbeddingResult:
"""Embed `inputs`, forwarding Voyage's `input_type` that the OpenAI model drops."""
if self._client.api_key == _MISSING_API_KEY:
raise ValueError(
f"VoyageAI embeddings need an API key: set {VOYAGE_API_KEY_ENV} or push one via admin AI settings."
)
merged: EmbeddingSettings = {**(settings or {})}
# extra_body is declared `object`, so narrow rather than assume a mapping.
current = merged.get("extra_body")
extra_body: dict[str, object] = dict(current) if isinstance(current, dict) else {}
extra_body.setdefault("input_type", input_type)
merged["extra_body"] = extra_body
return await super().embed(inputs, input_type=input_type, settings=merged)
def build_voyage_model(
model_name: str,
*,
api_key: str | None = None,
base_url: str | None = None,
) -> VoyageEmbeddingModel:
"""Build a Voyage embedding model; a missing key only fails once an embed is attempted."""
key = api_key or os.environ.get(VOYAGE_API_KEY_ENV) or _MISSING_API_KEY
provider = OpenAIProvider(base_url=base_url or VOYAGE_BASE_URL, api_key=key)
return VoyageEmbeddingModel(model_name, provider=provider)
+18
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from pathlib import Path
import pytest
from stirling.contracts import PageText
@@ -647,3 +649,19 @@ def _dummy_tool_def() -> object:
"""Sentinel passed to ``_prepare_search_knowledge``. The callback only inspects
``_search_count``; it doesn't read anything off the tool_def or context."""
return object()
# concurrent store startup
def test_many_stores_open_the_same_file_without_locking_out(tmp_path: Path) -> None:
"""Workers all construct a store against one file on boot; the WAL switch races."""
import concurrent.futures
db_path = tmp_path / "rag.db"
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
stores = list(pool.map(lambda _: SqliteVecStore(db_path), range(8)))
assert len(stores) == 8
mode = stores[0]._conn.execute("PRAGMA journal_mode").fetchone()[0]
assert str(mode).lower() == "wal"
+205
View File
@@ -0,0 +1,205 @@
from __future__ import annotations
import json
import math
import os
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
import httpx
import pytest
from pydantic_ai import Embedder
from pydantic_ai.providers.openai import OpenAIProvider
from stirling.documents.embedder import _build_embedder
from stirling.documents.voyage import VOYAGE_BASE_URL, VoyageEmbeddingModel, build_voyage_model
# Voyage's documented response body: OpenAI's shape, minus prompt_tokens.
VOYAGE_RESPONSE = {
"object": "list",
"data": [
{"object": "embedding", "embedding": [0.1, 0.2, 0.3], "index": 0},
{"object": "embedding", "embedding": [0.4, 0.5, 0.6], "index": 1},
],
"model": "voyage-4",
"usage": {"total_tokens": 7},
}
@dataclass
class SentRequest:
url: str
auth: str | None
body: dict[str, Any]
def _recording_model(sent: list[SentRequest]) -> VoyageEmbeddingModel:
def handler(request: httpx.Request) -> httpx.Response:
sent.append(
SentRequest(
url=str(request.url),
auth=request.headers.get("authorization"),
body=json.loads(request.content),
)
)
return httpx.Response(200, json=VOYAGE_RESPONSE)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
provider = OpenAIProvider(base_url=VOYAGE_BASE_URL, api_key="pa-test-key", http_client=client)
return VoyageEmbeddingModel("voyage-4", provider=provider)
@pytest.mark.anyio
async def test_posts_to_voyage_embeddings_endpoint_with_bearer_auth() -> None:
sent: list[SentRequest] = []
await Embedder(_recording_model(sent)).embed_documents(["alpha", "beta"])
assert sent[0].url == f"{VOYAGE_BASE_URL}/embeddings"
assert sent[0].auth == "Bearer pa-test-key"
assert sent[0].body["model"] == "voyage-4"
assert sent[0].body["input"] == ["alpha", "beta"]
@pytest.mark.anyio
@pytest.mark.parametrize(
("call", "expected"),
[("embed_query", "query"), ("embed_documents", "document")],
)
async def test_forwards_voyage_input_type(call: str, expected: str) -> None:
"""The stock OpenAI model drops this field; Voyage needs it."""
sent: list[SentRequest] = []
embedder = Embedder(_recording_model(sent))
await getattr(embedder, call)(["text"])
assert sent[0].body["input_type"] == expected
@pytest.mark.anyio
async def test_caller_settings_win_over_the_default_input_type() -> None:
sent: list[SentRequest] = []
await Embedder(_recording_model(sent)).embed_documents(
["text"], settings={"extra_body": {"input_type": "query", "output_dimension": 512}}
)
assert sent[0].body["input_type"] == "query"
assert sent[0].body["output_dimension"] == 512
@pytest.mark.anyio
async def test_parses_voyage_response_into_embeddings() -> None:
result = await Embedder(_recording_model([])).embed_documents(["alpha", "beta"])
assert result.embeddings == [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]
def test_build_voyage_model_reads_the_api_key_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key")
assert build_voyage_model("voyage-4").model_name == "voyage-4"
def test_build_voyage_model_without_a_key_still_constructs(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
assert build_voyage_model("voyage-4").model_name == "voyage-4"
@pytest.mark.anyio
async def test_embedding_without_a_key_fails_with_a_clear_error(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
model = build_voyage_model("voyage-4")
with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"):
await Embedder(model).embed_documents(["text"])
@pytest.mark.anyio
async def test_an_openai_key_is_never_sent_to_voyage(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-secret")
model = build_voyage_model("voyage-4")
with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"):
await Embedder(model).embed_documents(["text"])
def test_env_form_routes_voyageai_through_the_adapter(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key")
embedder = _build_embedder("voyageai:voyage-4")
assert isinstance(embedder.model, VoyageEmbeddingModel)
assert embedder.model.model_name == "voyage-4"
def test_config_push_form_routes_voyageai_through_the_adapter() -> None:
embedder = _build_embedder("voyage-4", provider="voyageai", api_key="pa-pushed-key")
assert isinstance(embedder.model, VoyageEmbeddingModel)
def test_the_voyageai_sdk_is_not_installed() -> None:
"""Guards the ~207MB the SDK would add back."""
with pytest.raises(ImportError):
__import__("voyageai")
# Live checks, skipped unless VOYAGE_API_KEY is set so CI stays offline.
live_only = pytest.mark.skipif(
not os.environ.get("VOYAGE_API_KEY"),
reason="set VOYAGE_API_KEY to run the live VoyageAI checks",
)
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
dot = sum(x * y for x, y in zip(a, b, strict=True))
return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
@live_only
@pytest.mark.anyio
async def test_live_voyage_returns_usable_embeddings() -> None:
result = await Embedder(build_voyage_model("voyage-4")).embed_documents(["alpha", "beta"])
assert len(result.embeddings) == 2
assert len(result.embeddings[0]) == 1024
@live_only
@pytest.mark.anyio
async def test_live_voyage_honours_input_type_server_side() -> None:
"""Voyage embeds the same text differently per input_type."""
embedder = Embedder(build_voyage_model("voyage-4"))
text = "How do I combine two PDFs?"
as_query = await embedder.embed_query(text)
as_document = await embedder.embed_documents([text])
assert _cosine(as_query.embeddings[0], as_document.embeddings[0]) < 0.999
@live_only
@pytest.mark.anyio
async def test_live_voyage_ranks_the_relevant_document_first() -> None:
embedder = Embedder(build_voyage_model("voyage-4"))
docs = await embedder.embed_documents(
["Stirling PDF merges and splits PDF files.", "The capital of France is Paris."]
)
query = await embedder.embed_query("How do I combine two PDFs?")
relevant = _cosine(query.embeddings[0], docs.embeddings[0])
irrelevant = _cosine(query.embeddings[0], docs.embeddings[1])
assert relevant > irrelevant
@live_only
@pytest.mark.anyio
async def test_live_voyage_accepts_voyage_only_parameters() -> None:
"""output_dimension has no OpenAI equivalent, so this proves extra_body lands."""
result = await Embedder(build_voyage_model("voyage-4")).embed_documents(
["dimension test"], settings={"extra_body": {"output_dimension": 256}}
)
assert len(result.embeddings[0]) == 256
+2 -1777
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -5,7 +5,16 @@ import { QueryClient, type DefaultOptions } from "@tanstack/react-query";
export const baseQueryOptions: DefaultOptions["queries"] = {
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: 1,
// Retry once on transient failures, but never on a 4xx: an auth/forbidden/not-found
// response won't change on a second identical request, so retrying just doubles the
// wait (e.g. a 403 firing twice) before the UI settles.
retry: (failureCount, error) => {
const status = (error as { status?: number } | null)?.status;
if (typeof status === "number" && status >= 400 && status < 500) {
return false;
}
return failureCount < 1;
},
networkMode: "always",
refetchOnWindowFocus: false,
};
@@ -0,0 +1,13 @@
// SaaS enterprise flag: derived from the plan tier (wallet-backed), not a local
// license bean. Shadows the self-hosted app-config version so Enterprise-only
// surfaces (e.g. Infrastructure > Audit) unlock for enterprise-plan tenants.
import { useTier } from "@portal/contexts/TierContext";
import type { EnterpriseState } from "@portal-proprietary/hooks/useEnterpriseEnabled";
export type { EnterpriseState };
export function useEnterpriseEnabled(): EnterpriseState {
const { tier } = useTier();
return { enabled: tier === "enterprise", loading: false };
}
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
vi.mock("react-i18next", () => ({
@@ -7,6 +7,12 @@ vi.mock("react-i18next", () => ({
i18n: { changeLanguage: vi.fn() },
}),
}));
const enterprise = { enabled: true, loading: false };
vi.mock("@portal/hooks/useEnterpriseEnabled", () => ({
useEnterpriseEnabled: () => enterprise,
}));
// Stub the live tab panels so the test doesn't pull their data dependencies.
vi.mock("@portal/components/infrastructure/ApiKeysTab", () => ({
ApiKeysTab: () => <div data-testid="api-keys-tab" />,
@@ -18,6 +24,11 @@ vi.mock("@portal/components/infrastructure/AuditTab", () => ({
import { Infrastructure } from "@portal/views/Infrastructure";
describe("Infrastructure (SaaS)", () => {
beforeEach(() => {
enterprise.enabled = true;
enterprise.loading = false;
});
it("defaults to the live API keys tab and drops the manage-editor button", () => {
render(<Infrastructure />);
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
@@ -41,4 +52,14 @@ describe("Infrastructure (SaaS)", () => {
}),
).toBeEnabled();
});
it("disables the audit tab for non-enterprise tenants", () => {
enterprise.enabled = false;
render(<Infrastructure />);
expect(
screen.getByRole("button", {
name: /portal.infrastructure.tabs.audit/,
}),
).toBeDisabled();
});
});
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Tabs, type TabItem } from "@app/ui";
import { useEnterpriseEnabled } from "@portal/hooks/useEnterpriseEnabled";
import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab";
import { AuditTab } from "@portal/components/infrastructure/AuditTab";
import "@portal/views/Infrastructure.css";
@@ -21,6 +22,8 @@ type InfraTab =
export function Infrastructure() {
const { t } = useTranslation();
const [tab, setTab] = useState<InfraTab>("api-keys");
// Audit is Enterprise-only; disabled (greyed, inert) for non-enterprise tenants.
const auditEnabled = useEnterpriseEnabled().enabled;
const comingSoon = (labelKey: string) => (
<>
@@ -33,7 +36,11 @@ export function Infrastructure() {
const tabs: TabItem<InfraTab>[] = [
{ key: "api-keys", label: t("portal.infrastructure.tabs.apiKeys") },
{ key: "audit", label: t("portal.infrastructure.tabs.audit") },
{
key: "audit",
label: t("portal.infrastructure.tabs.audit"),
disabled: !auditEnabled,
},
{
key: "deployments",
label: comingSoon("portal.infrastructure.tabs.deployments"),
@@ -29,6 +29,9 @@ import {
type AuditFilter = "all" | AuditCategory;
// Enterprise-only: the Infrastructure view disables this tab for non-enterprise
// instances, so this component only ever renders when entitled. The backend still
// scopes the log to admins / team leads (403 -> forbidden state below).
export function AuditTab() {
const { t } = useTranslation();
const { tier } = useTier();
@@ -0,0 +1,17 @@
// Enterprise-license flag for the portal, from the backend app-config (`runningEE`).
// Gates Enterprise-only surfaces (e.g. Infrastructure > Audit) so they show a locked
// upsell instead of firing a doomed 403 request. The SaaS build shadows this file to
// derive enterprise from the plan tier (wallet-backed) - see portal-saas.
import { useAppConfig } from "@app/contexts/AppConfigContext";
export interface EnterpriseState {
enabled: boolean;
// True while the flag is still resolving, so callers can hold rather than flash a lock.
loading: boolean;
}
export function useEnterpriseEnabled(): EnterpriseState {
const { config, loading } = useAppConfig();
return { enabled: Boolean(config?.runningEE), loading };
}
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
@@ -14,6 +14,11 @@ vi.mock("@portal/contexts/ViewContext", () => ({
useView: () => ({ setActiveView: vi.fn() }),
}));
const enterprise = { enabled: true, loading: false };
vi.mock("@portal/hooks/useEnterpriseEnabled", () => ({
useEnterpriseEnabled: () => enterprise,
}));
vi.mock("@portal/components/infrastructure/ApiKeysTab", () => ({
ApiKeysTab: () => <div data-testid="api-keys-tab" />,
}));
@@ -42,6 +47,11 @@ function tabButtons() {
}
describe("Infrastructure view", () => {
beforeEach(() => {
enterprise.enabled = true;
enterprise.loading = false;
});
it("orders the working tabs first and defaults to API keys", () => {
renderView();
@@ -91,4 +101,24 @@ describe("Infrastructure view", () => {
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument();
});
it("disables the audit tab (inert, never opens) for non-enterprise users", () => {
enterprise.enabled = false;
renderView();
const auditBtn = screen.getByRole("button", { name: `${T}.audit` });
expect(auditBtn).toBeDisabled();
fireEvent.click(auditBtn);
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument();
});
it("ignores a ?tab=audit deep link when not enterprise", () => {
enterprise.enabled = false;
renderView("/infrastructure?tab=audit");
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument();
});
});
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button, Tabs, type TabItem } from "@app/ui";
import { useView } from "@portal/contexts/ViewContext";
import { useEnterpriseEnabled } from "@portal/hooks/useEnterpriseEnabled";
import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab";
import { AuditTab } from "@portal/components/infrastructure/AuditTab";
import "@portal/views/Infrastructure.css";
@@ -12,30 +13,39 @@ type InfraTab = "api-keys" | "audit";
/** Shown but inert: no backend behind these screens yet. */
type DisabledInfraTab = "deployments" | "security" | "models" | "storage";
const ENABLED_TABS: InfraTab[] = ["api-keys", "audit"];
export function Infrastructure() {
const { t } = useTranslation();
const [tab, setTab] = useState<InfraTab>("api-keys");
const { setActiveView } = useView();
const [searchParams, setSearchParams] = useSearchParams();
// Audit is Enterprise-only; disabled (greyed, inert) on non-enterprise instances.
const auditEnabled = useEnterpriseEnabled().enabled;
const canOpenTab = useCallback(
(key: string) => key === "api-keys" || (key === "audit" && auditEnabled),
[auditEnabled],
);
// Deep-link (?tab=<key>) from elsewhere (e.g. the home visualiser's outcome
// cards → audit log): open that tab, then drop the param.
useEffect(() => {
const requested = searchParams.get("tab");
if (!requested) return;
if ((ENABLED_TABS as string[]).includes(requested)) {
if (canOpenTab(requested)) {
setTab(requested as InfraTab);
}
const next = new URLSearchParams(searchParams);
next.delete("tab");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
}, [searchParams, setSearchParams, canOpenTab]);
const tabs: TabItem<InfraTab | DisabledInfraTab>[] = [
{ key: "api-keys", label: t("portal.infrastructure.tabs.apiKeys") },
{ key: "audit", label: t("portal.infrastructure.tabs.audit") },
{
key: "audit",
label: t("portal.infrastructure.tabs.audit"),
disabled: !auditEnabled,
},
{
key: "deployments",
label: t("portal.infrastructure.tabs.deployments"),
@@ -78,7 +88,7 @@ export function Infrastructure() {
items={tabs}
activeKey={tab}
onChange={(key) => {
if ((ENABLED_TABS as string[]).includes(key)) setTab(key as InfraTab);
if (canOpenTab(key)) setTab(key as InfraTab);
}}
variant="underline"
ariaLabel={t("portal.infrastructure.sectionsAriaLabel")}
+43
View File
@@ -890,6 +890,11 @@ log "Setting permissions..."
mkdir -p /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps /logs /configs /configs/heap_dumps /configs/cache /customFiles /pipeline /storage || true
CHOWN_PATHS=("$HOME" "/logs" "/scripts" "/configs" "/customFiles" "/pipeline" "/storage" "/tmp/stirling-pdf" "/app.jar")
[ -d /usr/share/fonts/truetype ] && CHOWN_PATHS+=("/usr/share/fonts/truetype")
# Chowned here rather than at build time so it follows PUID/PGID remapping.
if [ -d "${STIRLING_ENGINE_HOME:-/opt/stirling-engine}" ]; then
mkdir -p "${STIRLING_ENGINE_HOME:-/opt/stirling-engine}/data" || true
CHOWN_PATHS+=("${STIRLING_ENGINE_HOME:-/opt/stirling-engine}/data")
fi
CHOWN_OK=true
for p in "${CHOWN_PATHS[@]}"; do
if [ -e "$p" ]; then
@@ -962,6 +967,38 @@ else
JAVA_CMD+=("org.springframework.boot.loader.launch.JarLauncher")
fi
# ---------- AI engine ----------
# Only the fat image ships it. The backend already defaults to http://localhost:5001.
STIRLING_ENGINE_HOME="${STIRLING_ENGINE_HOME:-/opt/stirling-engine}"
ENGINE_PID=""
if [ -x "$STIRLING_ENGINE_HOME/.venv/bin/python" ] && [ "${AIENGINE_ENABLED:-true}" = "false" ]; then
log "AI engine bundled but AIENGINE_ENABLED=false; not starting it."
elif [ -x "$STIRLING_ENGINE_HOME/.venv/bin/python" ]; then
log "Starting bundled AI engine on port ${STIRLING_ENGINE_PORT:-5001}..."
ENGINE_CMD=(
"$STIRLING_ENGINE_HOME/.venv/bin/python" -m uvicorn
stirling.api.app:app
--host 127.0.0.1
--port "${STIRLING_ENGINE_PORT:-5001}"
--workers "${STIRLING_ENGINE_WORKERS:-2}"
--app-dir "$STIRLING_ENGINE_HOME/src"
)
# init.sh exports PYTHONPATH for unoserver's 3.12 venv; inheriting it breaks the 3.13 engine.
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
env -u PYTHONPATH "${ENGINE_CMD[@]}" &
elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then
env -u PYTHONPATH \
HOME="$(getent passwd "$RUNTIME_USER" | cut -d: -f6)" \
USER="$RUNTIME_USER" \
LOGNAME="$RUNTIME_USER" \
setpriv --reuid="$RUNTIME_USER" --regid="$(id -gn "$RUNTIME_USER")" --init-groups -- "${ENGINE_CMD[@]}" &
else
env -u PYTHONPATH "${ENGINE_CMD[@]}" &
fi
ENGINE_PID=$!
log "AI engine started (PID $ENGINE_PID)"
fi
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
"${JAVA_CMD[@]}" &
elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then
@@ -1074,6 +1111,12 @@ fi
wait "$JAVA_PID" || true
exit_code=$?
if [ -n "$ENGINE_PID" ] && kill -0 "$ENGINE_PID" 2>/dev/null; then
log "Stopping AI engine (PID $ENGINE_PID)..."
kill "$ENGINE_PID" 2>/dev/null || true
wait "$ENGINE_PID" 2>/dev/null || true
fi
case "$exit_code" in
0) log "Stirling PDF exited normally." ;;
137) log "Stirling PDF was OOM-killed (exit 137). Check container memory limits." ;;