Merge branch 'main' into pdf-text-editor-v2-pdfium-2

This commit is contained in:
Anthony Stirling
2026-08-24 22:20:57 +01:00
152 changed files with 2469 additions and 2512 deletions
+8
View File
@@ -96,6 +96,14 @@ configs/
__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.*
+2 -13
View File
@@ -22,26 +22,15 @@ indent_size = 4
[*.html]
indent_size = 2
insert_final_newline = false
trim_trailing_whitespace = false
[{*.js,*.jsx,*.mjs,*.ts,*.tsx}]
[{*.js,*.jsx,*.mjs,*.ts,*.tsx,*.mts}]
indent_size = 2
[*.css]
# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards.
indent_size = 2
[*.{yml,yaml}]
# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices.
indent_size = 2
insert_final_newline = false
trim_trailing_whitespace = false
[*.json]
# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting.
indent_size = 2
[*.jsonc]
# JSONC (JSON with comments) files also follow the standard JSON formatting with an indent size of 2 spaces.
[*.{json,jsonc}]
indent_size = 2
+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
+5 -4
View File
@@ -375,13 +375,13 @@ tasks:
desc: "Auto-fix code formatting"
deps: [install]
cmds:
- npx prettier --write .
- npx oxfmt --write .
format:check:
desc: "Check code formatting"
deps: [install]
cmds:
- npx prettier --check .
- npx oxfmt --check .
fix:
desc: "Auto-fix lint and format"
@@ -554,6 +554,7 @@ tasks:
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts
- task: format
sources:
- editor/scripts/generate-tool-api-types.mts
- ../SwaggerDoc.json
@@ -563,9 +564,9 @@ tasks:
tool-models:check:
desc: "Fail if committed tool API types are out of date"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts --check
- task: tool-models
- git diff --exit-code -- editor/src/core/types/toolApiTypes.ts editor/src/core/types/toolIO.ts
licenses:generate:
desc: "Generate frontend license report"
+1 -1
View File
@@ -18,7 +18,7 @@ dependencies {
api "org.apache.pdfbox:preflight:$pdfboxVersion"
api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
api 'org.snakeyaml:snakeyaml-engine:3.1.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:9.3.2'
@@ -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();
}
@@ -9,6 +9,7 @@ import java.util.Map;
import java.util.Set;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
@@ -20,12 +21,13 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.service.UserServiceInterface;
@@ -48,11 +50,13 @@ import tools.jackson.databind.node.ObjectNode;
* <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
* 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.
* {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF.
*
* <p>Published in the API spec rather than hidden, so the tool-model generator emits it and a
* pipeline can name it as a step like any other tool. Classification is a thing a pipeline does,
* not a thing only the Classification policy may do.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/ai/tools")
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
@@ -99,19 +103,31 @@ public class ClassifyLabelController {
}
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
// PDF in, the same PDF out with a verdict on it, so a chain can be checked across this step.
@ToolIO(accepts = ToolFormat.PDF, produces = ToolFormat.PDF)
@Operation(
summary = "Classify a PDF and label its metadata",
description =
"Reads the first two and last two pages, classifies the document via the AI"
+ " engine, and stores the result in the StirlingPDFClassification"
+ " metadata field. Dispatched by the Classification policy; not"
+ " intended for direct client use.")
+ " metadata field. A document that already carries a verdict is"
+ " passed through untouched unless reclassify=true.")
public ResponseEntity<Resource> classifyAndLabel(
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
@RequestParam("fileInput") MultipartFile fileInput,
@RequestParam(value = "reclassify", defaultValue = "false") boolean reclassify)
throws IOException {
aiFeatureGate.requireClassify();
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
String fileName = safeFileName(fileInput.getOriginalFilename());
if (!reclassify && isClassified(document)) {
// Classifying twice costs a second engine call and charges for it, and a document
// that already carries a verdict has nothing new to learn. A pipeline can run this
// step over a mixed batch without paying for the ones already done.
log.debug("[classify-and-label] {} already classified; passing through", fileName);
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
}
List<EngineLabel> allowed = resolveAllowedLabels();
if (allowed.isEmpty()) {
// No vocabulary to classify against: pass the file through unlabelled rather than
@@ -135,6 +151,25 @@ public class ClassifyLabelController {
}
}
/**
* Whether a verdict is already on the document.
*
* <p>This only reads back what a previous run of this step wrote. It is not a statement that
* the verdict is trustworthy: the key is ordinary PDF metadata that whoever supplied the file
* can set. Skipping the engine on the strength of it is safe because the cost of being wrong is
* a missing re-classification, not a wrong decision. Anything that makes a SECURITY decision
* from this field - routing a document somewhere on the strength of its label, say - must
* classify with {@code reclassify=true} rather than trust what arrived.
*/
private static boolean isClassified(PDDocument document) {
PDDocumentInformation info = document.getDocumentInformation();
if (info == null) {
return false;
}
String existing = info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY);
return existing != null && !existing.isBlank();
}
private List<AiPageText> extractWindow(PDDocument document) throws IOException {
List<AiPageText> pages = new ArrayList<>();
for (int pageNumber : windowPageNumbers(document.getNumberOfPages(), WINDOW_PAGES)) {
@@ -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 =
@@ -85,6 +85,11 @@ public record Policy(
return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId);
}
/** A copy under a different owner (e.g. moving a seed off a placeholder name). */
public Policy withOwner(String newOwner) {
return new Policy(id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId);
}
/** A copy referencing the given saved output destinations. */
public Policy withOutputIds(List<String> newOutputIds) {
return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId);
@@ -22,8 +22,8 @@ import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.TeamService;
/**
* Seeds an enabled Classification policy per team so classification is on by default. Idempotent;
* skips the internal team.
* Seeds an enabled Classification policy per team; idempotent, skips the internal team. Left
* unowned: nobody created it, and an owner here would have to name a real user.
*/
@Slf4j
@Component
@@ -34,6 +34,11 @@ public class DefaultClassificationPolicySeeder {
private static final String CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label";
private static final String POLICY_NAME = "Classification Policy";
/**
* Pre-existing seeds used this placeholder, which was never a user; see {@link #repairOwner}.
*/
private static final String LEGACY_OWNER = "system";
private final PolicyStore policyStore;
private final TeamRepository teamRepository;
@@ -46,9 +51,8 @@ public class DefaultClassificationPolicySeeder {
.ifPresent(team -> seedIfMissing(team.getId(), team.getName()));
}
// Any team created at runtime (admin-created, SaaS sign-ups). Seeds inside the team's own
// transaction: rollback still leaves no policy behind, and the store's pessimistic lock needs a
// live transaction, which AFTER_COMMIT cannot offer.
// Seeds inside the new team's own transaction: rollback leaves no policy behind, and the
// store's pessimistic lock needs a live transaction, which AFTER_COMMIT cannot offer.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void onTeamCreated(TeamCreatedEvent event) {
seedIfMissing(event.teamId(), event.teamName());
@@ -58,16 +62,33 @@ public class DefaultClassificationPolicySeeder {
if (teamId == null || TeamService.INTERNAL_TEAM_NAME.equals(teamName)) {
return;
}
boolean alreadySeeded =
Policy existing =
policyStore.findByTeam(teamId).stream()
.anyMatch(DefaultClassificationPolicySeeder::isClassification);
if (alreadySeeded) {
.filter(DefaultClassificationPolicySeeder::isClassification)
.findFirst()
.orElse(null);
if (existing != null) {
repairOwner(existing);
return;
}
policyStore.save(defaultPolicy(teamId));
log.info("Seeded default Classification policy for team {}", teamId);
}
/**
* Clear an owner seeded as a placeholder name. An owner someone deliberately set is left alone.
*/
private void repairOwner(Policy policy) {
if (!LEGACY_OWNER.equals(policy.owner())) {
return;
}
policyStore.save(policy.withOwner(null));
log.info(
"Cleared placeholder owner '{}' on Classification policy {}",
LEGACY_OWNER,
policy.id());
}
private static boolean isClassification(Policy policy) {
return policy.output() != null
&& CATEGORY.equals(policy.output().options().get("categoryId"));
@@ -85,7 +106,9 @@ public class DefaultClassificationPolicySeeder {
return new Policy(
null,
POLICY_NAME,
"system",
// Nobody created this - it is seeded. A name here would have to be a real user, and
// every consumer of owner already handles its absence.
null,
true,
List.of(),
List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())),
@@ -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)))
@@ -14,6 +14,7 @@ import static org.mockito.Mockito.when;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -76,7 +77,7 @@ class ClassifyLabelControllerTest {
.thenReturn("{\"outcome\":\"classification\",\"labels\":[\"invoice\"]}");
try {
controller.classifyAndLabel(file);
controller.classifyAndLabel(file, false);
} catch (Exception ignored) {
// WebResponseUtils.pdfDocToWebResponse needs a real temp file; the engine call and
// metadata write we assert on have already happened by the time it runs.
@@ -89,6 +90,52 @@ class ClassifyLabelControllerTest {
return objectMapper.readTree(body.getValue());
}
/** Stubs a document that already carries a verdict, as a second run over a batch would see. */
private MultipartFile alreadyClassifiedDocument() throws Exception {
PDDocument document = mock(PDDocument.class);
PDDocumentInformation info = mock(PDDocumentInformation.class);
when(document.getDocumentInformation()).thenReturn(info);
when(info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY))
.thenReturn("{\"labels\":[\"invoice\"]}");
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("invoice.pdf");
when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(document);
return file;
}
@Test
void classifyAndLabel_skipsADocumentThatAlreadyCarriesAVerdict() throws Exception {
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
MultipartFile file = alreadyClassifiedDocument();
try {
controller.classifyAndLabel(file, false);
} catch (Exception ignored) {
// The response needs a real temp file; the decision under test happens before it.
}
// No second engine call, and no charge for one: re-classifying buys the same answer twice.
verify(aiEngineClient, never()).post(anyString(), anyString(), any());
verify(pdfMetadataService, never()).setClassificationMetadata(any(), anyString());
}
@Test
void classifyAndLabel_reclassifiesWhenAskedTo() throws Exception {
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
MultipartFile file = alreadyClassifiedDocument();
when(pdfContentExtractor.extractPageTextRaw(any(), eq(1))).thenReturn("Invoice total");
when(aiEngineClient.post(eq("/api/v1/documents/classify"), anyString(), isNull()))
.thenReturn("{\"outcome\":\"classification\",\"labels\":[\"receipt\"]}");
try {
controller.classifyAndLabel(file, true);
} catch (Exception ignored) {
// As above.
}
verify(aiEngineClient).post(eq("/api/v1/documents/classify"), anyString(), isNull());
}
@Test
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
@@ -36,10 +36,14 @@ class DefaultClassificationPolicySeederTest {
}
private static Policy classificationPolicy(Long teamId) {
return classificationPolicy(teamId, null);
}
private static Policy classificationPolicy(Long teamId, String owner) {
return new Policy(
"p1",
"Classification Policy",
"system",
owner,
true,
List.of(),
List.of(),
@@ -77,6 +81,29 @@ class DefaultClassificationPolicySeederTest {
verify(policyStore, never()).save(any());
}
@Test
void clearsAPlaceholderOwnerSeededBeforeOwnersHadToBeReal() {
when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "system")));
seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme"));
// "system" was never a user row, and a step dispatch authenticates as the owner. Absence
// is handled everywhere; a placeholder name is not.
ArgumentCaptor<Policy> saved = ArgumentCaptor.forClass(Policy.class);
verify(policyStore).save(saved.capture());
assertThat(saved.getValue().owner()).isNull();
assertThat(saved.getValue().id()).isEqualTo("p1");
}
@Test
void leavesADeliberatelyChosenOwnerAlone() {
when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "alice")));
seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme"));
verify(policyStore, never()).save(any());
}
@Test
void doesNotSeedForTheInternalTeam() {
seeder().onTeamCreated(new TeamCreatedEvent(2L, "Internal"));
@@ -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
@@ -67,6 +67,30 @@ COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
# 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}
@@ -84,6 +108,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/
# Fonts go to system dir, root ownership is correct (world-readable)
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
@@ -97,6 +127,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
@@ -116,6 +148,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
+33
View File
@@ -0,0 +1,33 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf",
"sortPackageJson": false,
"ignorePatterns": [
"dist/",
"editor/dist/",
"editor/src-tauri/**/target/",
"editor/src-tauri/gen/",
"node_modules/",
"editor/public/vendor/",
"editor/public/mockServiceWorker.js",
"editor/public/og-metadata.json",
"editor/public/og-metadata.saas.json",
"editor/src/core/data/ogImageMap.json",
"editor/src/portal/generated/docsManifest.json",
"editor/public/pdfjs*/",
"editor/public/js/thirdParty/",
"editor/public/css/cookieconsent.css",
"storybook-static/",
"playwright-report/",
"editor/playwright-report/",
"test-results/",
"editor/test-results/",
"*.min.*",
"*.md",
"*.wxs",
"*.toml",
"editor/src/output.css"
]
}
-30
View File
@@ -1,30 +0,0 @@
dist/
editor/dist/
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier).
# Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each
# have their own Cargo workspace under src-tauri/.
editor/src-tauri/**/target/
editor/src-tauri/gen/
node_modules/
editor/public/vendor/
# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
editor/public/mockServiceWorker.js
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
editor/public/og-metadata.json
editor/public/og-metadata.saas.json
editor/src/core/data/ogImageMap.json
# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim.
editor/src/portal/generated/docsManifest.json
editor/public/pdfjs*/
editor/public/js/thirdParty/
editor/public/css/cookieconsent.css
# Build / test artifacts that may exist locally even though they're gitignored
storybook-static/
playwright-report/
editor/playwright-report/
test-results/
editor/test-results/
*.min.*
*.md
*.wxs
editor/src/output.css
-6
View File
@@ -1,6 +0,0 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf"
}
+1 -1
View File
@@ -18,7 +18,7 @@ For desktop app development, see the [Tauri](#tauri) section below.
`frontend/` is a workspace containing one or more apps. Today it holds the
PDF editor under `frontend/editor/`; new apps (the developer portal, etc.)
will sit alongside it as siblings. Shared tooling — `package.json`, `node_modules`,
`.storybook/`, oxlint, Prettier — lives at `frontend/` so every app installs
`.storybook/`, oxlint, oxfmt — lives at `frontend/` so every app installs
once and lints with the same config.
## Environment Variables
@@ -4592,6 +4592,10 @@ desc = "Change document restrictions and permissions"
tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights"
title = "Change Permissions"
[home.classify]
desc = "Identify what kind of document this is and tag it."
title = "Classify"
[home.compare]
desc = "Compares and shows the differences between 2 PDF Documents"
tags = "difference,compare,diff,compare PDFs,compare documents,find differences,show differences,changes,what changed,track changes,revisions,version compare,side by side,contrast,delta"
@@ -11044,6 +11048,7 @@ searchPlaceholder = "Search tools..."
[toolPicker.subcategories]
advancedFormatting = "Advanced Formatting"
ai = "AI"
automation = "Automation"
developerTools = "Developer Tools"
documentReview = "Document Review"
+6
View File
@@ -195,6 +195,11 @@
"title": "Compress - Stirling PDF",
"description": "Compress PDFs to reduce their file size."
},
"classify": {
"image": "/og_images/home.png",
"title": "Classify - Stirling PDF",
"description": "Identify what kind of document this is and tag it."
},
"extractPages": {
"image": "/og_images/extract-pages.png",
"title": "Extract Pages - Stirling PDF",
@@ -575,6 +580,7 @@
"/remove-cert-sign": "removeCertSign",
"/unlock-p-d-f-forms": "unlockPDFForms",
"/compress": "compress",
"/classify": "classify",
"/extract-pages": "extractPages",
"/reorganize-pages": "reorganizePages",
"/extract-images": "extractImages",
@@ -196,6 +196,11 @@
"title": "Compress - Stirling PDF",
"description": "Compress PDFs to reduce their file size."
},
"classify": {
"image": "/og_images/home.png",
"title": "Classify - Stirling PDF",
"description": "Identify what kind of document this is and tag it."
},
"extractPages": {
"image": "/og_images/extract-pages.png",
"title": "Extract Pages - Stirling PDF",
@@ -588,6 +593,7 @@
"/remove-cert-sign": "removeCertSign",
"/unlock-p-d-f-forms": "unlockPDFForms",
"/compress": "compress",
"/classify": "classify",
"/extract-pages": "extractPages",
"/reorganize-pages": "reorganizePages",
"/extract-images": "extractImages",
@@ -10,15 +10,9 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { parseArgs } from "node:util";
import { compile, type JSONSchema } from "json-schema-to-typescript";
import * as prettier from "prettier";
// The API namespaces whose endpoints a pipeline can reference. `/api/v1/ai/tools/`
// is absent from the spec, so it cannot appear here. Extend this list when other
// namespaces become tools.
//
// `/api/v1/filter/` and `/api/v1/integration/` are included even though neither is a
// user-facing tool: a stored pipeline can contain one, and ToolEndpoint keys the I/O
// table, so leaving them out would stop a chain being checked past such a step.
// Endpoints a pipeline can reference. filter/integration are not user-facing tools but a stored
// pipeline can contain one; the AI namespace is admitted one endpoint at a time, not wholesale.
const ALLOWED_PATH_PREFIXES = [
"/api/v1/general/",
"/api/v1/misc/",
@@ -26,6 +20,7 @@ const ALLOWED_PATH_PREFIXES = [
"/api/v1/convert/",
"/api/v1/filter/",
"/api/v1/integration/",
"/api/v1/ai/tools/classify-and-label",
];
// File plumbing, not user parameters: `fileInput` and `file` are the uploaded primary document
@@ -227,11 +222,7 @@ function collectToolIO(
return { table, dropped };
}
async function renderToolIO(
spec: Json,
table: Record<string, unknown>,
outputPath: string,
): Promise<string> {
function renderToolIO(spec: Json, table: Record<string, unknown>): string {
if (Object.keys(table).length === 0) {
throw new Error(
`No ${IO_EXTENSION} declarations in the spec. The backend publishes these from @ToolIO; regenerate with 'task backend:swagger'.`,
@@ -312,33 +303,12 @@ export function toolIOFor(
}
`;
const prettierConfig = await prettier.resolveConfig(outputPath);
return prettier.format(body, { ...prettierConfig, parser: "typescript" });
return body;
}
/** In check mode, fail when the committed file is out of date. */
function writeOrCheck(
outputPath: string,
formatted: string,
check: boolean,
task: string,
): void {
if (check) {
let current = "";
try {
current = readFileSync(outputPath, "utf-8");
} catch {
// Missing file counts as out of date.
}
if (current !== formatted) {
throw new Error(
`${outputPath} is out of date. Run '${task}' and commit the result.`,
);
}
return;
}
function writeOutput(outputPath: string, contents: string): void {
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, formatted, "utf-8");
writeFileSync(outputPath, contents, "utf-8");
}
async function main(): Promise<void> {
@@ -347,12 +317,11 @@ async function main(): Promise<void> {
spec: { type: "string" },
output: { type: "string" },
"io-output": { type: "string" },
check: { type: "boolean", default: false },
},
});
if (!values.spec || !values.output || !values["io-output"]) {
throw new Error(
"Usage: generate-tool-api-types.mts --spec <SwaggerDoc.json> --output <file.ts> --io-output <file.ts> [--check]",
"Usage: generate-tool-api-types.mts --spec <SwaggerDoc.json> --output <file.ts> --io-output <file.ts>",
);
}
const specPath = resolve(values.spec);
@@ -478,14 +447,9 @@ async function main(): Promise<void> {
`Dropped ${dropped.length} @ToolIO declaration(s) on paths that are not tool endpoints. Add the namespace to ALLOWED_PATH_PREFIXES if a pipeline can contain these steps:\n ${dropped.join("\n ")}`,
);
}
writeOrCheck(
ioOutputPath,
await renderToolIO(spec, ioDeclarations, ioOutputPath),
values.check ?? false,
"task frontend:tool-models",
);
writeOutput(ioOutputPath, renderToolIO(spec, ioDeclarations));
console.log(
`${values.check ? "Up to date" : "Generated"}: ${Object.keys(ioDeclarations).length} tool I/O declarations.`,
`Generated ${Object.keys(ioDeclarations).length} tool I/O declarations.`,
);
// Transitively inline every referenced component into `definitions`, rewriting its refs too.
@@ -508,7 +472,6 @@ async function main(): Promise<void> {
definitions,
fileFieldsByClass,
outputPath,
values.check ?? false,
skipped,
);
}
@@ -518,7 +481,6 @@ async function compileAndWrite(
definitions: Record<string, Json>,
fileFieldsByClass: Record<string, string[]>,
outputPath: string,
check: boolean,
skipped: string[],
): Promise<void> {
// json-schema-to-typescript only emits a named, exported interface per schema
@@ -597,17 +559,9 @@ async function compileAndWrite(
].join("\n");
const body = `${FILE_HEADER}\n\n${models}\n\n${footer}\n`;
const prettierConfig = await prettier.resolveConfig(outputPath);
const formatted = await prettier.format(body, {
...prettierConfig,
parser: "typescript",
});
writeOrCheck(outputPath, formatted, check, "task frontend:tool-models");
console.log(
`${check ? "Up to date" : "Generated"}: ${tools.length} tool endpoints.`,
);
if (!check && skipped.length > 0) {
writeOutput(outputPath, body);
console.log(`Generated ${tools.length} tool endpoints.`);
if (skipped.length > 0) {
console.log(
`Skipped ${skipped.length} POST endpoint(s) with no request body: ${skipped.join(", ")}`,
);
@@ -153,7 +153,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
if (onShared) {
await onShared();
}
} catch (error: any) {
} catch (error: unknown) {
console.error("Failed to generate share link:", error);
setErrorMessage(
t(
@@ -1,5 +1,6 @@
import { Stack, Card, Text, Flex } from "@mantine/core";
import { Tooltip } from "@app/components/shared/Tooltip";
import { TooltipTip } from "@app/types/tips";
import { useTranslation } from "react-i18next";
export interface CardOption<T = string> {
@@ -7,14 +8,14 @@ export interface CardOption<T = string> {
prefixKey: string;
nameKey: string;
tooltipKey?: string;
tooltipContent?: any[];
tooltipContent?: TooltipTip[];
}
export interface CardSelectorProps<T, K extends CardOption<T>> {
options: K[];
onSelect: (value: T) => void;
disabled?: boolean;
getTooltipContent?: (option: K) => any[];
getTooltipContent?: (option: K) => TooltipTip[];
}
const CardSelector = <T, K extends CardOption<T>>({
@@ -269,7 +269,7 @@ export function FileSelectorPicker({
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
} as any,
},
);
const ct = readResponseHeader(res.headers, "content-type");
const disp = readResponseHeader(res.headers, "content-disposition");
@@ -287,7 +287,7 @@ export function FileSelectorPicker({
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
} as any,
},
);
const ct = readResponseHeader(res.headers, "content-type");
const disp = readResponseHeader(res.headers, "content-disposition");
@@ -208,7 +208,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const openWatchedFolders = useCallback(() => {
if (collapsed && onToggleCollapse) onToggleCollapse();
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId: null });
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any);
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
}, [collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions]);
// Clicking a file's membership dot jumps straight into that folder.
@@ -216,7 +216,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
(folderId: string) => {
if (collapsed && onToggleCollapse) onToggleCollapse();
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId });
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any);
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
},
[collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions],
);
@@ -1,4 +1,5 @@
import { useState } from "react";
import axios from "axios";
import { Modal, Stack, Text, PasswordInput, Alert } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
@@ -95,10 +96,13 @@ export default function FirstLoginModal({
setTimeout(() => {
onPasswordChanged();
}, 1500);
} catch (err: any) {
} catch (err: unknown) {
console.error("Failed to change password:", err);
const message = axios.isAxiosError<{ message?: string }>(err)
? err.response?.data?.message
: undefined;
setError(
err.response?.data?.message ||
message ||
t(
"firstLogin.passwordChangeFailed",
"Failed to change password. Please check your current password.",
@@ -1,4 +1,4 @@
import React, { CSSProperties, useMemo, useRef } from "react";
import React, { CSSProperties, useCallback, useMemo, useRef } from "react";
import { useAdjustFontSizeToFit } from "@app/components/shared/fitText/textFit";
type FitTextProps = {
@@ -28,8 +28,14 @@ const FitText: React.FC<FitTextProps> = ({
}) => {
const ref = useRef<HTMLElement | null>(null);
// Callback ref: an HTMLElement handler satisfies span's/div's differing ref
// types (callback refs are contravariant), so the tag can stay polymorphic.
const setRef = useCallback((node: HTMLElement | null) => {
ref.current = node;
}, []);
// Hook runs after mount and on size/text changes; uses observers internally
useAdjustFontSizeToFit(ref as any, {
useAdjustFontSizeToFit(ref, {
maxFontSizePx: fontSize,
minFontScale: minimumFontScale,
maxLines: lines,
@@ -38,7 +44,7 @@ const FitText: React.FC<FitTextProps> = ({
// Memoize the HTML tag to render (span/div) from the `as` prop so
// React doesn't create a new component function on each render.
const ElementTag: any = useMemo(() => as, [as]);
const ElementTag: React.ElementType = useMemo(() => as, [as]);
// For the / character, insert zero-width soft breaks to prefer wrapping at them
const displayText = useMemo(() => {
@@ -70,7 +76,7 @@ const FitText: React.FC<FitTextProps> = ({
return (
<ElementTag
ref={ref}
ref={setRef}
className={className}
style={{ ...clampStyles, ...style }}
>
@@ -165,7 +165,7 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
if (onUploaded) {
await onUploaded();
}
} catch (error: any) {
} catch (error: unknown) {
console.error("Failed to generate share link:", error);
setErrorMessage(
t(
@@ -226,7 +226,7 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
durationMs: 2500,
});
}
} catch (error: any) {
} catch (error: unknown) {
console.error("Failed to create share link:", error);
setErrorMessage(
t(
@@ -18,6 +18,18 @@ import { useLogoAssets } from "@app/hooks/useLogoAssets";
import styles from "@app/components/shared/tooltip/Tooltip.module.css";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
// The wrapped child's own event handlers, which Tooltip forwards to after
// running its own trigger logic. Kept partial since any given child may set none.
interface ForwardedHandlers {
onPointerEnter?: React.PointerEventHandler;
onPointerLeave?: React.PointerEventHandler;
onMouseDown?: React.MouseEventHandler;
onMouseUp?: React.MouseEventHandler;
onClick?: React.MouseEventHandler;
onFocus?: React.FocusEventHandler;
onBlur?: React.FocusEventHandler;
}
export interface TooltipProps {
sidebarTooltip?: boolean;
position?: "right" | "left" | "top" | "bottom";
@@ -218,7 +230,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!isPinned && !disabled) openWithDelay();
(children.props as any)?.onPointerEnter?.(e);
(children.props as ForwardedHandlers).onPointerEnter?.(e);
},
[isPinned, openWithDelay, children.props, disabled],
);
@@ -233,19 +245,19 @@ export const Tooltip: React.FC<TooltipProps> = ({
tooltipRef.current &&
tooltipRef.current.contains(related)
) {
(children.props as any)?.onPointerLeave?.(e);
(children.props as ForwardedHandlers).onPointerLeave?.(e);
return;
}
// Ignore transient leave between mousedown and click
if (clickPendingRef.current) {
(children.props as any)?.onPointerLeave?.(e);
(children.props as ForwardedHandlers).onPointerLeave?.(e);
return;
}
clearTimers();
if (allowAutoClose && !isPinned) setOpen(false);
(children.props as any)?.onPointerLeave?.(e);
(children.props as ForwardedHandlers).onPointerLeave?.(e);
},
[clearTimers, isPinned, setOpen, children.props, allowAutoClose],
);
@@ -253,7 +265,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
clickPendingRef.current = true;
(children.props as any)?.onMouseDown?.(e);
(children.props as ForwardedHandlers).onMouseDown?.(e);
},
[children.props],
);
@@ -262,7 +274,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
(e: React.MouseEvent) => {
// allow microtask turn so click can see this false
queueMicrotask(() => (clickPendingRef.current = false));
(children.props as any)?.onMouseUp?.(e);
(children.props as ForwardedHandlers).onMouseUp?.(e);
},
[children.props],
);
@@ -279,7 +291,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
return;
}
clickPendingRef.current = false;
(children.props as any)?.onClick?.(e);
(children.props as ForwardedHandlers).onClick?.(e);
},
[clearTimers, pinOnClick, open, setOpen, children.props],
);
@@ -288,7 +300,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handleFocus = useCallback(
(e: React.FocusEvent) => {
if (!isPinned && !disabled && openOnFocus) openWithDelay();
(children.props as any)?.onFocus?.(e);
(children.props as ForwardedHandlers).onFocus?.(e);
},
[isPinned, openWithDelay, children.props, disabled, openOnFocus],
);
@@ -301,12 +313,12 @@ export const Tooltip: React.FC<TooltipProps> = ({
tooltipRef.current &&
tooltipRef.current.contains(related)
) {
(children.props as any)?.onBlur?.(e);
(children.props as ForwardedHandlers).onBlur?.(e);
return;
}
clearTimers();
if (allowAutoClose && !isPinned) setOpen(false);
(children.props as any)?.onBlur?.(e);
(children.props as ForwardedHandlers).onBlur?.(e);
},
[isPinned, setOpen, children.props, allowAutoClose, clearTimers],
);
@@ -339,24 +351,30 @@ export const Tooltip: React.FC<TooltipProps> = ({
);
// Enhance child with handlers and ref
const childWithHandlers = React.cloneElement(children as any, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node || null;
const originalRef = (children as any).ref;
if (typeof originalRef === "function") originalRef(node);
else if (originalRef && typeof originalRef === "object")
(originalRef as any).current = node;
const childWithHandlers = React.cloneElement(
children as React.ReactElement<Record<string, unknown>>,
{
ref: (node: HTMLElement | null) => {
triggerRef.current = node || null;
const originalRef = (
children as React.ReactElement & { ref?: React.Ref<HTMLElement> }
).ref;
if (typeof originalRef === "function") originalRef(node);
else if (originalRef && typeof originalRef === "object")
(originalRef as React.MutableRefObject<HTMLElement | null>).current =
node;
},
"aria-describedby": open ? tooltipIdRef.current : undefined,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
onClick: handleClick,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
},
"aria-describedby": open ? tooltipIdRef.current : undefined,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
onClick: handleClick,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
});
);
const shouldShowTooltip = open;
const shouldShowCloseButton = showCloseButton || isPinned;
@@ -25,6 +25,13 @@ export default function WorkbenchBarMobileActions({
}: WorkbenchBarActionsProps) {
const { t } = useTranslation();
const exportDisabled = actionsDisabled || policyEnforcing;
const showPrint = currentView === "viewer";
const showFileActions = !isCustomView;
// Custom workbench views own their content, so none of these apply. The
// desktop cluster renders nothing at all in that case; without this the
// trigger would still be there, opening an empty dropdown.
if (!showPrint && !showFileActions) return null;
return (
<Menu shadow="md" width={230} position="bottom-end">
@@ -39,7 +46,7 @@ export default function WorkbenchBarMobileActions({
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{currentView === "viewer" && (
{showPrint && (
<Menu.Item
leftSection={<PrintIcon sx={{ fontSize: "1.1rem" }} />}
disabled={exportDisabled}
@@ -48,7 +55,7 @@ export default function WorkbenchBarMobileActions({
{t("workbenchBar.print", "Print PDF")}
</Menu.Item>
)}
{!isCustomView && (
{showFileActions && (
<Menu.Item
leftSection={
<LocalIcon
@@ -63,7 +70,7 @@ export default function WorkbenchBarMobileActions({
{downloadLabel}
</Menu.Item>
)}
{!isCustomView && saveAsIconName && (
{showFileActions && saveAsIconName && (
<Menu.Item
leftSection={
<LocalIcon icon={saveAsIconName} width="1.1rem" height="1.1rem" />
@@ -74,7 +81,7 @@ export default function WorkbenchBarMobileActions({
{t("workbenchBar.saveAs", "Save As")}
</Menu.Item>
)}
{!isCustomView && (
{showFileActions && (
<>
<Menu.Divider />
<Menu.Item
@@ -282,6 +282,9 @@ export default function RightSidebar() {
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={false}
/* Mobile keeps the workbench bar - and with it the super search -
on the other slide, so the list needs its own filter. */
showSearch={isMobile}
/>
</>
</div>
@@ -183,6 +183,17 @@
}
}
/* In-panel tool filter. Aligned with .tool-picker__compact's inline padding so
the field lines up with the tool rows underneath it. */
.tool-panel__search {
flex-shrink: 0;
padding: 0.5rem var(--mantine-spacing-sm) 0;
}
.tool-panel__search .search-input-container {
margin: 0;
}
.tool-panel__compact-header-actions {
display: flex;
align-items: center;
@@ -4,6 +4,7 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import ToolPicker from "@app/components/tools/ToolPicker";
import SearchResults from "@app/components/tools/SearchResults";
import ToolRenderer from "@app/components/tools/ToolRenderer";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
import { ToolId } from "@app/types/toolId";
@@ -20,6 +21,11 @@ interface ToolPanelProps {
onToolSelect?: (id: ToolId) => void;
/** Whether to render the compact (favourites + recommended only) view. */
compact?: boolean;
/**
* Render a tool filter at the head of the panel. Set where the workbench
* bar's super search is out of reach, so the list stays searchable in place.
*/
showSearch?: boolean;
}
/** Tool list and renderer for the right rail; rail chrome lives in RightSidebar. */
@@ -28,24 +34,44 @@ export default function ToolPanel({
onShowAllTools,
onToolSelect,
compact: compactProp,
showSearch = false,
}: ToolPanelProps) {
const { t } = useTranslation();
const {
leftPanelView,
searchQuery,
setSearchQuery,
filteredTools,
toolRegistry,
selectedToolKey,
handleToolSelect,
setPreviewFile,
} = useToolWorkflow();
const selectTool = onToolSelect ?? handleToolSelect;
// Only offer the filter over the list itself; once a tool is open the panel
// belongs to that tool. Deriving the results branch from the same flag keeps
// the input and what it filters from drifting apart.
const panelSearch = showSearch && leftPanelView === "toolPicker";
const searching = searchQuery.trim().length > 0;
return (
<>
{/* Viewer mode tools — annotate, redact, form fill */}
<ToolPanelViewerBar />
{allToolsView && searchQuery.trim().length > 0 ? (
{panelSearch && (
<div className="tool-panel__search">
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
/>
</div>
)}
{searching && (allToolsView || panelSearch) ? (
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
@@ -59,7 +85,7 @@ export default function ToolPanel({
selectedToolKey={selectedToolKey}
onSelect={(id) => selectTool(id as ToolId)}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
isSearching={searching}
compact={compactProp ?? !allToolsView}
onShowAllTools={onShowAllTools}
/>
@@ -82,7 +82,7 @@ export default function AutomationCreation({
setConfigModalOpen(true);
};
const handleToolConfigSave = (parameters: Record<string, any>) => {
const handleToolConfigSave = (parameters: Record<string, unknown>) => {
if (configuraingToolIndex >= 0) {
updateTool(configuraingToolIndex, {
configured: true,
@@ -19,7 +19,7 @@ interface AutomationEntryProps {
/** Optional description for tooltip */
description?: string;
/** MUI Icon component for the badge */
badgeIcon?: React.ComponentType<any>;
badgeIcon?: React.ComponentType;
/** Array of tool operation names in the workflow */
operations: string[];
/** Click handler */
@@ -9,11 +9,12 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { AutomationConfig, ExecutionStep } from "@app/types/automation";
import { EXECUTION_STATUS } from "@app/constants/automation";
import { useResourceCleanup } from "@app/utils/resourceManager";
import type { useAutomateOperation } from "@app/hooks/tools/automate/useAutomateOperation";
interface AutomationRunProps {
automation: AutomationConfig;
onComplete: () => void;
automateOperation?: any; // TODO: Type this properly when available
automateOperation?: ReturnType<typeof useAutomateOperation>;
}
export default function AutomationRun({
@@ -34,13 +35,13 @@ export default function AutomationRun({
// Use the operation hook's loading state
const isExecuting = automateOperation?.isLoading || false;
const hasResults =
automateOperation?.files.length > 0 ||
(automateOperation?.files.length ?? 0) > 0 ||
automateOperation?.downloadUrl !== null;
// Initialize execution steps from automation
useEffect(() => {
if (automation?.operations) {
const steps = automation.operations.map((op: any, index: number) => {
const steps = automation.operations.map((op, index) => {
const tool = toolRegistry[op.operation as keyof typeof toolRegistry];
return {
id: `${op.operation}-${index}`,
@@ -125,7 +126,7 @@ export default function AutomationRun({
// Mark all as completed and reset current step
setCurrentStepIndex(-1);
console.log(`✅ Automation completed successfully`);
} catch (error: any) {
} catch (error: unknown) {
console.error("Automation execution failed:", error);
setCurrentStepIndex(-1);
}
@@ -4,6 +4,7 @@ import { Stack, Text, ScrollArea } from "@mantine/core";
import {
ToolRegistryEntry,
ToolRegistry,
SubcategoryId,
getToolSupportsAutomate,
} from "@app/data/toolsTaxonomy";
import { useToolSections } from "@app/hooks/useToolSections";
@@ -81,9 +82,7 @@ export default function ToolSelector({
}, [filteredTools]);
// Use the same tool sections logic as the main ToolPicker
const { sections, searchGroups } = useToolSections(
transformedFilteredTools as any /* FIX ME */,
);
const { sections, searchGroups } = useToolSections(transformedFilteredTools);
// Determine what to display: search results or organized sections
const isSearching = searchTerm.trim().length > 0;
@@ -98,7 +97,9 @@ export default function ToolSelector({
return [
{
name: "Tools",
subcategoryId: "all" as any,
// Synthetic "all tools" group used only as a fallback when the
// taxonomy produces no sections; "all" is not a real SubcategoryId.
subcategoryId: "all" as unknown as SubcategoryId,
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool })),
},
];
@@ -125,7 +126,7 @@ export default function ToolSelector({
displayGroups.map((subcategory) =>
renderToolButtons(
t,
subcategory as any,
subcategory,
null,
handleToolSelect,
!isSearching,
@@ -13,6 +13,7 @@ import {
useFileActions,
} from "@app/contexts/FileContext";
import { useFileWithUrl } from "@app/hooks/useFileWithUrl";
import { ZoomMode } from "@embedpdf/plugin-zoom/react";
import { useViewer } from "@app/contexts/ViewerContext";
import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF";
import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar";
@@ -418,7 +419,7 @@ const EmbedPdfViewerContent = ({
return;
case "0":
event.preventDefault();
zoomActions.requestZoom("fit-width");
zoomActions.requestZoom(ZoomMode.FitWidth);
return;
}
}
@@ -118,7 +118,6 @@ export function SelectionAPIBridge() {
const buildApi = () => ({
copyToClipboard: () => selection.copyToClipboard(),
getSelectedText: () => selection.getSelectedText(),
getFormattedSelection: () => selection.getFormattedSelection(),
selectAll: async (totalPages: number) => {
const docId = activeDocumentId;
@@ -6,6 +6,8 @@ import {
ZoomState,
} from "@app/contexts/viewer/viewerBridges";
import { PdfBookmarkObject, PdfAttachmentObject } from "@embedpdf/models";
import { ZoomLevel, Point } from "@embedpdf/plugin-zoom";
import { FormattedSelection } from "@embedpdf/plugin-selection";
export interface ScrollActions {
scrollToPage: (page: number, behavior?: "smooth" | "instant") => void;
@@ -19,7 +21,7 @@ export interface ZoomActions {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: any, center?: any) => void;
requestZoom: (level: ZoomLevel, center?: Point) => void;
setZoomLevel: (factor: number) => void;
}
@@ -31,8 +33,7 @@ export interface PanActions {
export interface SelectionActions {
copyToClipboard: () => void;
getSelectedText: () => string;
getFormattedSelection: () => any;
getFormattedSelection: () => FormattedSelection[] | null;
selectAll: (totalPages: number) => Promise<boolean>;
selectWordAt: (pageIndex: number, x: number, y: number) => boolean;
}
@@ -51,7 +52,7 @@ export interface RotationActions {
}
export interface SearchActions {
search: (query: string) => Promise<any> | undefined;
search: (query: string) => Promise<unknown> | undefined;
next: () => void;
previous: () => void;
clear: () => void;
@@ -214,7 +215,7 @@ export function createViewerActions({
api.toggleMarqueeZoom();
}
},
requestZoom: (level: any, center?: any) => {
requestZoom: (level: ZoomLevel, center?: Point) => {
const api = registry.current.zoom?.api;
if (api?.requestZoom) {
api.requestZoom(level, center);
@@ -257,13 +258,6 @@ export function createViewerActions({
api.copyToClipboard();
}
},
getSelectedText: () => {
const api = registry.current.selection?.api;
if (api?.getSelectedText) {
return api.getSelectedText() ?? "";
}
return "";
},
getFormattedSelection: () => {
const api = registry.current.selection?.api;
if (api?.getFormattedSelection) {
@@ -1,5 +1,7 @@
import { SpreadMode } from "@embedpdf/plugin-spread/react";
import { PdfBookmarkObject, PdfAttachmentObject } from "@embedpdf/models";
import { ZoomLevel, Point } from "@embedpdf/plugin-zoom";
import { FormattedSelection } from "@embedpdf/plugin-selection";
export enum PdfPermissionFlag {
Print = 0x0004,
@@ -46,7 +48,7 @@ export interface ZoomAPIWrapper {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: any, center?: any) => void;
requestZoom: (level: ZoomLevel, center?: Point) => void;
}
export interface PanAPIWrapper {
@@ -58,8 +60,7 @@ export interface PanAPIWrapper {
export interface SelectionAPIWrapper {
copyToClipboard: () => void;
getSelectedText: () => string | any;
getFormattedSelection: () => any;
getFormattedSelection: () => FormattedSelection[];
selectAll: (totalPages: number) => Promise<boolean>;
selectWordAt: (pageIndex: number, x: number, y: number) => boolean;
}
@@ -79,7 +80,7 @@ export interface RotationAPIWrapper {
}
export interface SearchAPIWrapper {
search: (query: string) => Promise<any>;
search: (query: string) => Promise<unknown>;
clear: () => void;
next: () => void;
previous: () => void;
@@ -0,0 +1,76 @@
/**
* Classification is a thing a pipeline can do, not a thing only the Classification policy may do.
*
* The chain that used to stop it: `getExecutableTools` drops any tool whose endpoint is not a
* member of the generated `ToolEndpoint` union; that union comes from the OpenAPI spec, gated by
* the generator's namespace allowlist; and the classify controller was `@Hidden`, so it never
* reached the spec at all. These tests pin each link, because any one of them silently removes the
* step from the builder's picker rather than failing loudly.
*/
import { describe, expect, test, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
import { getExecutableTools } from "@app/hooks/tools/shared/toolAutomation";
import { isToolEndpoint } from "@app/hooks/tools/shared/toolApiMapping";
import { TOOL_IO } from "@app/types/toolIO";
import { filterToolRegistryByQuery } from "@app/utils/toolSearch";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
i18n: { changeLanguage: vi.fn(), language: "en-US" },
}),
Trans: ({ children }: { children?: unknown }) => children,
}));
const CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label";
describe("classify as a pipeline task", () => {
test("the classify endpoint is a generated ToolEndpoint", () => {
// Fails if the controller goes back to @Hidden, or the generator's allowlist drops the
// /api/v1/ai/tools/ namespace, or nobody regenerated after either.
expect(isToolEndpoint(CLASSIFY_ENDPOINT)).toBe(true);
});
test("the builder offers it as a step", () => {
const { result } = renderHook(() => useTranslatedToolCatalog());
const executable = getExecutableTools(result.current.regularTools);
const classify = executable.find((tool) => tool.toolId === "classify");
expect(classify).toBeDefined();
expect(classify?.endpoint).toBe(CLASSIFY_ENDPOINT);
});
test("it declares PDF in, PDF out, so a chain can be checked across it", () => {
// Without this the builder shows "Can't check what this step accepts" and validation stops
// dead at the step - the I/O table is keyed by endpoint and comes from @ToolIO in the spec.
expect(TOOL_IO[CLASSIFY_ENDPOINT]).toEqual({
accepts: ["PDF"],
produces: "PDF",
arity: "SISO",
});
});
test("it is offered to pipelines but kept out of the editor's tool list", () => {
const { result } = renderHook(() => useTranslatedToolCatalog());
// There is no interactive classify tool to open - it only means something inside a pipeline.
expect(result.current.regularTools.classify?.hiddenFromToolList).toBe(true);
expect(
filterToolRegistryByQuery(result.current.regularTools, "").some(
(ranked) => ranked.item[0] === "classify",
),
).toBe(false);
});
test("it does not re-classify by default", () => {
const { result } = renderHook(() => useTranslatedToolCatalog());
const config = result.current.regularTools.classify?.operationConfig;
// The step is idempotent unless asked otherwise: a second run on a classified document
// would be a second engine call, and a second charge, for the same answer.
expect(config?.defaultParameters).toEqual({ reclassify: false });
});
});

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