Merge remote-tracking branch 'origin/main' into dynamic-tool-recommendations

# Conflicts:
#	frontend/editor/public/locales/en-GB/translation.toml
#	frontend/editor/src/core/utils/toolIOCompat.test.ts
#	frontend/editor/src/core/utils/toolIOLabels.test.ts
This commit is contained in:
Anthony Stirling
2026-08-12 12:05:48 +01:00
591 changed files with 96890 additions and 39671 deletions
-2
View File
@@ -119,8 +119,6 @@
"extensions": [
"elagil.pre-commit-helper", // Support for pre-commit hooks to enforce code quality
"josevseb.google-java-format-for-vs-code", // Google Java code formatter to follow the Google Java Style Guide
"ms-python.black-formatter", // Python code formatter using Black
"ms-python.flake8", // Flake8 linter for Python to enforce code quality
"ms-python.python", // Official Microsoft Python extension with IntelliSense, debugging, and Jupyter support
"ms-vscode-remote.vscode-remote-extensionpack", // Remote Development Pack for SSH, WSL, and Containers
// "Oracle.oracle-java", // Oracle Java extension with additional features for Java development
+5
View File
@@ -16,6 +16,11 @@ updates:
cooldown:
default-days: 7
rebase-strategy: "auto"
groups:
simple-java-mail:
patterns:
- "org.simplejavamail:simple-java-mail"
- "org.simplejavamail:outlook-module"
- package-ecosystem: "docker"
directories:
+9 -28
View File
@@ -19,9 +19,9 @@ import argparse
import glob
import os
import re
import tomllib # Python 3.11+ (stdlib)
from pathlib import Path
import tomllib # Python 3.11+ (stdlib)
import tomli_w # For writing TOML files
@@ -133,11 +133,7 @@ def update_missing_keys(reference_file, file_list, branch=""):
file_path = Path(file_path)
language_dir = file_path.parent.name
reference_lang_dir = reference_file.parent.name
if (
language_dir == reference_lang_dir
or file_path.suffix != ".toml"
or file_path.parents[1].name != "locales"
):
if language_dir == reference_lang_dir or file_path.suffix != ".toml" or file_path.parents[1].name != "locales":
print(f"Skipping file: {file_path}")
continue
@@ -198,9 +194,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
# Verify that file is within the expected directory
if not absolute_path.is_relative_to(base_dir):
has_differences = True
report.append(
f"\n⚠️ Unsafe file found: `{locale_dir}/{basename_current_file}`\n\n---\n"
)
report.append(f"\n⚠️ Unsafe file found: `{locale_dir}/{basename_current_file}`\n\n---\n")
continue
# Verify file size before processing
@@ -214,10 +208,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if basename_current_file == basename_reference_file and locale_dir == "en-US":
continue
if (
file_normpath.suffix != ".toml"
or basename_current_file != "translation.toml"
):
if file_normpath.suffix != ".toml" or basename_current_file != "translation.toml":
continue
only_reference_file = False
@@ -261,9 +252,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
)
report.append("")
report.append(" Use the following command to remove them:")
report.append(
f" `python scripts/translations/translation_merger.py {locale_dir} remove-unused`"
)
report.append(f" `python scripts/translations/translation_merger.py {locale_dir} remove-unused`")
report.append("")
if extra_keys_list:
report.append(
@@ -271,9 +260,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
)
report.append("")
report.append(" Use the following command to add them:")
report.append(
f" `python scripts/translations/translation_merger.py {locale_dir} add-missing`"
)
report.append(f" `python scripts/translations/translation_merger.py {locale_dir} add-missing`")
report.append("")
if missing_keys_list or extra_keys_list:
@@ -288,9 +275,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
output = "\n".join(
[
f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
for key, first, duplicate in find_duplicate_keys(
branch_path / file_normpath
)
for key, first, duplicate in find_duplicate_keys(branch_path / file_normpath)
]
)
report.append("3. **Test Status:** ❌ **_Failed_**")
@@ -313,18 +298,14 @@ def check_for_differences(reference_file, file_list, branch, actor):
else:
report.append("## ✅ Overall Check Status: **_Success_**")
report.append("")
report.append(
f"Thanks @{actor} for your help in keeping the translations up to date."
)
report.append(f"Thanks @{actor} for your help in keeping the translations up to date.")
if not only_reference_file:
print("\n".join(report))
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Find missing keys in TOML translation files"
)
parser = argparse.ArgumentParser(description="Find missing keys in TOML translation files")
parser.add_argument(
"--actor",
required=False,
-9
View File
@@ -1,9 +0,0 @@
pip
setuptools
WeasyPrint
pdf2image
pillow
unoserver
opencv-python-headless
pre-commit
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
-528
View File
@@ -1,528 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# pip-compile --allow-unsafe --generate-hashes --output-file='.github\scripts\requirements_dev.txt' --strip-extras '.github\scripts\requirements_dev.in'
#
# WARNING: pip install will require the following package to be hashed.
# Consider using a hashable URL like https://github.com/jazzband/pip-tools/archive/SOMECOMMIT.zip
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
# via
# -r .github/scripts/requirements_dev.in
# fonttools
cffi==2.1.0 \
--hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
--hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
--hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
--hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
--hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
--hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
--hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
--hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
--hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
--hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
--hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
--hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
--hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
--hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
--hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
--hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
--hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
--hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
--hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
--hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
--hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
--hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
--hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
--hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
--hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
--hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
--hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
--hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
--hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
--hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
--hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
--hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
--hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
--hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
--hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
--hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
--hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
--hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
--hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
--hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
--hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
--hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
--hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
--hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
--hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
--hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
--hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
--hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
--hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
--hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
--hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
--hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
--hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
--hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
--hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
--hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
--hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
--hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
--hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
--hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
--hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
--hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
--hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
--hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
--hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
--hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
--hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
--hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
--hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
--hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
--hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
--hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
--hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
--hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
--hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
--hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
--hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
--hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
--hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
--hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
--hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
--hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
--hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
--hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
--hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
--hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
--hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
--hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
--hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
--hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
--hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
--hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
--hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
--hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
--hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
--hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
--hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
--hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
--hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
--hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
# via weasyprint
cfgv==3.5.0 \
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
# via pre-commit
cssselect2==0.9.0 \
--hash=sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563 \
--hash=sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb
# via weasyprint
distlib==0.4.3 \
--hash=sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b \
--hash=sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed
# via virtualenv
filelock==3.30.0 \
--hash=sha256:1774e682dbe443bd60f9609162fc596e2c80dc84ffc2957068953406d0520090 \
--hash=sha256:40632998f0772e64183bb819f086a1b9def6be1090cf1dcb9d45f46806ef279b
# via
# python-discovery
# virtualenv
fonttools==4.63.0 \
--hash=sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69 \
--hash=sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c \
--hash=sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac \
--hash=sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096 \
--hash=sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d \
--hash=sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68 \
--hash=sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616 \
--hash=sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78 \
--hash=sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f \
--hash=sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b \
--hash=sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b \
--hash=sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02 \
--hash=sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d \
--hash=sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f \
--hash=sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8 \
--hash=sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272 \
--hash=sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49 \
--hash=sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419 \
--hash=sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001 \
--hash=sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03 \
--hash=sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196 \
--hash=sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9 \
--hash=sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e \
--hash=sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5 \
--hash=sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007 \
--hash=sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380 \
--hash=sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8 \
--hash=sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27 \
--hash=sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40 \
--hash=sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e \
--hash=sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0 \
--hash=sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263 \
--hash=sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb \
--hash=sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94 \
--hash=sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b \
--hash=sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6 \
--hash=sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579 \
--hash=sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4 \
--hash=sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59 \
--hash=sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0 \
--hash=sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e \
--hash=sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be \
--hash=sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd \
--hash=sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18 \
--hash=sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22 \
--hash=sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0 \
--hash=sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b \
--hash=sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b \
--hash=sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af \
--hash=sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745
# via weasyprint
identify==2.6.19 \
--hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \
--hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842
# via pre-commit
nodeenv==1.10.0 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
# via pre-commit
numpy==2.4.6 \
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
--hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
--hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
--hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
--hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
--hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
--hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
--hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
--hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
--hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
--hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
--hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
--hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
--hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
--hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
--hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
--hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
--hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
--hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
--hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
--hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
--hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
--hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
--hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
--hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
--hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
--hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
--hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
--hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
--hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
--hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
--hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
--hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
--hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
--hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
--hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
--hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
--hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
--hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
--hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
--hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
--hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
--hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
--hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
--hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
--hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
--hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
--hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
--hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
--hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
--hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
--hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
--hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
--hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
--hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
--hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
--hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
--hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
--hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
--hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
--hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
--hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
--hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
--hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
--hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
--hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
--hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
--hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
--hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
--hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
# via opencv-python-headless
opencv-python-headless==5.0.0.93 \
--hash=sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f \
--hash=sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4 \
--hash=sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f \
--hash=sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00 \
--hash=sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e \
--hash=sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4 \
--hash=sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c \
--hash=sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9 \
--hash=sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37
# via -r .github/scripts/requirements_dev.in
pdf2image==1.17.0 \
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
# via -r .github/scripts/requirements_dev.in
pillow==12.3.0 \
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
--hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
--hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \
--hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \
--hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \
--hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \
--hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \
--hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \
--hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \
--hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \
--hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \
--hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \
--hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \
--hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \
--hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \
--hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \
--hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \
--hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \
--hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
--hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \
--hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \
--hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \
--hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \
--hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \
--hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \
--hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \
--hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \
--hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \
--hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \
--hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \
--hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \
--hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \
--hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \
--hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \
--hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \
--hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \
--hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \
--hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \
--hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \
--hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \
--hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \
--hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \
--hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \
--hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \
--hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \
--hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \
--hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \
--hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \
--hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \
--hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \
--hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \
--hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \
--hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \
--hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \
--hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \
--hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \
--hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \
--hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \
--hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \
--hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \
--hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \
--hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \
--hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \
--hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \
--hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \
--hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
--hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \
--hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \
--hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \
--hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \
--hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \
--hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \
--hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \
--hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \
--hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \
--hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \
--hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \
--hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \
--hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \
--hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \
--hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \
--hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \
--hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \
--hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \
--hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
--hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
--hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
# via
# -r .github/scripts/requirements_dev.in
# pdf2image
# weasyprint
platformdirs==4.10.0 \
--hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \
--hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a
# via
# python-discovery
# virtualenv
pre-commit==4.6.0 \
--hash=sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9 \
--hash=sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b
# via -r .github/scripts/requirements_dev.in
pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via cffi
pydyf==0.12.1 \
--hash=sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc \
--hash=sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095
# via weasyprint
pyphen==0.17.2 \
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
--hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3
# via weasyprint
python-discovery==1.4.4 \
--hash=sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3 \
--hash=sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe
# via virtualenv
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via pre-commit
tinycss2==1.5.1 \
--hash=sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661 \
--hash=sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957
# via
# cssselect2
# weasyprint
tinyhtml5==2.1.0 \
--hash=sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67 \
--hash=sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a
# via weasyprint
unoserver==3.7 \
--hash=sha256:b05f9578506ac7374ae1b314c3a79528636c542ac78220a9ce99110584ca424b \
--hash=sha256:fc44e6808071c9d2957e705ecf1742cea8a582aa5d5cc23babf36bb332ec6e8e
# via -r .github/scripts/requirements_dev.in
virtualenv==21.6.1 \
--hash=sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128 \
--hash=sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b
# via pre-commit
weasyprint==69.0 \
--hash=sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6 \
--hash=sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c
# via -r .github/scripts/requirements_dev.in
webencodings==0.5.1 \
--hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \
--hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923
# via
# cssselect2
# tinycss2
# tinyhtml5
zopfli==0.4.3 \
--hash=sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d \
--hash=sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef \
--hash=sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3 \
--hash=sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6 \
--hash=sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc \
--hash=sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42 \
--hash=sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e \
--hash=sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073 \
--hash=sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357 \
--hash=sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe \
--hash=sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94 \
--hash=sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206 \
--hash=sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e
# via fonttools
# The following packages are considered to be unsafe in a requirements file:
pip==26.1.2 \
--hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \
--hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605
# via -r .github/scripts/requirements_dev.in
setuptools==83.0.0 \
--hash=sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef \
--hash=sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3
# via -r .github/scripts/requirements_dev.in
@@ -1,2 +0,0 @@
tomlkit
tomli-w
@@ -1,14 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in'
#
tomli-w==1.2.0 \
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
# via -r .github/scripts/requirements_sync_readme.in
tomlkit==0.15.0 \
--hash=sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738 \
--hash=sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3
# via -r .github/scripts/requirements_sync_readme.in
+6 -7
View File
@@ -4,19 +4,18 @@
Usage: verify-updater-signatures.py <dir-to-scan> [tauri.conf.json]
"""
import binascii
import sys
import json
import base64
import binascii
import hashlib
import json
import sys
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
ART_ROOT = Path(sys.argv[1])
CONF = Path(
sys.argv[2] if len(sys.argv) > 2 else "frontend/editor/src-tauri/tauri.conf.json"
)
CONF = Path(sys.argv[2] if len(sys.argv) > 2 else "frontend/editor/src-tauri/tauri.conf.json")
def load_pubkey():
@@ -264,7 +264,7 @@ jobs:
if: needs.check-comment.outputs.enable_prototypes == 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./engine
context: .
file: ./engine/Dockerfile
push: true
cache-from: type=gha,scope=stirling-pdf-engine
+11
View File
@@ -31,6 +31,9 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: ai-engine
- name: Install Task
@@ -96,6 +99,14 @@ jobs:
echo "============================================"
exit 1
- name: Build engine production image
if: always()
run: docker build --file engine/Dockerfile --tag stirling-pdf-engine:ci .
- name: Build engine development image
if: always()
run: docker build --file engine/Dockerfile.dev --tag stirling-pdf-engine-dev:ci .
- name: Remove engine check comment on success
if: steps.engine-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
+8 -6
View File
@@ -192,12 +192,14 @@ jobs:
retention-days: 3
if-no-files-found: warn
- name: Install defusedxml for coverage summary
# coverage-summary.py parses JaCoCo XML through defusedxml to
# silence security scanners that pattern-match on the stdlib
# xml.etree.ElementTree.parse call.
- name: Install uv
if: always() && matrix.flavor == 'saas'
run: python -m pip install --quiet defusedxml
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: JaCoCo coverage step summary
# Only the saas leg posts the JUnit summary - it's a strict
@@ -206,7 +208,7 @@ jobs:
# near-identical tables crowding out the aggregate report.
if: always() && matrix.flavor == 'saas'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Backend JUnit coverage (JDK ${{ matrix.jdk-version }})" \
--jacoco "common=app/common/build/reports/jacoco/test/jacocoTestReport.xml" \
--jacoco "core=app/core/build/reports/jacoco/test/jacocoTestReport.xml" \
+9 -8
View File
@@ -329,15 +329,16 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: ./testing/cucumber/requirements.txt
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Install behave test deps
run: |
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
uv sync --project engine --locked --group cucumber
- name: Build the multi-node image
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" build
@@ -360,10 +361,10 @@ jobs:
- name: Run multi-node regression (implemented guarantees)
working-directory: testing/cucumber
# -e overrides behave.ini's exclusion of features/multinode; ~@known_gap skips any tracked-gap scenarios.
run: python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
run: uv run --project ../../engine --locked --group cucumber python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
- name: Run multi-node failover (destructive)
working-directory: testing/cucumber
run: python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
run: uv run --project ../../engine --locked --group cucumber python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
- name: Dump node logs on failure
if: failure()
working-directory: testing/compose
@@ -34,6 +34,9 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: generated-models
- name: Set up JDK 25
+8 -5
View File
@@ -195,20 +195,23 @@ jobs:
console.log(`Reference file path: ${referenceFilePath}`);
core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Install Python dependencies
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
uv sync --project engine --locked --group tools
- name: Run Python script to check files
id: run-check
run: |
echo "Running Python script to check TOML files..."
python .github/scripts/check_language_toml.py \
uv run --project engine --locked --group tools python .github/scripts/check_language_toml.py \
--actor ${{ github.event.pull_request.user.login }} \
--reference-file "${REFERENCE_FILE}" \
--branch "pr-branch" \
+9 -11
View File
@@ -57,15 +57,13 @@ jobs:
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage scripts
# Both coverage-summary.py and coverage-matrix.py parse JaCoCo
# XML through defusedxml - see the script headers for context.
run: python -m pip install --quiet defusedxml
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
# Pattern matches every artifact this PR's producers might upload:
# jacoco-exec-junit-jdk-25 (uploaded only by the saas
@@ -158,7 +156,7 @@ jobs:
# ("how much of the backend do real user flows cover?").
if: steps.inventory.outputs.found_e2e == 'true'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Real user-flow backend coverage (e2e:live + cucumber)" \
--jacoco "merged=build/reports/jacoco/aggregate-e2e/jacocoTestReport.xml" \
--github-step-summary
@@ -169,7 +167,7 @@ jobs:
# is meaningless when one is a strict superset of the other.
if: steps.inventory.outputs.found_all == 'true'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Combined backend coverage (JUnit + e2e:live + cucumber)" \
--jacoco "merged=build/reports/jacoco/aggregate-all/jacocoTestReport.xml" \
--github-step-summary
@@ -224,7 +222,7 @@ jobs:
# generated above) plus whichever frontend artifacts landed.
# Every input is optional; missing ones render as "-".
run: |
python scripts/coverage-matrix.py \
uv run --project engine --locked --group tools python scripts/coverage-matrix.py \
${{ steps.inventory.outputs.found_all == 'true' && '--jacoco-all build/reports/jacoco/aggregate-all/jacocoTestReport.xml' || '' }} \
${{ steps.inventory.outputs.found_e2e == 'true' && '--jacoco-e2e build/reports/jacoco/aggregate-e2e/jacocoTestReport.xml' || '' }} \
--vitest matrix-inputs/vitest/coverage-summary.json \
+9 -14
View File
@@ -69,16 +69,17 @@ jobs:
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
cache-dependency-path: ./testing/cucumber/requirements.txt
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Pip requirements
- name: Install Cucumber and coverage dependencies
run: |
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
uv sync --project engine --locked --group cucumber --group tools
- name: Extract JaCoCo agent for cucumber coverage
# Stages build/jacoco/jacocoagent.jar where the coverage override
@@ -121,16 +122,10 @@ jobs:
echo "report=false" >> "$GITHUB_OUTPUT"
fi
- name: Install defusedxml for coverage summary
# coverage-summary.py parses JaCoCo XML through defusedxml -
# see the script header for context.
if: always() && steps.cucumber-coverage.outputs.report == 'true'
run: python -m pip install --quiet defusedxml
- name: Cucumber coverage step summary
if: always() && steps.cucumber-coverage.outputs.report == 'true'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Cucumber (docker) JaCoCo coverage" \
--jacoco "cucumber=build/reports/jacoco/cucumber/jacocoTestReport.xml" \
--github-step-summary
+13 -32
View File
@@ -119,20 +119,18 @@ jobs:
echo "::warning::No e2e:live .exec found at .test-state/playwright/jacoco.exec; skipping report"
echo "report=false" >> "$GITHUB_OUTPUT"
fi
- name: Set up Python for coverage summary
if: always() && steps.live-coverage.outputs.report == 'true'
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
if: always()
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage summary
# coverage-summary.py uses defusedxml instead of stdlib xml.etree
# to dodge XXE / billion-laughs scanner findings.
if: always() && steps.live-coverage.outputs.report == 'true'
run: python -m pip install --quiet defusedxml
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: e2e:live coverage step summary
if: always() && steps.live-coverage.outputs.report == 'true'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Playwright (live backend) JaCoCo coverage" \
--jacoco "e2e-live=build/reports/jacoco/e2e-live/jacocoTestReport.xml" \
--github-step-summary
@@ -155,23 +153,6 @@ jobs:
retention-days: 7
if-no-files-found: warn
- name: Set up Python for frontend coverage summary
# Separate from the backend-coverage python step because the
# frontend path doesn't depend on a JaCoCo report - it produces
# a summary even on backend failure, as long as some Playwright
# tests ran far enough to dump V8 coverage.
if: always()
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install defusedxml for frontend coverage summary
# Idempotent re-install: the backend-coverage step may have
# installed it already, but this leg can run on its own when the
# backend report step skips (e.g. .exec missing).
if: always()
run: python -m pip install --quiet defusedxml
- name: Aggregate Playwright frontend (V8) coverage
# Rolls per-test V8 dumps from the test-base fixture into one
# vitest-shaped coverage-summary.json. Tolerates a missing dump
@@ -182,7 +163,7 @@ jobs:
run: |
if [ -d .test-state/playwright/coverage-pw ] && \
find .test-state/playwright/coverage-pw -name '*.json' -type f | grep -q .; then
python scripts/playwright-coverage-summary.py \
uv run --project engine --locked --group tools python scripts/playwright-coverage-summary.py \
.test-state/playwright/coverage-pw \
--out .test-state/playwright/coverage-pw-summary/coverage-summary.json
echo "summary=true" >> "$GITHUB_OUTPUT"
@@ -194,10 +175,10 @@ jobs:
- name: Playwright frontend coverage step summary
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
run: |
python scripts/coverage-summary.py \
--title "Playwright (live) frontend coverage" \
--vitest .test-state/playwright/coverage-pw-summary/coverage-summary.json \
--github-step-summary
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Playwright (live) frontend coverage" \
--vitest .test-state/playwright/coverage-pw-summary/coverage-summary.json \
--github-step-summary
- name: Upload Playwright frontend coverage
# Bundle both the aggregated summary and the raw V8 dumps so
+19 -6
View File
@@ -2,7 +2,8 @@ name: Playwright E2E (stubbed)
# Reusable workflow called from build.yml. Backend-free Playwright suite —
# fast, no Spring Boot required. Runs against the `stubbed` project which
# mocks API responses in the browser.
# mocks API responses in the browser. Fans out one job per browser
# (chromium/firefox/webkit) so all three run in parallel on their own runner.
on:
workflow_call:
@@ -11,7 +12,19 @@ permissions:
jobs:
playwright-e2e:
name: playwright-e2e (${{ matrix.browser }})
runs-on: ubuntu-latest
strategy:
# One browser breaking must not mask a failure in another - report all.
fail-fast: false
matrix:
include:
- browser: chromium
project: stubbed
- browser: firefox
project: stubbed-firefox
- browser: webkit
project: stubbed-webkit
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
@@ -27,16 +40,16 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Install Playwright (${{ matrix.browser }})
run: task e2e:install -- ${{ matrix.browser }}
- name: Build frontend (production bundle for vite preview)
env:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run stubbed E2E tests (chromium)
- name: Run stubbed E2E tests (${{ matrix.browser }})
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:stubbed -- --workers=3
run: task e2e:stubbed-project PROJECT=${{ matrix.project }} -- --workers=3
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
@@ -50,6 +63,6 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-stubbed-${{ github.run_id }}
name: playwright-report-stubbed-${{ matrix.browser }}-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 7
+9 -21
View File
@@ -1,8 +1,8 @@
name: Frontend lint, type-check, and build
# Reusable workflow called from build.yml when frontend / testing sources
# change. Runs the consolidated `task frontend:check:all` (lint, types,
# unit tests, build) and uploads the dist artifact for downstream jobs.
# change. Runs `task frontend:check:all` and uploads the
# coverage + dist artifacts for downstream jobs.
on:
workflow_call:
@@ -105,30 +105,18 @@ jobs:
comment_id: existing.id,
});
}
- name: Vitest coverage
# Separate from `frontend:check:all` so the quality-gate run stays
# uninstrumented (faster signal) and coverage stays an informational
# follow-up. Continue-on-error keeps the workflow green even when
# a handful of test files refuse to import (e.g. missing icon
# specifiers) - the summary still gets posted with whatever
# vitest managed to instrument.
id: frontend-coverage
continue-on-error: true
run: task frontend:test:coverage
- name: Set up Python for coverage summary
- name: Install uv
if: always()
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage summary
# See coverage-summary.py header - it parses XML through defusedxml
# to dodge the stdlib parser's exposure to XXE / billion-laughs.
if: always()
run: python -m pip install --quiet defusedxml
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Vitest coverage step summary
if: always()
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Frontend Vitest coverage" \
--vitest frontend/editor/coverage/coverage-summary.json \
--github-step-summary
+8 -2
View File
@@ -887,10 +887,16 @@ jobs:
# Gate publish on valid updater sigs. Runs after the review upload (so
# artifacts survive for debugging) and before action-gh-release.
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Verify updater signatures
run: |
python3 -m pip install --quiet 'cryptography==44.0.0'
python3 .github/scripts/verify-updater-signatures.py \
uv run --project engine --locked --only-group updater-signatures python .github/scripts/verify-updater-signatures.py \
./artifacts/tauri frontend/editor/src-tauri/tauri.conf.json
# workflow_dispatch path requires platform=='all' so a single-platform
+4 -3
View File
@@ -59,9 +59,10 @@ jobs:
# the story itself — a shared component, a theme token — still surfaces within
# a day.
a11y-all-stories:
name: a11y (every story)
name: a11y (every story, light + dark)
runs-on: ubuntu-latest
timeout-minutes: 60
# Two full sweeps (one per theme), each ~30 minutes of browser time.
timeout-minutes: 120
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
@@ -81,7 +82,7 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: a11y gate (every story)
- name: a11y gate (every story, light + dark)
run: task frontend:storybook:a11y
- name: Upload scan reports
+3
View File
@@ -28,6 +28,9 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: pre-commit
- name: Install Task
+9 -12
View File
@@ -51,28 +51,25 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Install Python dependencies
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: sync-files
- name: Install Python dependencies
run: |
uv sync --project engine --locked --group tools
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
- name: Sync translation TOML files
run: |
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
uv run --project engine --locked --group tools python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
- name: Sort translation TOML files
run: |
@@ -85,7 +82,7 @@ jobs:
- name: Sync README.md
run: |
python scripts/counter_translation_v3.py
uv run --project engine --locked --group tools python scripts/counter_translation_v3.py
- name: Run git add
run: |
+1 -1
View File
@@ -22,7 +22,7 @@ frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
app/core/src/main/java/stirling/software/SPDF/pdf/signature/CreateSignatureBase.java:generic-api-key:224
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
+9
View File
@@ -15,6 +15,15 @@ tasks:
cmds:
- npx playwright test --project=stubbed {{.CLI_ARGS}}
stubbed-project:
desc: "Run the stubbed E2E suite for a single Playwright project"
dir: frontend/editor
deps: [ ':frontend:prepare' ]
vars:
PROJECT: '{{.PROJECT | default "stubbed"}}'
cmds:
- npx playwright test --project={{.PROJECT}} {{.CLI_ARGS}}
live:
desc: "Run live E2E tests"
summary: |
+40 -13
View File
@@ -2,22 +2,49 @@ version: '3'
tasks:
install:
desc: "Install engine dependencies"
desc: "Install engine runtime and development dependencies"
run: once
cmds:
- uv python install 3.13.8
- uv sync
- uv sync --locked --group engine --group engine-dev
sources:
- uv.lock
- pyproject.toml
status:
- test -d .venv
lock:
desc: "Update the engine lockfile from project metadata"
cmds:
- uv lock
lock:upgrade:
desc: "Upgrade allowed engine dependencies and update the lockfile"
cmds:
- uv lock --upgrade
lock:check:
desc: "Check whether the engine lockfile is current"
cmds:
- uv lock --check
update:
desc: "Upgrade engine dependencies and synchronize the environment"
cmds:
- task: lock:upgrade
- uv sync --locked --group engine --group engine-dev
update:all:
desc: "Upgrade all Python dependency groups and synchronize the environment"
cmds:
- task: lock:upgrade
- uv sync --locked --all-groups
prepare:
desc: "Set up engine .env from template"
deps: [install]
cmds:
- uv run scripts/setup_env.py
- uv run --locked --group engine --group engine-dev scripts/setup_env.py
sources:
- scripts/setup_env.py
generates:
@@ -33,7 +60,7 @@ tasks:
env:
PYTHONUNBUFFERED: "1"
cmds:
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
- uv run --locked --group engine uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
dev:
desc: "Start engine dev server with hot reload"
@@ -45,43 +72,43 @@ tasks:
env:
PYTHONUNBUFFERED: "1"
cmds:
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --reload
- uv run --locked --group engine --group engine-dev uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --reload
lint:
desc: "Run linting"
deps: [install]
cmds:
- uv run ruff check .
- uv run --locked --group engine --group engine-dev ruff check .
lint:fix:
desc: "Auto-fix lint issues"
deps: [install]
cmds:
- uv run ruff check . --fix
- uv run --locked --group engine --group engine-dev ruff check . --fix
format:
desc: "Auto-fix code formatting"
deps: [install]
cmds:
- uv run ruff format .
- uv run --locked --group engine --group engine-dev ruff format .
format:check:
desc: "Check code formatting"
deps: [install]
cmds:
- uv run ruff format . --diff
- uv run --locked --group engine --group engine-dev ruff format . --diff
typecheck:
desc: "Run type checking"
deps: [install]
cmds:
- uv run pyright . --warnings
- uv run --locked --group engine --group engine-dev pyright . --warnings
test:
desc: "Run tests"
deps: [prepare]
cmds:
- uv run pytest tests
- uv run --locked --group engine --group engine-dev pytest tests
fix:
desc: "Auto-fix lint + format"
@@ -102,7 +129,7 @@ tasks:
desc: "Generate tool_models.py from Java OpenAPI spec (SwaggerDoc.json)"
deps: [install, ":backend:swagger"]
cmds:
- uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py
- uv run --locked --group engine --group engine-dev python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py
sources:
- ../SwaggerDoc.json
- scripts/generate_tool_models.py
@@ -114,7 +141,7 @@ tasks:
desc: "Fail if the committed tool models are out of date"
deps: [install, ":backend:swagger"]
cmds:
- uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py --check
- uv run --locked --group engine --group engine-dev python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py --check
clean:
desc: "Clean build artifacts"
+20 -20
View File
@@ -211,11 +211,13 @@ tasks:
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
storybook:a11y:
desc: "a11y regression gate over every story: fail only on NEW axe violations"
desc: "a11y regression gate over every story, light and dark: fail only on NEW axe violations"
deps: [prepare, storybook:browser]
cmds:
- node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
- SCAN_THEME=dark node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
storybook:a11y:changed:
desc: "a11y gate over the stories this branch affects (default base origin/main)"
@@ -244,13 +246,17 @@ tasks:
fi
node .storybook/a11y-scan.mjs {{.CHANGED}}
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
SCAN_THEME=dark node .storybook/a11y-scan.mjs {{.CHANGED}}
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
storybook:a11y:record:
desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)"
desc: "Re-record both a11y baselines (run after intentionally fixing/adding violations)"
deps: [prepare, storybook:browser]
cmds:
- node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record
- SCAN_THEME=dark node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record --baseline .storybook/a11y-baseline.dark.json
# ============================================================
# Code quality
@@ -451,8 +457,17 @@ tasks:
test:editor:
desc: "Run editor tests"
deps: [prepare]
vars:
COVERAGE: '{{.COVERAGE | default .CI | default "false"}}'
cmds:
- npx vitest run --root editor
- >
npx vitest run --root editor
{{if eq .COVERAGE "true"}}--coverage
--coverage.provider=v8
--coverage.reporter=text-summary
--coverage.reporter=json-summary
--coverage.reporter=html
--coverage.reportsDirectory=./coverage{{end}}
test:watch:
desc: "Run tests in watch mode"
@@ -462,24 +477,9 @@ tasks:
test:coverage:
desc: "Run tests with coverage (one-shot; CI-friendly)."
deps: [prepare]
cmds:
# `vitest run` makes this CI-safe (the bare `vitest` form enters watch
# mode). Explicit reporter list because v8 + json-summary is what the
# coverage-summary.py helper consumes; html/text are kept for humans.
#
# reportsDirectory is pinned to ./coverage relative to vitest's root
# (--root editor), so output lands at frontend/editor/coverage/. The
# CI upload step reads from that path. An earlier attempt with
# `./editor/coverage` double-nested into frontend/editor/editor/coverage;
# pinning future-proofs against vitest changing the default.
- >
npx vitest run --root editor --coverage
--coverage.provider=v8
--coverage.reporter=text-summary
--coverage.reporter=json-summary
--coverage.reporter=html
--coverage.reportsDirectory=./coverage
- task: test:editor
vars: { COVERAGE: "true" }
# ============================================================
# Code Generation
+17 -13
View File
@@ -45,6 +45,10 @@ vars:
# which owns the version and caches the binary here.
GITLEAKS_BIN: '.task/bin/gitleaks{{if eq OS "windows"}}.exe{{end}}'
env:
# Keep repository-wide checks isolated from the engine runtime environment.
UV_PROJECT_ENVIRONMENT: '.venv-pre-commit'
tasks:
default:
desc: "Check formatting, spelling, and secrets across the repo"
@@ -76,19 +80,19 @@ tasks:
desc: "Install the pinned pre-commit Python tools"
run: once
cmds:
- uv sync --project scripts/pre-commit --locked
- uv sync --project engine --locked --group pre-commit
sources:
- scripts/pre-commit/uv.lock
- scripts/pre-commit/pyproject.toml
- engine/uv.lock
- engine/pyproject.toml
status:
- test -d scripts/pre-commit/.venv
- test -d engine/.venv-pre-commit
clean:
desc: "Remove the cached gitleaks binary and the tool virtualenv"
desc: "Remove the cached gitleaks binary and the pre-commit virtualenv"
cmds:
- cmd: rm -rf scripts/pre-commit/.venv .task/bin/gitleaks
- cmd: rm -rf engine/.venv-pre-commit .task/bin/gitleaks
platforms: [linux, darwin]
- cmd: cmd /c "rmdir /s /q scripts\pre-commit\.venv & del /q .task\bin\gitleaks.exe"
- cmd: cmd /c "rmdir /s /q engine\.venv-pre-commit & del /q .task\bin\gitleaks.exe"
platforms: [windows]
ignore_error: true
@@ -97,26 +101,26 @@ tasks:
ruff:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync ruff check --line-length=127 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
- uv run --project engine --locked --group pre-commit ruff check --isolated --line-length=120 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
ruff-format:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync ruff format {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
- uv run --project engine --locked --group pre-commit ruff format --isolated --line-length=120 {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
codespell:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
- uv run --project engine --locked --group pre-commit codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
toml-sort:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
whitespace:
cmds:
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
gitleaks:
deps: [gitleaks-bin]
@@ -130,4 +134,4 @@ tasks:
internal: true
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
cmds:
- uv run --no-project python scripts/pre-commit/install_gitleaks.py
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/install_gitleaks.py
+1 -2
View File
@@ -2,8 +2,6 @@
"recommendations": [
"elagil.pre-commit-helper", // Support for pre-commit hooks to enforce code quality
"josevseb.google-java-format-for-vs-code", // Google Java code formatter to follow the Google Java Style Guide
"ms-python.black-formatter", // Python code formatter using Black
"ms-python.flake8", // Flake8 linter for Python to enforce code quality
"ms-python.python", // Official Microsoft Python extension with IntelliSense, debugging, and Jupyter support
"ms-vscode-remote.vscode-remote-extensionpack", // Remote Development Pack for SSH, WSL, and Containers
// "Oracle.oracle-java", // Oracle Java extension with additional features for Java development
@@ -19,6 +17,7 @@
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
"tamasfe.even-better-toml", // TOML language support and formatting
"oxc.oxc-vscode", // Oxc (oxlint) extension for JavaScript/TypeScript linting
]
}
+2 -1
View File
@@ -20,8 +20,9 @@
"editor.defaultFormatter": "vscode.json-language-features"
},
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
"editor.defaultFormatter": "charliermarsh.ruff"
},
"ruff.configuration": "${workspaceFolder}/engine/pyproject.toml",
"[gradle-kotlin-dsl]": {
"editor.defaultFormatter": "vscjava.vscode-gradle"
},
+2 -2
View File
@@ -21,8 +21,8 @@ dependencies {
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:8.12.6'
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
api 'org.simplejavamail:simple-java-mail:9.2.0'
api 'org.simplejavamail:outlook-module:9.2.0' // MSG file support
api 'jakarta.mail:jakarta.mail-api:2.1.5'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
@@ -1,5 +1,6 @@
package stirling.software.common.config.swagger;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
@@ -18,6 +19,7 @@ import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOCase;
import stirling.software.common.model.tool.ToolIOWhen;
import stirling.software.common.service.ToolIOParameterDefaults;
/**
* Publishes each {@link ToolIO} into the spec as {@code x-stirling-io}, which is how the frontend
@@ -49,40 +51,42 @@ public class ToolIOOperationCustomizer
if (declaration == null) {
return operation;
}
operation.addExtension(EXTENSION_NAME, toExtension(declaration));
operation.addExtension(EXTENSION_NAME, toExtension(declaration, handlerMethod.getMethod()));
operation.setDescription(appendSummaryLine(operation.getDescription(), declaration));
return operation;
}
private static Map<String, Object> toExtension(ToolIO declaration) {
private static Map<String, Object> toExtension(ToolIO declaration, Method handler) {
Map<String, Object> extension = new LinkedHashMap<>();
extension.put("accepts", names(declaration.accepts()));
extension.put("produces", declaration.produces().name());
extension.put("arity", declaration.arity().name());
if (declaration.cases().length > 0) {
extension.put("cases", cases(declaration));
extension.put("cases", cases(declaration, handler));
}
return extension;
}
private static List<Map<String, Object>> cases(ToolIO declaration) {
return Arrays.stream(declaration.cases()).map(ToolIOOperationCustomizer::toCase).toList();
private static List<Map<String, Object>> cases(ToolIO declaration, Method handler) {
return Arrays.stream(declaration.cases()).map(rule -> toCase(rule, handler)).toList();
}
private static Map<String, Object> toCase(ToolIOCase rule) {
private static Map<String, Object> toCase(ToolIOCase rule, Method handler) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put(
"when",
Arrays.stream(rule.when()).map(ToolIOOperationCustomizer::toCondition).toList());
entry.put("when", Arrays.stream(rule.when()).map(c -> toCondition(c, handler)).toList());
entry.put("produces", rule.produces().name());
entry.put("arity", rule.arity().name());
return entry;
}
private static Map<String, Object> toCondition(ToolIOWhen condition) {
private static Map<String, Object> toCondition(ToolIOWhen condition, Method handler) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("param", condition.param());
entry.put("matches", List.of(condition.matches()));
// The default the endpoint uses when this parameter is absent, so a step that never sends
// it still resolves. Omitted when the parameter is required with none.
ToolIOParameterDefaults.resolve(handler, condition.param())
.ifPresent(value -> entry.put("default", value));
return entry;
}
@@ -1047,6 +1047,8 @@ public class ApplicationProperties {
// 'https://app.example.com'). If not set, falls back to backendUrl.
private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature
private boolean enableMobileSignature =
true; // Enable drawing signatures on a phone via QR code
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
private ServerCertificate serverCertificate = new ServerCertificate();
@@ -1105,6 +1107,13 @@ public class ApplicationProperties {
@Data
public static class Encryption {
private boolean enabled = false;
/**
* Emit an audit event for every decrypt of an encrypted blob. Compliance reviewers
* (HIPAA) expect read audit, so it defaults on; busy multi-user installs can disable.
* Denied decrypts and key lifecycle events are always audited regardless.
*/
private boolean auditReads = true;
}
@Data
@@ -5,13 +5,18 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/** The runtime form of a {@link ToolIO} declaration, read off a handler method once at startup. */
public record ToolIOSpec(
Set<ToolFormat> accepts, ToolFormat produces, ToolArity arity, List<Case> cases) {
public record When(String param, List<String> matches) {
/**
* @param paramDefault the value used when the parameter is absent, or null when it has no
* default - an absent parameter then leaves the case unresolved rather than defaulted.
*/
public record When(String param, List<String> matches, String paramDefault) {
boolean holdsFor(Object value) {
String normalised = normalise(value);
@@ -19,6 +24,14 @@ public record ToolIOSpec(
}
}
/** Supplies the default value a request parameter takes when a caller omits it. */
@FunctionalInterface
public interface ParameterDefaults {
Optional<String> defaultFor(String param);
ParameterDefaults NONE = param -> Optional.empty();
}
/**
* Both sides of a condition are normalised at comparison, not at construction: the declaration
* reaches the frontend and the engine as published data, and normalising only one side there
@@ -44,25 +57,33 @@ public record ToolIOSpec(
}
public static ToolIOSpec from(ToolIO annotation) {
return from(annotation, ParameterDefaults.NONE);
}
public static ToolIOSpec from(ToolIO annotation, ParameterDefaults defaults) {
return new ToolIOSpec(
new LinkedHashSet<>(Arrays.asList(annotation.accepts())),
annotation.produces(),
annotation.arity(),
Arrays.stream(annotation.cases()).map(ToolIOSpec::toCase).toList());
Arrays.stream(annotation.cases()).map(rule -> toCase(rule, defaults)).toList());
}
private static Case toCase(ToolIOCase rule) {
List<When> when = Arrays.stream(rule.when()).map(ToolIOSpec::toWhen).toList();
private static Case toCase(ToolIOCase rule, ParameterDefaults defaults) {
List<When> when = Arrays.stream(rule.when()).map(c -> toWhen(c, defaults)).toList();
return new Case(when, rule.produces(), rule.arity());
}
private static When toWhen(ToolIOWhen condition) {
return new When(condition.param(), List.of(condition.matches()));
private static When toWhen(ToolIOWhen condition, ParameterDefaults defaults) {
return new When(
condition.param(),
List.of(condition.matches()),
defaults.defaultFor(condition.param()).orElse(null));
}
/**
* First matching {@link Case} wins. If none match but one reads a parameter we cannot see, the
* declared output comes back uncertain: a value we never saw might have picked another branch.
* First matching {@link Case} wins. A parameter the caller omitted resolves to its declared
* default; only a parameter with no default leaves the output uncertain, since an unseen value
* might then have picked another branch.
*
* @param parameters the step's configured parameters, or null when not known
*/
@@ -71,12 +92,17 @@ public record ToolIOSpec(
for (Case rule : cases) {
boolean allHold = true;
for (When condition : rule.when()) {
if (parameters == null || !parameters.containsKey(condition.param())) {
Object value;
if (parameters != null && parameters.containsKey(condition.param())) {
value = parameters.get(condition.param());
} else if (condition.paramDefault() != null) {
value = condition.paramDefault();
} else {
sawUnknownParam = true;
allHold = false;
continue;
}
allHold &= condition.holdsFor(parameters.get(condition.param()));
allHold &= condition.holdsFor(value);
}
if (allHold) {
return new Output(rule.produces(), rule.arity(), true);
@@ -17,7 +17,6 @@ import java.util.concurrent.Semaphore;
import java.util.function.Consumer;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.examples.util.DeletingRandomAccessFile;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.io.MemoryUsageSetting;
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
@@ -31,6 +30,7 @@ import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.DeletingRandomAccessFile;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFileManager;
@@ -0,0 +1,92 @@
package stirling.software.common.service;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.Optional;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.extern.slf4j.Slf4j;
/**
* The value a request parameter takes when the caller omits it, read from the request model so a
* {@code @ToolIOCase} can be resolved even for a step that never sends the parameter it branches
* on. The default is read from the field so it cannot drift.
*/
@Slf4j
public final class ToolIOParameterDefaults {
// Swagger's sentinel for an unset @Schema string member; not a real default value.
private static final String SCHEMA_UNSET = "##default";
private ToolIOParameterDefaults() {}
/**
* The default {@code param} resolves to when absent, or empty when the parameter is required
* with no declared default - in which case an unset value leaves the output genuinely unknown
* rather than defaulted, and the chain reports it as uncertain.
*
* <p>Precedence: an explicit {@code @Schema(defaultValue)}, then the field's own value (a
* primitive's language default, or an initializer), then the empty string for an optional field
* left null, and finally empty for a required field with none of the above.
*/
public static Optional<String> resolve(Method handler, String param) {
for (Parameter parameter : handler.getParameters()) {
Field field = findField(parameter.getType(), param);
if (field != null) {
return fromField(parameter.getType(), field);
}
}
return Optional.empty();
}
private static Optional<String> fromField(Class<?> owner, Field field) {
Schema schema = field.getAnnotation(Schema.class);
if (schema != null
&& !schema.defaultValue().isEmpty()
&& !SCHEMA_UNSET.equals(schema.defaultValue())) {
return Optional.of(schema.defaultValue());
}
Object value = readField(owner, field);
if (value != null) {
return Optional.of(String.valueOf(value));
}
return isRequired(field, schema) ? Optional.empty() : Optional.of("");
}
private static Object readField(Class<?> owner, Field field) {
try {
Object instance = owner.getDeclaredConstructor().newInstance();
field.setAccessible(true);
return field.get(instance);
} catch (ReflectiveOperationException | RuntimeException e) {
// A request model we cannot instantiate leaves the default unknown, which the check
// treats conservatively as uncertain. Never break startup over it.
log.warn("Could not read default of {}.{}", owner.getSimpleName(), field.getName(), e);
return null;
}
}
private static boolean isRequired(Field field, Schema schema) {
if (schema != null && schema.requiredMode() == Schema.RequiredMode.REQUIRED) {
return true;
}
return field.isAnnotationPresent(NotNull.class)
|| field.isAnnotationPresent(NotBlank.class);
}
private static Field findField(Class<?> type, String name) {
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
try {
return c.getDeclaredField(name);
} catch (NoSuchFieldException ignored) {
// Try the superclass; request models extend a shared file-input base.
}
}
return null;
}
}
@@ -67,7 +67,10 @@ public class ToolIORegistry implements ToolMetadataService, ToolIOSource {
if (annotation == null) {
return;
}
ToolIOSpec spec = ToolIOSpec.from(annotation);
Method method = handler.getMethod();
ToolIOSpec spec =
ToolIOSpec.from(
annotation, param -> ToolIOParameterDefaults.resolve(method, param));
for (String pattern : extractPatterns(info)) {
target.put(pattern, spec);
}
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.util;
package stirling.software.common.util;
import java.io.File;
import java.io.IOException;
@@ -194,7 +194,7 @@ public class EmlParser {
}
attachment.setFilename(filename);
String contentId = embedded ? stripCid(resourceName) : null;
String contentId = embedded ? stripCid(resource.getContentId()) : null;
attachment.setContentId(contentId);
String detectedContentType = EmlProcessingUtils.detectMimeType(filename, contentType);
@@ -56,8 +56,10 @@ public class RequestUriUtils {
return true;
}
// Mobile scanner page for QR code-based file uploads (peer-to-peer, no backend auth needed)
if (normalizedUri.startsWith("/mobile-scanner")) {
// Mobile pages reached by scanning a QR code (peer-to-peer, no backend auth
// needed): /mobile-scanner uploads photos, /mobile-sign draws a signature.
if (normalizedUri.startsWith("/mobile-scanner")
|| normalizedUri.startsWith("/mobile-sign")) {
return true;
}
@@ -101,7 +101,12 @@ class ToolChainValidatorConformanceTest {
for (JsonNode match : condition.get("matches")) {
matches.add(match.asString());
}
when.add(new ToolIOSpec.When(condition.get("param").asString(), matches));
JsonNode paramDefault = condition.get("default");
when.add(
new ToolIOSpec.When(
condition.get("param").asString(),
matches,
paramDefault == null ? null : paramDefault.asString()));
}
cases.add(
new ToolIOSpec.Case(
@@ -73,6 +73,13 @@ class RequestUriUtilsTest {
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
}
@Test
void testIsStaticResource_mobileSignPath() {
// The phone-side signature drawing page, reached from the Sign tool QR code.
assertTrue(RequestUriUtils.isStaticResource("/mobile-sign"));
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/mobile-sign"));
}
@Test
void testIsStaticResource_portalShell() {
// The admin portal SPA shell (/processor) is served pre-auth so it's directly navigable.
+3 -2
View File
@@ -312,8 +312,9 @@ tasks.register('cleanFrontendAssets', Delete) {
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
// Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are
// copied from the frontend build. Remove stale ones so renamed/removed tools don't linger.
// api-landing.html and mobile-upload.html are real backend source files, not generated artifacts.
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html'])
// api-landing.html, mobile-upload.html and mobile-sign.html are real backend source files,
// not generated artifacts.
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html', 'mobile-sign.html'])
// Nested prerendered route pages (e.g. settings/people.html)
delete new File(resourcesStaticDir, 'settings')
}
@@ -61,6 +61,7 @@ public class PdfOverlayController {
int overlayPos = request.getOverlayPosition();
MultipartFile[] overlayFiles = request.getOverlayFiles();
validateOverlayFiles(overlayFiles);
File[] overlayPdfFiles = new File[overlayFiles.length];
List<File> tempFiles = new ArrayList<>(); // List to keep track of temporary files
@@ -120,10 +121,29 @@ public class PdfOverlayController {
}
}
// Both fields are declared required, but @ModelAttribute binding leaves them null when the
// caller omits them, which would otherwise surface as a 500 instead of a 400.
private void validateOverlayFiles(MultipartFile[] overlayFiles) {
if (overlayFiles == null || overlayFiles.length == 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.overlayFilesRequired", "At least one overlay file is required");
}
for (MultipartFile overlayFile : overlayFiles) {
if (overlayFile == null || overlayFile.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.overlayFileEmpty", "Overlay files must not be empty");
}
}
}
private Map<Integer, String> prepareOverlayGuide(
int basePageCount, File[] overlayFiles, String mode, int[] counts, List<File> tempFiles)
throws IOException {
Map<Integer, String> overlayGuide = new HashMap<>();
if (mode == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", "null");
}
switch (mode) {
case "SequentialOverlay":
sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles);
@@ -194,6 +194,9 @@ public class ConfigController {
configData.put(
"enableMobileScanner",
applicationProperties.getSystem().isEnableMobileScanner());
configData.put(
"enableMobileSignature",
applicationProperties.getSystem().isEnableMobileSignature());
configData.put(
"mobileScannerConvertToPdf",
applicationProperties.getSystem().getMobileScannerSettings().isConvertToPdf());
@@ -62,12 +62,17 @@ public class MobileScannerController {
}
/**
* Check if mobile scanner feature is enabled
* Check if any feature backed by these transfer sessions is enabled. The mobile scanner and
* mobile signature drawing share this session/upload API, so the endpoints stay available while
* either feature is on; each flag independently controls only its own UI.
*
* @return Error response if disabled, null if enabled
*/
private ResponseEntity<Map<String, Object>> checkFeatureEnabled() {
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
boolean anyEnabled =
applicationProperties.getSystem().isEnableMobileScanner()
|| applicationProperties.getSystem().isEnableMobileSignature();
if (!anyEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(
Map.of(
@@ -275,7 +280,8 @@ public class MobileScannerController {
@Parameter(description = "Filename to download", required = true) @PathVariable
String filename) {
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
if (!applicationProperties.getSystem().isEnableMobileScanner()
&& !applicationProperties.getSystem().isEnableMobileSignature()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
@@ -13,7 +13,6 @@ import java.util.Calendar;
import java.util.List;
import org.apache.commons.io.FileUtils;
import org.apache.pdfbox.examples.signature.CreateSignatureBase;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
@@ -76,6 +75,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.pdf.signature.CreateSignatureBase;
import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
@@ -80,6 +80,8 @@ public class ReactRoutingController {
private boolean saasLandingExists = false;
private String cachedMobileUploadHtml;
private boolean mobileUploadHtmlExists = false;
private String cachedMobileSignHtml;
private boolean mobileSignHtmlExists = false;
@PostConstruct
public void init() {
@@ -103,10 +105,12 @@ public class ReactRoutingController {
}
// Desktop (Tauri) serves the SPA from its bundled webview, so a phone scanning the QR can't
// load the React /mobile-scanner route from the local backend. Cache the self-contained
// static upload page to serve at that route in desktop mode instead.
// load the React /mobile-scanner or /mobile-sign routes from the local backend. Cache the
// self-contained static pages to serve at those routes in desktop mode instead.
this.cachedMobileUploadHtml = readStaticHtml("mobile-upload.html");
this.mobileUploadHtmlExists = this.cachedMobileUploadHtml != null;
this.cachedMobileSignHtml = readStaticHtml("mobile-sign.html");
this.mobileSignHtmlExists = this.cachedMobileSignHtml != null;
// Check for external index.html first (customFiles/static/)
Path externalIndexPath = Path.of(InstallationPathConfig.getStaticPath(), "index.html");
@@ -268,6 +272,17 @@ public class ReactRoutingController {
return serveIndexHtml(request);
}
@GetMapping(value = "/mobile-sign", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveMobileSign(HttpServletRequest request) {
if (isDesktopMode() && mobileSignHtmlExists) {
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(cachedMobileSignHtml);
}
return serveIndexHtml(request);
}
@GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveTauriAuthCallback(HttpServletRequest request) {
// cachedCallbackHtml is always initialized in @PostConstruct
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.IOException;
import java.io.InputStream;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.IOException;
import java.io.InputStream;
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.IOException;
import java.io.InputStream;
@@ -15,7 +15,7 @@
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.pdfbox.examples.util;
package stirling.software.SPDF.utils;
import java.io.IOException;
import java.io.InputStream;
@@ -188,6 +188,7 @@ system:
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured.
mobileScannerSettings:
convertToPdf: true # Automatically convert uploaded images to PDF format. If false, images are kept as-is.
imageResolution: full # Image resolution for mobile uploads: 'full' (original size) or 'reduced' (max 1200px on longest side). Only applies when convertToPdf is true.
@@ -292,26 +293,15 @@ storage:
linkExpirationDays: 3 # Number of days before share links expire
signing:
enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA]
# ====================================================================================
# ENCRYPTION AT REST - PRO / ENTERPRISE LICENSE REQUIRED TO ENABLE
# ====================================================================================
# Encrypts stored files (AES-256 envelope encryption, per-team keys). The master key is
# resolved in this order:
# 1. stirling.security.fileEncryptionKey property
# 2. STIRLING_FILE_ENCRYPTION_KEY environment variable
# 3. an auto-generated configs/file-encryption.key (single-node only; cluster mode
# requires an explicitly shared key on every node)
# Generate a key with: openssl rand -base64 32
#
# *** BACK UP THE MASTER KEY. Losing it makes every encrypted stored file ***
# *** permanently unrecoverable. Verify backups against the key fingerprint logged ***
# *** at startup. ***
#
# Enabling encrypts new writes only (existing files stay readable as plaintext).
# Disabling later only stops encrypting new writes - existing encrypted files remain
# readable as long as the key material is present.
# Encryption at rest for stored files (AES-256, per-team keys). Requires a Pro or
# Enterprise licence. Key setup, cluster requirements, the encrypt-existing migration,
# the revocation kill switch and master-key rotation are documented in
# devGuide/STORAGE_ENCRYPTION_AT_REST.md
# WARNING: back up the master key (configs/file-encryption.key by default) - losing it
# makes every encrypted stored file permanently unrecoverable.
encryption:
enabled: false # set to 'true' to encrypt stored files at rest
auditReads: true # audit every decrypt of an encrypted file (denied decrypts and key lifecycle events are always audited). NOTE: audit events require an Enterprise licence; encryption itself works on Pro.
userListScope: org # Signing user-picker scope: 'org' (default) = whole instance, else caller's team only.
autoPipeline:
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
@@ -0,0 +1,642 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#c02223">
<meta name="robots" content="noindex">
<title>Stirling PDF - Draw Signature</title>
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="shortcut icon" href="favicon.ico">
<style>
:root {
--brand: #c02223;
--brand-dark: #882425;
--bg: #f3f4f6;
--card: #ffffff;
--text: #111827;
--text-dim: #6b7280;
--border: #e5e7eb;
--tile: #f9fafb;
--tile-hover: #f1f3f5;
--success: #16a34a;
--danger: #dc2626;
--shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.12);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--card: #1a1d23;
--text: #f3f4f6;
--text-dim: #9ca3af;
--border: #2c303a;
--tile: #22262e;
--tile-hover: #2a2f38;
--shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.5);
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html, body {
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg);
color: var(--text);
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: env(safe-area-inset-top) 1rem 1.5rem;
}
.wrap {
width: 100%;
max-width: 34rem;
display: flex;
flex-direction: column;
gap: 1rem;
margin: auto;
}
header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 0 0.25rem;
justify-content: center;
}
header .logo {
width: 2.75rem;
height: 2.75rem;
flex: none;
}
header .titles {
display: flex;
flex-direction: column;
line-height: 1.15;
}
header .wordmark {
height: 1.5rem;
width: auto;
display: block;
color: var(--text);
}
header .subtitle {
font-size: 0.8rem;
color: var(--text-dim);
font-weight: 500;
}
.card {
background-color: var(--card);
border-radius: 1.25rem;
box-shadow: var(--shadow);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.status {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
padding: 0.7rem 0.85rem;
border-radius: 0.75rem;
background: var(--tile);
color: var(--text-dim);
}
.status .dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--text-dim);
flex: none;
}
.status.ok { color: var(--success); }
.status.ok .dot { background: var(--success); }
.status.err { color: var(--danger); }
.status.err .dot { background: var(--danger); }
/* The pad stays white in both themes: signatures land on paper-white
pages, so drawing on white previews the real result. */
.pad {
position: relative;
width: 100%;
height: min(45vh, 20rem);
background: #ffffff;
border: 1px solid var(--border);
border-radius: 0.9rem;
overflow: hidden;
}
.pad canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: crosshair;
}
.pad .guide {
position: absolute;
left: 8%;
right: 8%;
bottom: 22%;
border-bottom: 1px dashed #d1d5db;
pointer-events: none;
}
/* Landscape phones: shorter chrome, give the pad more of the height. */
@media (orientation: landscape) and (max-height: 32rem) {
header { padding-top: 0.5rem; }
.pad { height: min(58vh, 16rem); }
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.swatches { display: flex; gap: 0.5rem; }
.swatch {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 3px solid transparent;
cursor: pointer;
}
.swatch.active { border-color: var(--brand); }
.sizes { display: flex; gap: 0.25rem; }
.size-btn {
border: 1px solid var(--border);
background: var(--tile);
color: var(--text);
border-radius: 0.5rem;
padding: 0.35rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
}
.size-btn.active {
border-color: var(--brand);
color: var(--brand);
font-weight: 600;
}
.tools { display: flex; gap: 0.5rem; }
.tool-btn {
border: 1px solid var(--border);
background: var(--tile);
color: var(--text);
border-radius: 0.5rem;
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
cursor: pointer;
}
.tool-btn:disabled { opacity: 0.45; cursor: default; }
.btn {
appearance: none;
border: none;
border-radius: 0.9rem;
background: var(--brand);
color: #fff;
font-size: 1rem;
font-weight: 600;
padding: 0.9rem 1rem;
cursor: pointer;
}
.btn:active { background: var(--brand-dark); }
.btn:disabled { opacity: 0.5; cursor: default; }
.progress {
height: 0.4rem;
border-radius: 0.25rem;
background: var(--tile);
overflow: hidden;
}
.progress span {
display: block;
height: 100%;
width: 0;
background: var(--brand);
transition: width 0.15s ease;
}
.hint {
font-size: 0.78rem;
color: var(--text-dim);
text-align: center;
line-height: 1.4;
}
.done {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 0;
text-align: center;
}
.done .check {
width: 3.25rem;
height: 3.25rem;
border-radius: 50%;
background: var(--success);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.done .check svg { width: 1.75rem; height: 1.75rem; }
.hidden { display: none !important; }
footer {
text-align: center;
font-size: 0.72rem;
color: var(--text-dim);
padding-top: 0.25rem;
}
</style>
</head>
<body>
<div class="wrap">
<header>
<svg class="logo" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<linearGradient id="lg" x1="223" x2="241" y1="372" y2="114" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#dcf1f3"/><stop offset="1" stop-color="#c2c2c9"/>
</linearGradient>
<path fill="#c02223" d="M347 14 99 69c-25 6-43 28-43 54v315c0 22 17 41 39 44l130 16 115-114 116-119-2-180c0-22-20-38-42-34l-29 7 0-14C383 25 366 10 347 14Z"/>
<path fill="#882425" d="M383 57v286l73-78-2-186c0-19-18-33-36-29Z"/>
<path fill="url(#lg)" d="M283 215s-22-29-39-37c-10-5-26-7-38-1-18 9-20 34-3 44 4 2 9 5 16 6 37 8 97 26 90 96 0 0-4 66-74 73-12 1-25 0-36-2-16-4-46-11-60-20l-3-75h7s14 33 53 50c7 3 15 4 23 3 10-1 21-7 26-23 0 0 8-23-33-36-33-10-63-21-76-55-6-16-7-34-3-52 5-22 21-54 64-68 0 0 57-16 97 6l-2 90Z"/>
</svg>
<div class="titles">
<div class="wordmark" style="font-size:1.25rem;font-weight:700;">Stirling PDF</div>
<div class="subtitle">Draw Signature</div>
</div>
</header>
<div class="card" id="signer">
<div class="status" id="status"><span class="dot"></span><span id="statusText">Connecting&hellip;</span></div>
<div class="pad">
<div class="guide"></div>
<canvas id="pad" aria-label="Signature drawing area"></canvas>
</div>
<div class="toolbar">
<div class="swatches">
<button class="swatch active" type="button" data-color="#101010" style="background:#101010" aria-label="Black ink"></button>
<button class="swatch" type="button" data-color="#1d4ed8" style="background:#1d4ed8" aria-label="Blue ink"></button>
</div>
<div class="sizes">
<button class="size-btn" type="button" data-size="2">S</button>
<button class="size-btn active" type="button" data-size="3.5">M</button>
<button class="size-btn" type="button" data-size="6">L</button>
</div>
<div class="tools">
<button class="tool-btn" id="undoBtn" type="button" disabled>Undo</button>
<button class="tool-btn" id="clearBtn" type="button" disabled>Clear</button>
</div>
</div>
<button class="btn" id="sendBtn" type="button" disabled>Send to computer</button>
<div class="progress hidden" id="progressWrap"><span id="progressBar"></span></div>
<p class="hint">Draw your signature above, then send it. It appears in the Sign tool on your computer automatically.</p>
</div>
<div class="card hidden" id="successCard">
<div class="done">
<div class="check">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 13 4 4L19 7"/></svg>
</div>
<h2>Sent!</h2>
<p>Your signature is now on your computer.</p>
<button class="tool-btn" id="againBtn" type="button">Draw another</button>
</div>
</div>
<footer>Stirling PDF &middot; signatures transfer directly to your desktop</footer>
</div>
<script>
(function () {
"use strict";
var API_BASE = new URL(".", window.location.href).href;
var params = new URLSearchParams(window.location.search);
var sessionId = (params.get("session") || "").trim();
var SESSION_RE = /^[A-Za-z0-9-]{8,128}$/;
var els = {
status: document.getElementById("status"),
statusText: document.getElementById("statusText"),
pad: document.getElementById("pad"),
undoBtn: document.getElementById("undoBtn"),
clearBtn: document.getElementById("clearBtn"),
sendBtn: document.getElementById("sendBtn"),
progressWrap: document.getElementById("progressWrap"),
progressBar: document.getElementById("progressBar"),
signer: document.getElementById("signer"),
successCard: document.getElementById("successCard"),
againBtn: document.getElementById("againBtn")
};
var sessionReady = false;
var penColor = "#101010";
var penSize = 3.5;
var strokes = [];
var activeStroke = null;
function setStatus(text, kind) {
els.statusText.textContent = text;
els.status.className = "status" + (kind ? " " + kind : "");
}
function refreshButtons() {
var hasInk = strokes.length > 0;
els.undoBtn.disabled = !hasInk;
els.clearBtn.disabled = !hasInk;
els.sendBtn.disabled = !sessionReady || !hasInk;
}
// ---- drawing ------------------------------------------------------
// Strokes are stored in CSS-pixel space; the backing store is scaled
// by devicePixelRatio so ink lands exactly under the finger.
var ctx = els.pad.getContext("2d");
function resizeCanvas() {
var rect = els.pad.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
els.pad.width = Math.max(1, Math.round(rect.width * dpr));
els.pad.height = Math.max(1, Math.round(rect.height * dpr));
redraw();
}
function drawStroke(target, stroke) {
var pts = stroke.points;
if (pts.length === 0) return;
target.strokeStyle = stroke.color;
target.fillStyle = stroke.color;
target.lineWidth = stroke.size;
target.lineCap = "round";
target.lineJoin = "round";
if (pts.length === 1) {
target.beginPath();
target.arc(pts[0].x, pts[0].y, stroke.size / 2, 0, Math.PI * 2);
target.fill();
return;
}
// Quadratic midpoint smoothing turns pointer samples into a pen-like line
target.beginPath();
target.moveTo(pts[0].x, pts[0].y);
for (var i = 1; i < pts.length - 1; i++) {
var midX = (pts[i].x + pts[i + 1].x) / 2;
var midY = (pts[i].y + pts[i + 1].y) / 2;
target.quadraticCurveTo(pts[i].x, pts[i].y, midX, midY);
}
var last = pts[pts.length - 1];
target.lineTo(last.x, last.y);
target.stroke();
}
function redraw() {
var dpr = window.devicePixelRatio || 1;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, els.pad.width / dpr, els.pad.height / dpr);
for (var i = 0; i < strokes.length; i++) drawStroke(ctx, strokes[i]);
if (activeStroke) drawStroke(ctx, activeStroke);
}
function pointFrom(e) {
var rect = els.pad.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
els.pad.addEventListener("pointerdown", function (e) {
if (activeStroke) return; // one stroke at a time
els.pad.setPointerCapture(e.pointerId);
activeStroke = { color: penColor, size: penSize, points: [pointFrom(e)] };
redraw();
});
els.pad.addEventListener("pointermove", function (e) {
if (!activeStroke) return;
var events = e.getCoalescedEvents ? e.getCoalescedEvents() : [e];
var rect = els.pad.getBoundingClientRect();
for (var i = 0; i < events.length; i++) {
activeStroke.points.push({
x: events[i].clientX - rect.left,
y: events[i].clientY - rect.top
});
}
redraw();
});
function endStroke() {
if (!activeStroke) return;
strokes.push(activeStroke);
activeStroke = null;
redraw();
refreshButtons();
}
els.pad.addEventListener("pointerup", endStroke);
els.pad.addEventListener("pointercancel", endStroke);
els.undoBtn.onclick = function () {
strokes.pop();
redraw();
refreshButtons();
};
els.clearBtn.onclick = function () {
strokes = [];
activeStroke = null;
redraw();
refreshButtons();
};
document.querySelectorAll(".swatch").forEach(function (btn) {
btn.onclick = function () {
penColor = btn.getAttribute("data-color");
document.querySelectorAll(".swatch").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
};
});
document.querySelectorAll(".size-btn").forEach(function (btn) {
btn.onclick = function () {
penSize = parseFloat(btn.getAttribute("data-size"));
document.querySelectorAll(".size-btn").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
};
});
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
// ---- export + upload ---------------------------------------------
function exportBlob(callback) {
if (strokes.length === 0) { callback(null); return; }
// Crop to the inked region (plus padding) so the signature
// stamps tightly on the desktop side.
var rect = els.pad.getBoundingClientRect();
var PAD = 12;
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
strokes.forEach(function (stroke) {
var reach = stroke.size / 2 + PAD;
stroke.points.forEach(function (p) {
if (p.x - reach < minX) minX = p.x - reach;
if (p.y - reach < minY) minY = p.y - reach;
if (p.x + reach > maxX) maxX = p.x + reach;
if (p.y + reach > maxY) maxY = p.y + reach;
});
});
minX = Math.max(0, minX);
minY = Math.max(0, minY);
maxX = Math.min(rect.width, maxX);
maxY = Math.min(rect.height, maxY);
var dpr = window.devicePixelRatio || 1;
var out = document.createElement("canvas");
out.width = Math.max(1, Math.round((maxX - minX) * dpr));
out.height = Math.max(1, Math.round((maxY - minY) * dpr));
var outCtx = out.getContext("2d");
outCtx.setTransform(dpr, 0, 0, dpr, -minX * dpr, -minY * dpr);
for (var i = 0; i < strokes.length; i++) drawStroke(outCtx, strokes[i]);
out.toBlob(callback, "image/png");
}
els.sendBtn.onclick = function () {
if (!sessionReady || strokes.length === 0) return;
exportBlob(function (blob) {
if (!blob) return;
var form = new FormData();
form.append("files", blob, "signature-draw-" + Date.now() + ".png");
var xhr = new XMLHttpRequest();
xhr.open("POST", API_BASE + "api/v1/mobile-scanner/upload/" + encodeURIComponent(sessionId));
els.sendBtn.disabled = true;
els.progressWrap.classList.remove("hidden");
els.progressBar.style.width = "0%";
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
els.progressBar.style.width = Math.round((e.loaded / e.total) * 100) + "%";
}
};
xhr.onload = function () {
els.progressWrap.classList.add("hidden");
if (xhr.status >= 200 && xhr.status < 300) {
els.signer.classList.add("hidden");
els.successCard.classList.remove("hidden");
} else {
var msg = "Sending failed. Please try again.";
if (xhr.status === 403) msg = "Mobile signatures are not enabled on this server.";
else if (xhr.status === 400) msg = "Session expired. Re-scan the QR code.";
setStatus(msg, "err");
refreshButtons();
}
};
xhr.onerror = function () {
els.progressWrap.classList.add("hidden");
setStatus("Network error. Stay on the same Wi-Fi and try again.", "err");
refreshButtons();
};
xhr.send(form);
});
};
els.againBtn.onclick = function () {
strokes = [];
activeStroke = null;
els.successCard.classList.add("hidden");
els.signer.classList.remove("hidden");
resizeCanvas();
refreshButtons();
};
// ---- session ------------------------------------------------------
function init() {
if (!sessionId || !SESSION_RE.test(sessionId)) {
setStatus("Invalid link. Re-scan the QR code on your computer.", "err");
return;
}
setStatus("Connecting…");
fetch(API_BASE + "api/v1/mobile-scanner/validate-session/" + encodeURIComponent(sessionId))
.then(function (res) {
if (res.ok) {
return res.json().then(function (data) {
if (data && data.valid) {
setStatus("Connected to your computer", "ok");
sessionReady = true;
refreshButtons();
} else {
setStatus("Session expired. Re-scan the QR code.", "err");
}
});
}
if (res.status === 403) {
setStatus("Mobile signatures are not enabled on this server.", "err");
} else {
setStatus("Session not found. Re-scan the QR code.", "err");
}
})
.catch(function () {
setStatus("Can't reach your computer. Stay on the same Wi-Fi.", "err");
});
}
init();
})();
</script>
</body>
</html>
@@ -16,6 +16,7 @@ To adjust the DPI resolution for rendering PDF pages:
import argparse
import os
from pdf2image import convert_from_path
from PIL import Image
@@ -149,9 +150,7 @@ def main(pdf_image_path, output_dir, dpi=300, single_images_flag=False):
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Convert a PDF file to WebP images.")
parser.add_argument("pdf_path", help="The path to the input PDF file.")
parser.add_argument(
"output_dir", help="The directory where the WebP images should be saved."
)
parser.add_argument("output_dir", help="The directory where the WebP images should be saved.")
parser.add_argument(
"--dpi",
type=int,
@@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOSpec;
import stirling.software.common.service.ToolIOParameterDefaults;
/**
* Every document-transforming endpoint must declare its I/O, or it becomes a hole in the
@@ -233,6 +234,32 @@ class ToolIODeclarationCoverageTest {
assertEquals(Set.of("ps", "pcl", "xps"), declared);
}
@Test
void anAbsentParameterResolvesToItsRequestModelDefault() {
// A pipeline step often omits a parameter a case branches on. The default is read from the
// request model, so the output resolves anyway instead of coming back uncertain.
// Auto Rotate never sends dryRun; its default (false) means the JSON branch cannot fire.
assertEquals(
ToolFormat.PDF,
spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).format());
assertTrue(spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).certain());
// Change Permissions posts to add-password with no password fields; both default to blank,
// so the unencrypted branch fires and it is not mistaken for producing an encrypted PDF.
assertEquals(
ToolFormat.PDF,
spec("/api/v1/security/add-password").resolveOutput(Map.of()).format());
assertTrue(spec("/api/v1/security/add-password").resolveOutput(Map.of()).certain());
}
@Test
void aRequiredParameterWithNoDefaultStaysUncertainWhenAbsent() {
// pdf/text branches on outputFormat, which is required with no default. Absent, its output
// is genuinely txt-or-rtf-dependent, so it must remain uncertain rather than assume TEXT.
assertFalse(spec("/api/v1/convert/pdf/text").resolveOutput(Map.of()).certain());
}
@Test
void onlyRemovePasswordAcceptsAnEncryptedDocument() {
assertTrue(
@@ -288,7 +315,11 @@ class ToolIODeclarationCoverageTest {
}
required.add(full);
if (declaration != null) {
declared.put(full, ToolIOSpec.from(declaration));
declared.put(
full,
ToolIOSpec.from(
declaration,
param -> ToolIOParameterDefaults.resolve(method, param)));
}
}
}
@@ -49,6 +49,33 @@ class MobileScannerControllerTest {
when(systemProps.isEnableMobileScanner()).thenReturn(false);
}
// --- shared-endpoint gating: scanner and mobile signature share this API ---
@Test
void createSession_whenOnlyMobileSignatureEnabled_returnsOk() {
// The signature feature must work with the scanner switched off.
when(applicationProperties.getSystem()).thenReturn(systemProps);
when(systemProps.isEnableMobileScanner()).thenReturn(false);
when(systemProps.isEnableMobileSignature()).thenReturn(true);
SessionInfo sessionInfo = new SessionInfo("test-session", 1000L, 601000L, 600000L);
when(mobileScannerService.createSession("test-session")).thenReturn(sessionInfo);
ResponseEntity<Map<String, Object>> response = controller.createSession("test-session");
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
void createSession_whenBothFeaturesDisabled_returnsForbidden() {
when(applicationProperties.getSystem()).thenReturn(systemProps);
when(systemProps.isEnableMobileScanner()).thenReturn(false);
when(systemProps.isEnableMobileSignature()).thenReturn(false);
ResponseEntity<Map<String, Object>> response = controller.createSession("test-session");
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
}
// --- createSession tests ---
@Test
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.util;
package stirling.software.SPDF.utils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@@ -19,6 +19,9 @@ public enum AuditEventType {
// File operations - STANDARD level
FILE_OPERATION("File operation"),
// Storage encryption at rest - STANDARD level
STORAGE_ENCRYPTION("Storage encryption operation"),
// PDF operations - STANDARD level
PDF_PROCESS("PDF processing operation"),
@@ -0,0 +1,39 @@
package stirling.software.proprietary.failure;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
/**
* "Seen, and I own it." Moves {@code NEW} to {@code ACKNOWLEDGED} so the row stops counting as
* unread while staying in the open list. Touches only the status, not the document, ledger or run.
*
* <p>Re-acknowledging is a no-op that keeps the original actor and timestamp, so the first person
* to pick it up stays credited.
*/
@Component
@RequiredArgsConstructor
public class AcknowledgeAction implements FailureAction {
private final FileRunEventStore store;
@Override
public FailureActionId id() {
return FailureActionId.ACKNOWLEDGE;
}
@Override
public FileRunEvent execute(FileRunEvent event, Map<String, String> inputs, String actor) {
// Guarded on NEW so a racing acknowledger cannot re-stamp the row, and so re-acknowledging
// returns the first actor's row rather than taking their credit.
return store.applyStatusOnce(
event.id(),
event.teamId(),
FileRunEventStatus.ACKNOWLEDGED,
actor,
List.of(FileRunEventStatus.NEW));
}
}
@@ -0,0 +1,32 @@
package stirling.software.proprietary.failure;
import java.util.Map;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
/**
* "No remediation will happen; close it." Terminal. Named Dismiss rather than Reject because it
* closes the incident, not the document: nothing is deleted or delivered, the ledger row is left
* alone, and a different file failing the same way still opens its own incident.
*
* <p>Recurrences of this exact failure fold onto the dismissed row, which is what makes "stop
* showing me this" hold for a source that re-lists the same failing file each sweep.
*/
@Component
@RequiredArgsConstructor
public class DismissAction implements FailureAction {
private final FileRunEventStore store;
@Override
public FailureActionId id() {
return FailureActionId.DISMISS;
}
@Override
public FileRunEvent execute(FileRunEvent event, Map<String, String> inputs, String actor) {
return store.applyStatus(event.id(), event.teamId(), FileRunEventStatus.DISMISSED, actor);
}
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.failure;
import java.util.Map;
/**
* The behaviour behind one {@link FailureActionId}. Implementations are Spring beans injected as a
* {@code List} and resolved by id, the pattern already used for {@code InputSource}, {@code
* PolicyOutputSink} and {@code PolicyTrigger}.
*
* <p>Keeping behaviour out of the registry keeps that pure data, so a new kind ships by declaring
* an action id that already has a handler.
*/
public interface FailureAction {
FailureActionId id();
/**
* Apply the action and return the updated event. {@code inputs} carries whatever the action
* declared it needs, which is nothing for the two that exist today. Implementations leave the
* document, ledger, run and output destinations alone unless that is the action's purpose.
*/
FileRunEvent execute(FileRunEvent event, Map<String, String> inputs, String actor);
}
@@ -0,0 +1,49 @@
package stirling.software.proprietary.failure;
import lombok.Getter;
/**
* Why an action could not be dispatched. Carries a {@link Reason} rather than an HTTP status, so
* the service stays web-agnostic and the controller owns the mapping.
*/
@Getter
public class FailureActionException extends RuntimeException {
public enum Reason {
/**
* No such event, it belongs to another team, or the caller's team did not resolve. One
* reason for all three, so the response does not vary with which it was. Unrelated to
* {@link FailureKind#UNKNOWN}, which is an unclassified failure rather than a refused
* action.
*/
EVENT_NOT_FOUND,
/** The action id is not in the vocabulary at all, or has no registered handler. */
ACTION_NOT_RECOGNISED,
/**
* The action exists but this kind does not declare it, so an incoherent pairing (releasing
* a document whose destination is what failed) cannot be dispatched even by hand.
*
* <p>Unreachable today: both kinds declare both actions, so no request can trip this guard
* until a kind ships with a restricted action set. Declared now because the guard must
* exist before that kind does, not after.
*/
ACTION_NOT_DECLARED,
/** The event is already closed, so no further transition is possible. */
ALREADY_CLOSED
}
private final Reason reason;
public FailureActionException(Reason reason, String message) {
this(reason, message, null);
}
/** For a refusal that follows from a lower-level failure, so its stack is not dropped. */
public FailureActionException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}
}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.failure;
/**
* The actions a {@link FailureKind} may declare. Both are incident dispositions: they change how
* the event is shown and touch nothing else, which is what makes them valid for every kind
* including {@link FailureKind#UNKNOWN}, and why there is no {@code APPROVE} yet.
*/
public enum FailureActionId {
ACKNOWLEDGE,
DISMISS
}
@@ -0,0 +1,68 @@
package stirling.software.proprietary.failure;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
/**
* Resolves a {@link FailureActionId} to the bean that implements it. The startup check is the
* point: because kinds declare action ids as data, one could name an action nobody implements,
* which would otherwise show up as a button that 400s rather than as a failed boot.
*/
@Slf4j
@Service
public class FailureActionRegistry {
private final Map<FailureActionId, FailureAction> byId = new EnumMap<>(FailureActionId.class);
public FailureActionRegistry(List<FailureAction> actions) {
for (FailureAction action : actions) {
FailureAction clash = byId.put(action.id(), action);
if (clash != null) {
throw new IllegalStateException(
"Two handlers registered for action "
+ action.id()
+ ": "
+ clash.getClass().getName()
+ " and "
+ action.getClass().getName());
}
}
}
/**
* Fail fast if any kind declares an action with no handler, naming every gap rather than the
* first, so one boot tells you everything that is missing.
*/
@PostConstruct
void verifyEveryDeclaredActionHasAHandler() {
List<String> gaps =
Arrays.stream(FailureKind.values())
.flatMap(
kind ->
kind.getActions().stream()
.filter(action -> !byId.containsKey(action))
.map(action -> kind.getId() + " -> " + action))
.toList();
if (!gaps.isEmpty()) {
throw new IllegalStateException(
"Failure kinds declare actions with no registered handler: " + gaps);
}
log.debug(
"Failure action registry initialised with {} handler(s) for {} kind(s)",
byId.size(),
FailureKind.values().length);
}
public Optional<FailureAction> find(FailureActionId id) {
return Optional.ofNullable(byId.get(id));
}
}
@@ -0,0 +1,109 @@
package stirling.software.proprietary.failure;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientResponseException;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.ExceptionUtils;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Maps a thrown failure onto a {@link FailureKind}. A tool's 4xx arrives as a {@link
* RestClientResponseException} whose body is the Problem Details document carrying {@code
* errorCode}, so this matches on codes rather than exception messages.
*
* <p>Anything unrecognised becomes {@link FailureKind#UNKNOWN}, so every failed run still gets a
* record.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FailureClassifier {
/** Set by {@code GlobalExceptionHandler#createProblemDetailResponse}. */
private static final String ERROR_CODE_PROPERTY = "errorCode";
/**
* Depth bound on the cause chain. The JDK forbids self-causation but not a longer cycle (A
* caused by B caused by A), which an unbounded walk would spin on. Real chains are a handful
* deep.
*/
private static final int MAX_CAUSE_DEPTH = 16;
private final ObjectMapper objectMapper;
/**
* Refuse to start on an ambiguous registry: two kinds claiming one code would make {@link
* #classify} depend on declaration order. Checked here because this is what resolves codes to
* kinds, and at boot so the message names the codes rather than arriving as a class-init error.
*/
@PostConstruct
void verifyNoErrorCodeIsClaimedTwice() {
List<String> duplicates = FailureKind.duplicateErrorCodes();
if (!duplicates.isEmpty()) {
throw new IllegalStateException(
"Error codes claimed by more than one failure kind: " + duplicates);
}
}
/** Never null, never throws. A classifier that can fail would lose the failure it describes. */
public FailureKind classify(Throwable throwable) {
Throwable current = throwable;
for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) {
FailureKind resolved = classifyOne(current);
if (resolved != FailureKind.UNKNOWN) {
return resolved;
}
Throwable cause = current.getCause();
current = cause == current ? null : cause;
}
return FailureKind.UNKNOWN;
}
private FailureKind classifyOne(Throwable throwable) {
// A tool step's 4xx/5xx: the Problem Details body names the error code.
if (throwable instanceof RestClientResponseException responseException) {
String code = errorCodeFromBody(responseException);
if (code != null) {
return FailureKind.byErrorCode(code).orElse(FailureKind.UNKNOWN);
}
}
// Thrown in-process (not over loopback): the exception carries its own code.
if (throwable instanceof ExceptionUtils.ErrorCodeProvider provider) {
return FailureKind.byErrorCode(provider.getErrorCode()).orElse(FailureKind.UNKNOWN);
}
return FailureKind.UNKNOWN;
}
/**
* Pull {@code errorCode} from a Problem Details body, or null when the body is absent, not
* JSON, or has no such property (an entitlement sentinel has {@code error} instead).
*/
private String errorCodeFromBody(RestClientResponseException exception) {
String body = exception.getResponseBodyAsString();
if (body == null || body.isBlank()) {
return null;
}
try {
JsonNode root = objectMapper.readTree(body);
JsonNode code = root.get(ERROR_CODE_PROPERTY);
if (code == null || !code.isTextual()) {
return null;
}
String text = code.asString();
return text.isBlank() ? null : text;
} catch (JacksonException e) {
log.debug("Downstream error body was not JSON; classifying as UNKNOWN");
return null;
}
}
}
@@ -0,0 +1,236 @@
package stirling.software.proprietary.failure;
import static stirling.software.proprietary.failure.FailureActionId.ACKNOWLEDGE;
import static stirling.software.proprietary.failure.FailureActionId.DISMISS;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import lombok.AccessLevel;
import lombok.Getter;
/**
* The registry of failure kinds, described as data: a stable id, i18n keys and an English fallback
* like {@code ExceptionUtils.ErrorCode}, plus the facets a review surface needs.
*
* <p>Actions are declared here but implemented in {@link FailureAction} beans resolved by id, so a
* new kind ships as a registry entry plus copy. Two members today: {@link #UNKNOWN} gives every
* failed run a record, and kinds get promoted out of it as production shows what occurs.
*/
@Getter
public enum FailureKind {
INPUT_PASSWORD_PROTECTED(
FailureStage.INPUT,
FailureSeverity.ERROR,
FailureRemedy.NEEDS_USER_INPUT,
FailureScope.FILE,
errorCodes("E004"),
fallback("This document is password-protected, so the pipeline could not read it."),
offer(ACKNOWLEDGE),
offer(DISMISS, "dismissSkipFile")),
UNKNOWN(
FailureStage.INTERNAL,
FailureSeverity.ERROR,
FailureRemedy.PERMANENT,
FailureScope.RUN,
noErrorCodes(),
fallback("This run failed for a reason Stirling does not yet recognise."),
offer(ACKNOWLEDGE),
offer(DISMISS));
private static final String KEY_PREFIX = "portal.failures.kind.";
private static final String ACTION_KEY_PREFIX = "portal.failures.action.";
/**
* Every claimed {@code ErrorCode}, to the kind claiming it. Indexed once rather than scanned
* per lookup, so a duplicate cannot be resolved by declaration order without anyone noticing. A
* duplicate is refused at boot; see {@link #duplicateErrorCodes()}.
*/
private static final Map<String, FailureKind> BY_ERROR_CODE = indexErrorCodes();
private final FailureStage stage;
private final FailureSeverity severity;
private final FailureRemedy remedy;
private final FailureScope scope;
/** English fallback, used when the client has no translation for {@link #getTitleKey()}. */
private final String defaultTitle;
/** Codes from {@code ExceptionUtils.ErrorCode} that map onto this kind; may be empty. */
private final List<String> errorCodes;
/**
* What the review surface may offer, in display order. Never empty. No generated accessor,
* because {@link Offer} is private; read via {@link #getActions()} or {@link #labelKeyFor}.
*/
@Getter(AccessLevel.NONE)
private final List<Offer> offers;
FailureKind(
FailureStage stage,
FailureSeverity severity,
FailureRemedy remedy,
FailureScope scope,
List<String> errorCodes,
String defaultTitle,
Offer... offers) {
this.stage = stage;
this.severity = severity;
this.remedy = remedy;
this.scope = scope;
this.errorCodes = List.copyOf(errorCodes);
this.defaultTitle = defaultTitle;
this.offers = List.of(offers);
}
/**
* One action this kind offers, with the key to label it by. One ordered list rather than ids
* plus a parallel map of overrides, which could disagree with each other.
*
* @param labelKeySuffix key under {@code portal.failures.action.}, or null for the generic
* label
*/
private record Offer(FailureActionId id, String labelKeySuffix) {}
/** An action labelled by this kind's own wording, where the generic label reads badly. */
private static Offer offer(FailureActionId id, String labelKeySuffix) {
return new Offer(id, labelKeySuffix);
}
/** An action labelled by the shared wording for that action. */
private static Offer offer(FailureActionId id) {
return new Offer(id, null);
}
/**
* The {@code ErrorCode}s this kind claims. Java has no named arguments, so these factories
* exist to label the two constructor arguments whose types do not already name themselves.
*/
private static List<String> errorCodes(String... codes) {
return List.of(codes);
}
/** Claims no {@code ErrorCode}: reached only through the classifier's fallback. */
private static List<String> noErrorCodes() {
return List.of();
}
/** The English text shown when the client has no translation for this kind. */
private static String fallback(String englishTitle) {
return englishTitle;
}
/** Stable wire id. Never renamed once shipped: persisted rows reference it. */
public String getId() {
return name();
}
public String getTitleKey() {
return KEY_PREFIX + lowerCamelId() + ".title";
}
public String getDescriptionKey() {
return KEY_PREFIX + lowerCamelId() + ".description";
}
/** The actions this kind offers, in display order. */
public List<FailureActionId> getActions() {
return offers.stream().map(Offer::id).toList();
}
/** Whether this kind offers {@code action}. The dispatch guard: see {@code FailureActionId}. */
public boolean declares(FailureActionId action) {
return offers.stream().anyMatch(offer -> offer.id() == action);
}
/** The label key for {@code action}: this kind's own wording, else the generic one. */
public String labelKeyFor(FailureActionId action) {
return offers.stream()
.filter(offer -> offer.id() == action && offer.labelKeySuffix() != null)
.map(offer -> ACTION_KEY_PREFIX + offer.labelKeySuffix())
.findFirst()
.orElseGet(() -> genericLabelKey(action));
}
/** The label key used when a kind supplies no wording of its own. */
static String genericLabelKey(FailureActionId action) {
return ACTION_KEY_PREFIX + toLowerCamel(action.name());
}
/**
* Lookup by wire id. Empty rather than throwing: ids arrive from persisted rows and clients.
*/
public static Optional<FailureKind> byId(String id) {
if (id == null || id.isBlank()) {
return Optional.empty();
}
return Arrays.stream(values()).filter(kind -> kind.name().equals(id)).findFirst();
}
private static Map<String, FailureKind> indexErrorCodes() {
Map<String, FailureKind> index = new HashMap<>();
for (FailureKind kind : values()) {
for (String code : kind.errorCodes) {
index.putIfAbsent(code, kind);
}
}
return Map.copyOf(index);
}
/**
* Codes claimed by more than one kind, which would make classification depend on declaration
* order. Empty in a well-formed registry.
*
* <p>Reported for a caller to act on rather than thrown from class init, where it would arrive
* as an {@code ExceptionInInitializerError} blamed on whatever touched the enum first, then as
* {@code NoClassDefFoundError} everywhere after. {@link FailureClassifier} refuses to start.
*/
static List<String> duplicateErrorCodes() {
return duplicatesIn(Arrays.stream(values()).flatMap(kind -> kind.errorCodes.stream()));
}
/**
* The codes appearing more than once, first-seen order. Split out from the registry because the
* registry is a closed enum: this is the only seam at which the detection itself can be shown
* to find anything.
*/
static List<String> duplicatesIn(Stream<String> codes) {
Set<String> seen = new HashSet<>();
return codes.filter(code -> !seen.add(code)).distinct().toList();
}
/** The kind claiming {@code errorCode}, if any. Empty for a code no kind has adopted yet. */
public static Optional<FailureKind> byErrorCode(String errorCode) {
if (errorCode == null || errorCode.isBlank()) {
return Optional.empty();
}
return Optional.ofNullable(BY_ERROR_CODE.get(errorCode));
}
private String lowerCamelId() {
return toLowerCamel(name());
}
/**
* {@code INPUT_PASSWORD_PROTECTED} to {@code inputPasswordProtected}, for i18n key building.
*/
private static String toLowerCamel(String screamingSnake) {
String[] parts = screamingSnake.toLowerCase(Locale.ROOT).split("_");
StringBuilder out = new StringBuilder(parts[0]);
for (int i = 1; i < parts.length; i++) {
if (parts[i].isEmpty()) {
continue;
}
out.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1));
}
return out.toString();
}
}
@@ -0,0 +1,43 @@
package stirling.software.proprietary.failure;
import java.util.List;
/**
* Wire shape of one registry entry, served so a client can describe kinds it was not built with.
* Also a feature probe: a build without the proprietary module has no such route, so a 404 means
* there is no failure tracking here.
*/
public record FailureKindView(
String id,
FailureStage stage,
FailureSeverity severity,
FailureRemedy remedy,
FailureScope scope,
List<String> errorCodes,
String titleKey,
String descriptionKey,
String defaultTitle,
List<ActionDeclaration> actions) {
public static FailureKindView of(FailureKind kind) {
return new FailureKindView(
kind.getId(),
kind.getStage(),
kind.getSeverity(),
kind.getRemedy(),
kind.getScope(),
kind.getErrorCodes(),
kind.getTitleKey(),
kind.getDescriptionKey(),
kind.getDefaultTitle(),
kind.getActions().stream()
.map(
action ->
new ActionDeclaration(
action.name(), kind.labelKeyFor(action)))
.toList());
}
/** An action this kind offers, with the label key to render it by. */
public record ActionDeclaration(String id, String labelKey) {}
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.failure;
/**
* What kind of run failed, not where it was started from. A policy is a policy whether a schedule
* fired it or someone clicked Run in the editor, so the two questions are answered separately:
* {@code actor} names the person for an attended run and {@code sourceId} names the folder, bucket
* or webhook for an unattended one.
*/
public enum FailureOrigin {
/** One tool called directly, with no policy around it. Today that means the editor. */
TOOL,
/** The policy engine ran it, however the run was triggered. */
POLICY,
/**
* The watched-folder pipeline that predates policies. Declared ahead of its producer: nothing
* writes this yet, because that pipeline records no failures at all. Instrumenting it is its
* own piece of work.
*/
PIPELINE
}
@@ -0,0 +1,14 @@
package stirling.software.proprietary.failure;
/**
* What intervention would clear this failure. Advisory metadata for the review surface; nothing
* branches on it server-side yet.
*/
public enum FailureRemedy {
TRANSIENT,
NEEDS_USER_INPUT,
NEEDS_FILE_FIX,
NEEDS_CONFIG_FIX,
NEEDS_SERVER_FIX,
PERMANENT
}
@@ -0,0 +1,13 @@
package stirling.software.proprietary.failure;
/**
* What the failure is about, which is what the dedup key groups repeats by: one file, run, policy,
* source, or the whole server.
*/
public enum FailureScope {
FILE,
RUN,
POLICY,
SOURCE,
SERVER
}
@@ -0,0 +1,8 @@
package stirling.software.proprietary.failure;
/** How loudly a failure kind should be surfaced. */
public enum FailureSeverity {
ERROR,
WARNING,
INFO
}
@@ -0,0 +1,17 @@
package stirling.software.proprietary.failure;
/**
* Where in a run's life the failure happened: reading the document ({@code INPUT}), a tool step or
* the engine ({@code INTERNAL}), delivery ({@code OUTPUT}), a gate refusing it ({@code BLOCKED}),
* or never admitted ({@code NEVER_RAN}).
*
* <p>All five declared up front so a later kind needs no enum change, which would strand the value
* already snapshotted on existing rows.
*/
public enum FailureStage {
INPUT,
INTERNAL,
OUTPUT,
BLOCKED,
NEVER_RAN
}
@@ -0,0 +1,59 @@
package stirling.software.proprietary.failure;
import java.time.Instant;
/**
* Read model for one recorded incident: the entity's fields with the registry kind already
* resolved.
*
* <p>The facets are the row's own snapshots and can differ from {@code kind}'s current facets on an
* old row, which is the point of snapshotting, so read them here rather than from {@code kind}.
*/
public record FileRunEvent(
String id,
Long teamId,
String actor,
FailureKind kind,
FailureStage stage,
FailureSeverity severity,
FailureScope scope,
FailureOrigin origin,
String policyId,
String runId,
String fileId,
String detail,
String dedupKey,
int occurrences,
FileRunEventStatus status,
String statusActor,
Instant statusAt,
Instant createdAt,
Instant lastSeenAt) {
/**
* Project an entity. An unrecognised {@code kindId} (written by a newer build, or since
* removed) falls back to {@link FailureKind#UNKNOWN}, so the row stays readable and actionable.
*/
public static FileRunEvent of(FileRunEventEntity entity) {
return new FileRunEvent(
entity.getId(),
entity.getTeamId(),
entity.getActor(),
FailureKind.byId(entity.getKindId()).orElse(FailureKind.UNKNOWN),
entity.getStage(),
entity.getSeverity(),
entity.getScope(),
entity.getOrigin(),
entity.getPolicyId(),
entity.getRunId(),
entity.getFileId(),
entity.getDetail(),
entity.getDedupKey(),
entity.getOccurrences(),
entity.getStatus(),
entity.getStatusActor(),
entity.getStatusAt(),
entity.getCreatedAt(),
entity.getLastSeenAt());
}
}
@@ -0,0 +1,146 @@
package stirling.software.proprietary.failure;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Read and triage recorded failures for the caller's team. Note the absence of a team parameter:
* the team comes from the authenticated principal, never the request.
*
* <p>Reviewing failures is a leader-level capability, gated the same way policy editing is: see
* {@link #requireFailureReviewAllowed()}.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/file-run-events")
@Hidden
@RequiredArgsConstructor
@Tag(name = "File run events", description = "Recorded policy and pipeline failures")
public class FileRunEventController {
/** Cap on one page, so a client cannot ask for the whole table. */
private static final int MAX_LIMIT = 200;
private static final int DEFAULT_LIMIT = 50;
private final FileRunEventService service;
private final PolicyManagementAuthority policyManagementAuthority;
private final ApplicationProperties applicationProperties;
@GetMapping
@Operation(
summary = "List recorded failures",
description =
"Failures recorded for the caller's team, newest first. Each row carries its"
+ " available actions already resolved.")
public FileRunEventsResponse list(
// Spring's converter 400s on a value outside the enum, so no hand-rolled parse.
@RequestParam(required = false) FileRunEventStatus status,
@RequestParam(required = false) String kindId,
@RequestParam(required = false) Integer limit) {
requireFailureReviewAllowed();
int cappedLimit = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT);
List<FileRunEventView> events =
// The kind filter is part of the query, before the limit is applied: filtering an
// already-limited page could return nothing while matching rows exist.
service.list(status, kindId, cappedLimit).stream()
.map(event -> FileRunEventView.of(event, service.availableActions(event)))
.toList();
return new FileRunEventsResponse(events);
}
@PostMapping("/{eventId}/actions/{actionId}")
@Operation(
summary = "Apply an action to a recorded failure",
description =
"Rejected with 400 if the failure's kind does not declare the action, so an"
+ " action that makes no sense for a given failure cannot be applied.")
public FileRunEventView act(
@PathVariable String eventId,
@PathVariable String actionId,
@RequestBody(required = false) ActionRequest request) {
requireFailureReviewAllowed();
Map<String, String> inputs = request == null ? Map.of() : request.safeInputs();
try {
FileRunEvent updated = service.dispatch(eventId, actionId, inputs);
return FileRunEventView.of(updated, service.availableActions(updated));
} catch (FailureActionException e) {
throw new ResponseStatusException(statusFor(e.getReason()), e.getMessage(), e);
}
}
@GetMapping("/kinds")
@Operation(
summary = "List known failure kinds",
description =
"The failure registry. Lets a client describe kinds it was not built with, and"
+ " doubles as the probe for whether failure tracking exists at all.")
public List<FailureKindView> kinds() {
requireFailureReviewAllowed();
return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList();
}
/**
* Triage is for a team leader (SaaS) or admin (self-hosted), mirroring {@code
* PolicyController.requirePolicyEditingAllowed()} rather than inventing a second notion of who
* manages a team's automation: a member can trigger runs, a leader reviews them.
*
* <p>Login disabled means a single-user deployment with no roles to tell apart, the same
* carve-out the policy endpoints make. Team scoping is separate, and lives in the service.
*/
private void requireFailureReviewAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Recorded failures may only be reviewed by a team leader");
}
}
/**
* A closed row is a conflict rather than a bad request: the request was well-formed and would
* have been valid a moment earlier.
*/
private static HttpStatus statusFor(FailureActionException.Reason reason) {
return switch (reason) {
case EVENT_NOT_FOUND -> HttpStatus.NOT_FOUND;
case ACTION_NOT_RECOGNISED, ACTION_NOT_DECLARED -> HttpStatus.BAD_REQUEST;
case ALREADY_CLOSED -> HttpStatus.CONFLICT;
};
}
/** Wrapped rather than a bare array so pagination can be added without breaking clients. */
public record FileRunEventsResponse(List<FileRunEventView> events) {}
/** Inputs an action declared it needs. Empty for both actions that exist today. */
public record ActionRequest(Map<String, String> inputs) {
Map<String, String> safeInputs() {
return inputs == null ? Map.of() : inputs;
}
}
}
@@ -0,0 +1,116 @@
package stirling.software.proprietary.failure;
import java.io.Serializable;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* One recorded failure incident. {@code actor} and {@code teamId} are plain values rather than
* foreign keys, matching {@link stirling.software.proprietary.policy.source.SourceEntity}, and the
* facets are snapshots at write time so re-classifying a kind never rewrites an old row.
*
* <p>Holds no document name or content: {@code fileId} is opaque, and {@code detail} is stripped of
* names by {@link RecordFailure}.
*/
@Entity
@Table(
name = "file_run_events",
indexes = {
// the review surface: this team's rows, newest first, optionally filtered by status
@Index(name = "idx_file_run_events_team", columnList = "team_id, status, last_seen_at")
},
// One row per incident, enforced by the database rather than only by the store's read-then-
// insert: two runs failing identically at the same moment would otherwise both find no row
// and both insert, leaving a twin that reappears after the first is dismissed. Doubles as
// the
// rollup's lookup index. Note SQL treats NULLs as distinct, so this does not constrain the
// unteamed rows a login-disabled deployment writes.
uniqueConstraints =
@UniqueConstraint(
name = "uk_file_run_events_dedup",
columnNames = {"team_id", "dedup_key"}))
@NoArgsConstructor
@Getter
@Setter
public class FileRunEventEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "id")
private String id;
@Column(name = "team_id")
private Long teamId;
/** The user whose work produced the failure. Null when login is disabled. */
@Column(name = "actor")
private String actor;
@Column(name = "kind_id", nullable = false)
private String kindId;
@Enumerated(EnumType.STRING)
@Column(name = "stage", nullable = false)
private FailureStage stage;
@Enumerated(EnumType.STRING)
@Column(name = "severity", nullable = false)
private FailureSeverity severity;
@Enumerated(EnumType.STRING)
@Column(name = "scope", nullable = false)
private FailureScope scope;
@Enumerated(EnumType.STRING)
@Column(name = "origin", nullable = false)
private FailureOrigin origin;
@Column(name = "policy_id")
private String policyId;
@Column(name = "run_id")
private String runId;
@Column(name = "file_id")
private String fileId;
@Column(name = "detail", columnDefinition = "text")
private String detail;
@Column(name = "dedup_key", length = 64, nullable = false)
private String dedupKey;
/** How many times this same failure has been seen. Starts at 1; the rollup increments it. */
@Column(name = "occurrences", nullable = false)
private int occurrences;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private FileRunEventStatus status;
/** Who last changed {@link #status}. Null while the row is untouched. */
@Column(name = "status_actor")
private String statusActor;
@Column(name = "status_at")
private Instant statusAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "last_seen_at", nullable = false)
private Instant lastSeenAt;
}
@@ -0,0 +1,104 @@
package stirling.software.proprietary.failure;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity, String> {
/**
* This team's events, newest first, scoped in the query rather than loaded and filtered. A
* {@code null} teamId matches the rows with no team (login disabled), mirroring {@link
* stirling.software.proprietary.policy.source.SourceRepository#findByTeam}, since a plain
* {@code = null} would return nothing.
*/
@Query(
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
+ " e.teamId = :teamId) and (:kindId is null or e.kindId = :kindId)"
+ " order by e.lastSeenAt desc")
List<FileRunEventEntity> findByTeam(
@Param("teamId") Long teamId, @Param("kindId") String kindId, Pageable pageable);
/** As {@link #findByTeam} but restricted to one status, for the review surface's filters. */
@Query(
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
+ " e.teamId = :teamId) and e.status = :status"
+ " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc")
List<FileRunEventEntity> findByTeamAndStatus(
@Param("teamId") Long teamId,
@Param("status") FileRunEventStatus status,
@Param("kindId") String kindId,
Pageable pageable);
/**
* Fold a repeat into an incident in one statement, against the row's <em>current</em> values. A
* read-modify-save here would be a merge of a possibly stale snapshot: concurrent folds would
* lose counts, and an in-flight fold could overwrite a reviewer's dismiss. Same idiom as {@code
* SourceDocCountRepository#increment} and friends.
*/
@Modifying(clearAutomatically = true)
@Transactional
@Query(
"update FileRunEventEntity e set e.occurrences = e.occurrences + 1,"
+ " e.lastSeenAt = :now, e.detail = coalesce(:detail, e.detail)"
+ " where e.id = :id")
int fold(@Param("id") String id, @Param("now") Instant now, @Param("detail") String detail);
/**
* Reopen a resolved incident whose failure has recurred. Guarded on the current status so only
* {@code RESOLVED} flips; a concurrent dismiss is never overwritten back to {@code NEW}.
*/
@Modifying(clearAutomatically = true)
@Transactional
@Query(
"update FileRunEventEntity e set"
+ " e.status = stirling.software.proprietary.failure.FileRunEventStatus.NEW,"
+ " e.statusActor = null, e.statusAt = null where e.id = :id and e.status ="
+ " stirling.software.proprietary.failure.FileRunEventStatus.RESOLVED")
int reopenIfResolved(@Param("id") String id);
/**
* Apply a status transition only if the row is still in one of {@code allowedFrom}. The guard
* runs in the database, so two racing closes cannot both succeed: the loser updates zero rows.
*/
@Modifying(clearAutomatically = true)
@Transactional
@Query(
"update FileRunEventEntity e set e.status = :target, e.statusActor = :actor,"
+ " e.statusAt = :now where e.id = :id and ((:teamId is null and e.teamId is"
+ " null) or e.teamId = :teamId) and e.status in :allowedFrom")
int applyStatusIf(
@Param("id") String id,
@Param("teamId") Long teamId,
@Param("target") FileRunEventStatus target,
@Param("actor") String actor,
@Param("now") Instant now,
@Param("allowedFrom") Collection<FileRunEventStatus> allowedFrom);
/**
* The most recent row for this exact failure, so the rollup can increment an existing incident
* instead of opening a new one. Team-scoped, so the same failure in two teams stays two rows.
*/
@Query(
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
+ " e.teamId = :teamId) and e.dedupKey = :dedupKey order by e.lastSeenAt desc")
List<FileRunEventEntity> findByTeamAndDedupKey(
@Param("teamId") Long teamId, @Param("dedupKey") String dedupKey, Pageable pageable);
/** One row by id, but only if it belongs to {@code teamId}. */
@Query(
"select e from FileRunEventEntity e where e.id = :id and ((:teamId is null and e.teamId"
+ " is null) or e.teamId = :teamId)")
Optional<FileRunEventEntity> findByIdAndTeam(
@Param("id") String id, @Param("teamId") Long teamId);
}
@@ -0,0 +1,159 @@
package stirling.software.proprietary.failure;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Reads and acts on incidents for the calling user's team.
*
* <p>Team scoping mirrors {@code PolicyAccessGuard}: everyone sees only their own team's rows, the
* team always comes from the authenticated principal, and scoping applies only when login is
* enabled so single-user deployments keep working. When the team cannot be resolved the caller
* reads nothing; see {@link #scope()}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileRunEventService {
private final FileRunEventStore store;
private final FailureActionRegistry actionRegistry;
private final PolicyManagementAuthority policyManagementAuthority;
private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties;
/** The calling user's events, newest first. Empty when their team cannot be resolved. */
public List<FileRunEvent> list(FileRunEventStatus status, String kindId, int limit) {
TeamScope scope = scope();
if (!scope.permitted()) {
return List.of();
}
return store.list(scope.teamId(), status, kindId, limit);
}
/**
* Dispatch an action against one event.
*
* @throws FailureActionException if the event is not the caller's, the action is unknown, the
* event's kind does not declare the action, or the event is already closed
*/
public FileRunEvent dispatch(String eventId, String actionId, Map<String, String> inputs) {
TeamScope scope = scope();
if (!scope.permitted()) {
// Reported as "no such event", the same as an id from another team, so the response
// does
// not depend on whether the id happens to exist.
throw new FailureActionException(
FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + eventId);
}
FileRunEvent event =
store.find(eventId, scope.teamId())
.orElseThrow(
() ->
new FailureActionException(
FailureActionException.Reason.EVENT_NOT_FOUND,
"No such event: " + eventId));
FailureActionId resolvedId = parseActionId(actionId);
// A kind that does not offer an action cannot have it applied, so an action that makes no
// sense for a failure is unreachable rather than merely unrendered.
if (!event.kind().declares(resolvedId)) {
throw new FailureActionException(
FailureActionException.Reason.ACTION_NOT_DECLARED,
"Kind " + event.kind().getId() + " does not offer action " + resolvedId);
}
if (event.status().terminal()) {
throw new FailureActionException(
FailureActionException.Reason.ALREADY_CLOSED,
"Event " + eventId + " is already " + event.status());
}
FailureAction action =
actionRegistry
.find(resolvedId)
.orElseThrow(
() ->
new FailureActionException(
FailureActionException.Reason.ACTION_NOT_RECOGNISED,
"No handler for action " + resolvedId));
return action.execute(event, inputs == null ? Map.of() : inputs, currentActor());
}
/**
* Which of an event's declared actions are usable right now. Decided per row, so the client
* never renders a button that would be refused.
*/
public List<AvailableAction> availableActions(FileRunEvent event) {
boolean closed = event.status().terminal();
return event.kind().getActions().stream()
.map(
action ->
new AvailableAction(
action,
event.kind().labelKeyFor(action),
!closed,
closed ? "portal.failures.disabled.closed" : null))
.toList();
}
private FailureActionId parseActionId(String actionId) {
for (FailureActionId candidate : FailureActionId.values()) {
if (candidate.name().equals(actionId)) {
return candidate;
}
}
throw new FailureActionException(
FailureActionException.Reason.ACTION_NOT_RECOGNISED, "Unknown action: " + actionId);
}
/**
* Which rows the caller may touch, since a null team id means two different things. Login
* disabled is the self-hosted setup with no users or teams, where unteamed rows are everyone's,
* as {@code PolicyAccessGuard} also treats them. Login enabled with no resolvable team reads
* nothing, because unteamed rows there are shared by every team's ad-hoc runs.
*/
private TeamScope scope() {
if (!enforced()) {
return TeamScope.of(null);
}
Long teamId = policyManagementAuthority.currentUserTeamId();
return teamId == null ? TeamScope.denied() : TeamScope.of(teamId);
}
/**
* The caller's readable team, or a refusal. {@code teamId} is only meaningful when permitted.
*/
private record TeamScope(boolean permitted, Long teamId) {
static TeamScope of(Long teamId) {
return new TeamScope(true, teamId);
}
static TeamScope denied() {
return new TeamScope(false, null);
}
}
private String currentActor() {
return enforced() ? userService.getCurrentUsername() : null;
}
private boolean enforced() {
return applicationProperties.getSecurity().isEnableLogin();
}
/** One action as offered for a specific event, with its resolved availability. */
public record AvailableAction(
FailureActionId id, String labelKey, boolean enabled, String disabledReasonKey) {}
}
@@ -0,0 +1,34 @@
package stirling.software.proprietary.failure;
import java.util.Arrays;
import java.util.List;
/**
* Disposition of one recorded failure. {@code RESOLVED} is declared but not set yet (it becomes
* system-set later); the rollup already defines what a repeat means for it, which is to reopen.
*/
public enum FileRunEventStatus {
NEW(false),
ACKNOWLEDGED(false),
DISMISSED(true),
RESOLVED(true);
/** The statuses a review queue shows by default: everything still needing a decision. */
private static final List<FileRunEventStatus> OPEN =
Arrays.stream(values()).filter(status -> !status.terminal).toList();
/** Whether no further transition is possible: the row is closed. */
private final boolean terminal;
FileRunEventStatus(boolean terminal) {
this.terminal = terminal;
}
public boolean terminal() {
return terminal;
}
public static List<FileRunEventStatus> open() {
return OPEN;
}
}
@@ -0,0 +1,201 @@
package stirling.software.proprietary.failure;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Durable store for recorded incidents. Who may act lives in {@link FileRunEventService}.
*
* <p>{@link #record} folds a repeat into the existing incident rather than inserting again, keyed
* on {@code (teamId, dedupKey)}. That matters for a stateless {@code snapshot} source, which
* re-lists every file on each sweep: the same broken file is one incident, not one per sweep.
* Distinct files keep distinct rows, so a reviewer can still act on any one of them.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileRunEventStore {
/** Only ever need the newest match; the index is ordered so this is a single-row read. */
private static final Pageable NEWEST = PageRequest.of(0, 1);
private final FileRunEventRepository repository;
/**
* Record a failure, folding it into an existing incident when one matches.
*
* <p>Deliberately not {@code @Transactional}: each repository call runs in its own transaction,
* so losing the insert race below leaves no rolled-back transaction to recover from.
*/
public FileRunEvent record(RecordFailure command) {
String dedupKey = command.dedupKey();
Instant now = Instant.now();
Optional<FileRunEvent> folded =
findByDedupKey(command.teamId(), dedupKey)
.flatMap(row -> absorb(row.getId(), command, now));
if (folded.isPresent()) {
return folded.get();
}
try {
return FileRunEvent.of(insert(command, dedupKey, now));
} catch (DataIntegrityViolationException e) {
// The unique constraint fired, so a concurrent writer inserted this incident between
// our read and our insert. Their row is the incident; fold into it instead of failing.
return findByDedupKey(command.teamId(), dedupKey)
.flatMap(row -> absorb(row.getId(), command, now))
.orElseThrow(() -> e);
}
}
private Optional<FileRunEventEntity> findByDedupKey(Long teamId, String dedupKey) {
return repository.findByTeamAndDedupKey(teamId, dedupKey, NEWEST).stream().findFirst();
}
/**
* Fold a repeat into an existing incident. {@code DISMISSED} stays dismissed, so a reviewer's
* "stop showing me this" holds; {@code RESOLVED} reopens, so a recurrence is visible again.
*
* <p>Both steps are single guarded UPDATE statements against the row's current values, never a
* save of the entity we read: that would merge a possibly stale snapshot over concurrent
* writes, losing counts and reverting a dismiss that landed in between.
*
* <p>Empty when the row went away between the caller's read and the fold, so the caller inserts
* instead. Reporting that as an error would lose the incident for a log line.
*/
private Optional<FileRunEvent> absorb(String id, RecordFailure command, Instant now) {
if (repository.fold(id, now, command.detail()) == 0) {
return Optional.empty();
}
repository.reopenIfResolved(id);
return repository.findById(id).map(FileRunEvent::of);
}
private FileRunEventEntity insert(RecordFailure command, String dedupKey, Instant now) {
FailureKind kind = command.kind();
FileRunEventEntity entity = new FileRunEventEntity();
entity.setId(UUID.randomUUID().toString());
entity.setTeamId(command.teamId());
entity.setActor(command.actor());
entity.setKindId(kind.getId());
// Snapshot the facets so a later registry edit does not rewrite what this row meant.
entity.setStage(kind.getStage());
entity.setSeverity(kind.getSeverity());
entity.setScope(kind.getScope());
entity.setOrigin(command.origin());
entity.setPolicyId(command.policyId());
entity.setRunId(command.runId());
entity.setFileId(command.fileId());
entity.setDetail(command.detail());
entity.setDedupKey(dedupKey);
entity.setOccurrences(1);
entity.setStatus(FileRunEventStatus.NEW);
entity.setCreatedAt(now);
entity.setLastSeenAt(now);
// Flushed so a duplicate-key violation surfaces here, inside record()'s catch. A plain
// save() defers the INSERT to commit time once a caller is transactional, and the
// violation would escape the catch as a 500.
return repository.saveAndFlush(entity);
}
/**
* A page of incidents, newest first, optionally narrowed to one status and one kind. The kind
* filter lives in the query, before the limit: filtering a already-limited page could return
* nothing while matching rows exist.
*/
@Transactional(readOnly = true)
public List<FileRunEvent> list(
Long teamId, FileRunEventStatus status, String kindId, int limit) {
Pageable page = PageRequest.of(0, Math.max(1, limit));
List<FileRunEventEntity> rows =
status == null
? repository.findByTeam(teamId, kindId, page)
: repository.findByTeamAndStatus(teamId, status, kindId, page);
return rows.stream().map(FileRunEvent::of).toList();
}
@Transactional(readOnly = true)
public Optional<FileRunEvent> find(String id, Long teamId) {
return repository.findByIdAndTeam(id, teamId).map(FileRunEvent::of);
}
/**
* Transition an open row, refusing a closed one. The service checks {@code terminal()} before
* dispatching, but that check and this write are separate requests under concurrency; the
* guarded UPDATE is what makes two racing closes resolve to one winner.
*
* @throws FailureActionException {@code ALREADY_CLOSED} when the row exists but is terminal,
* {@code EVENT_NOT_FOUND} when it does not exist for this team
*/
@Transactional
public FileRunEvent applyStatus(
String id, Long teamId, FileRunEventStatus target, String actor) {
return applyStatus(id, teamId, target, actor, FileRunEventStatus.open())
.orElseThrow(() -> refusalFor(id, teamId));
}
/**
* As {@link #applyStatus(String, Long, FileRunEventStatus, String)} but with the caller naming
* which current statuses may transition. Empty when the row exists outside {@code allowedFrom}.
*
* <p>The UPDATE runs first and nothing is read beforehand: a pre-read only to classify the
* refusal would be one more thing to race with, and would report a row deleted in between as
* closed rather than missing.
*/
@Transactional
public Optional<FileRunEvent> applyStatus(
String id,
Long teamId,
FileRunEventStatus target,
String actor,
Collection<FileRunEventStatus> allowedFrom) {
if (repository.applyStatusIf(id, teamId, target, actor, Instant.now(), allowedFrom) == 0) {
return Optional.empty();
}
return repository.findByIdAndTeam(id, teamId).map(FileRunEvent::of);
}
/**
* Transition, or accept that someone else already reached {@code target}. The loser of a race
* reads the winner's row back rather than re-stamping it, so the first actor keeps the credit.
*/
@Transactional
public FileRunEvent applyStatusOnce(
String id,
Long teamId,
FileRunEventStatus target,
String actor,
Collection<FileRunEventStatus> allowedFrom) {
return applyStatus(id, teamId, target, actor, allowedFrom)
.or(() -> find(id, teamId).filter(current -> current.status() == target))
.orElseThrow(() -> refusalFor(id, teamId));
}
/**
* Why the guarded UPDATE refused, worked out only once it has. Missing and closed are told
* apart after the fact rather than before, so the answer describes the row the UPDATE saw.
*/
private FailureActionException refusalFor(String id, Long teamId) {
return repository.findByIdAndTeam(id, teamId).isPresent()
? new FailureActionException(
FailureActionException.Reason.ALREADY_CLOSED,
"Event " + id + " is already closed")
// The same reason the service raises for an id it never found, so losing a delete
// race answers 404 like every other "no such event" rather than 400.
: new FailureActionException(
FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + id);
}
}
@@ -0,0 +1,73 @@
package stirling.software.proprietary.failure;
import java.util.List;
/**
* Wire shape of one incident. Carries i18n keys plus {@code defaultTitle} rather than rendered
* copy, so the server can ship a new kind without a client release, and {@code actions} arrive
* already resolved so the client needs no rules. No document name: {@code fileId} is an opaque
* reference.
*/
public record FileRunEventView(
String id,
String kindId,
FailureStage stage,
FailureSeverity severity,
FailureScope scope,
FailureOrigin origin,
FailureRemedy remedy,
String titleKey,
String descriptionKey,
String defaultTitle,
String detail,
String policyId,
String runId,
String fileId,
String actor,
int occurrences,
FileRunEventStatus status,
String statusActor,
List<ActionView> actions,
long createdAt,
long lastSeenAt) {
public static FileRunEventView of(
FileRunEvent event, List<FileRunEventService.AvailableAction> actions) {
FailureKind kind = event.kind();
return new FileRunEventView(
event.id(),
kind.getId(),
event.stage(),
event.severity(),
event.scope(),
event.origin(),
kind.getRemedy(),
kind.getTitleKey(),
kind.getDescriptionKey(),
kind.getDefaultTitle(),
event.detail(),
event.policyId(),
event.runId(),
event.fileId(),
event.actor(),
event.occurrences(),
event.status(),
event.statusActor(),
actions.stream().map(ActionView::of).toList(),
event.createdAt() == null ? 0L : event.createdAt().toEpochMilli(),
event.lastSeenAt() == null ? 0L : event.lastSeenAt().toEpochMilli());
}
/** One button, as offered for this specific row. */
public record ActionView(
String id, String labelKey, boolean enabled, String disabledReasonKey) {
static ActionView of(FileRunEventService.AvailableAction action) {
return new ActionView(
action.id().name(),
action.labelKey(),
action.enabled(),
action.disabledReasonKey());
}
}
}
@@ -0,0 +1,79 @@
package stirling.software.proprietary.failure;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* The seam the policy engine calls when a run fails: classify, then record. Best-effort throughout,
* so failing to write a row cannot change the failure the caller already observed.
*
* <p>The team comes from the originating policy rather than the calling thread, which carries only
* an audit principal. An ad-hoc run has no stored policy, so its rows land unteamed.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PolicyFailureRecorder {
private final FailureClassifier classifier;
private final FileRunEventStore store;
private final PolicyStore policyStore;
/**
* Record a failed run, classifying {@code cause}. {@code detail} is the message the run itself
* reported, kept verbatim so an {@link FailureKind#UNKNOWN} row is still diagnosable.
*/
public void recordRunFailure(
String runId,
String policyId,
String actor,
String fileIdentity,
String detail,
Throwable cause) {
record(classifier.classify(cause), runId, policyId, actor, fileIdentity, detail);
}
/**
* Record a failure whose kind is already decided, for paths with no exception to classify (a
* run rejected at admission). Named distinctly rather than overloading {@link
* #recordRunFailure}, whose argument list is otherwise near-identical, so a null cause cannot
* pick the wrong one.
*/
public void recordRunFailureAs(
FailureKind kind, String runId, String policyId, String actor, String detail) {
record(kind, runId, policyId, actor, null, detail);
}
private void record(
FailureKind kind,
String runId,
String policyId,
String actor,
String fileIdentity,
String detail) {
try {
store.record(
RecordFailure.forRun(
kind, teamFor(policyId), actor, policyId, runId, fileIdentity, detail));
} catch (RuntimeException e) {
// Deliberately swallowed: see the class comment.
log.warn("Could not record failure event for run {} (kind {})", runId, kind.getId(), e);
}
}
private Long teamFor(String policyId) {
if (policyId == null || policyId.isBlank()) {
return null;
}
try {
return policyStore.get(policyId).map(policy -> policy.teamId()).orElse(null);
} catch (RuntimeException e) {
log.debug("Could not resolve team for policy {}: {}", policyId, e.getMessage());
return null;
}
}
}
@@ -0,0 +1,117 @@
package stirling.software.proprietary.failure;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
/**
* Everything needed to record one failure. Every reference field is nullable, because a failure can
* happen before a file or policy is known and with no user at all (a trigger-fired run on a
* login-disabled deployment). Only {@code kind} and {@code origin} are required.
*/
public record RecordFailure(
FailureKind kind,
FailureOrigin origin,
Long teamId,
String actor,
String policyId,
String runId,
String sourceId,
String fileId,
String detail) {
/** Upper bound on a stored message, so one enormous stack trace cannot fill the column. */
private static final int MAX_DETAIL_LENGTH = 2_000;
public RecordFailure {
if (kind == null) {
throw new IllegalArgumentException("kind is required");
}
if (origin == null) {
throw new IllegalArgumentException("origin is required");
}
// Capped here rather than at each call site, since this record is the only way a row is
// written, and an unclassified failure carries a raw message of unbounded length. Stored
// verbatim otherwise: it is the user's own error about their own file, and hiding parts of
// it makes the row harder to act on without making it meaningfully safer.
detail = truncate(detail);
}
/** A processor-side failure with no file or source context, e.g. a run that failed outright. */
public static RecordFailure forRun(
FailureKind kind,
Long teamId,
String actor,
String policyId,
String runId,
String fileId,
String detail) {
return new RecordFailure(
kind, FailureOrigin.POLICY, teamId, actor, policyId, runId, null, fileId, detail);
}
/**
* What this failure is about, per the row's scope. Two failures sharing a kind and a scope
* reference are the same incident; see {@link #dedupKey()}.
*/
public String scopeRef() {
return switch (kind.getScope()) {
case FILE -> nullToEmpty(policyId) + "|" + fileOrRun();
case RUN -> nullToEmpty(runId);
case POLICY -> nullToEmpty(policyId);
case SOURCE -> nullToEmpty(sourceId);
// One server-wide condition is one incident regardless of which run tripped over it.
case SERVER -> "";
};
}
/**
* The file this failure is about, or the run when the producer could not name one. Without the
* fallback, every file failing the same way under one policy shares a scope reference, and the
* second onwards folds into the first as though one document had failed repeatedly.
*
* <p>Prefixed so a file id and a run id cannot collide.
*/
private String fileOrRun() {
return isBlank(fileId) ? "run:" + nullToEmpty(runId) : "file:" + fileId;
}
/**
* Decides whether this failure repeats an existing incident: SHA-256 of {@code
* kindId|scope|scopeRef}. Hashed so a long scope reference (an S3 key) fits a fixed-width
* index, and deterministic so two nodes converge on one incident. Every input is an enum name
* or an id.
*/
public String dedupKey() {
String raw = kind.getId() + "|" + kind.getScope().name() + "|" + scopeRef();
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(raw.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
// SHA-256 is mandated by the JDK; unreachable outside a broken runtime.
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
private static String truncate(String detail) {
if (detail == null || detail.length() <= MAX_DETAIL_LENGTH) {
return detail;
}
// Leave room for the ellipsis so the cap is the cap, and step back once more rather than
// cutting between the halves of a surrogate pair, which would store invalid UTF-16.
int end = MAX_DETAIL_LENGTH - 1;
if (Character.isHighSurrogate(detail.charAt(end - 1))) {
end--;
}
return detail.substring(0, end) + "";
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
}
@@ -32,6 +32,8 @@ import stirling.software.common.service.ResourceMonitor;
import stirling.software.common.service.TaskManager;
import stirling.software.common.util.ExecutorFactory;
import stirling.software.common.util.JobContext;
import stirling.software.proprietary.failure.FailureKind;
import stirling.software.proprietary.failure.PolicyFailureRecorder;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.Policy;
@@ -72,6 +74,8 @@ public class PolicyEngine {
private final PolicyExecutor stepExecutor;
private final TaskManager taskManager;
private final PolicyRunRegistry registry;
// Durable record of why a run failed. Best-effort by contract: see PolicyFailureRecorder.
private final PolicyFailureRecorder failureRecorder;
private final FileStorage fileStorage;
private final JobOwnershipService jobOwnershipService;
private final List<PolicyOutputSink> outputSinks;
@@ -123,12 +127,26 @@ public class PolicyEngine {
// async
// worker.
String principal = currentActingPrincipal();
return submitForPrincipal(principal, principal, policyId, definition, inputs, listener);
return submitForPrincipal(
principal, principal, policyId, definition, inputs, null, listener);
}
/** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */
public PolicyRunHandle runPolicy(
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
return runPolicy(policy, inputs, null, listener);
}
/**
* As {@link #runPolicy(Policy, PolicyInputs, PolicyProgressListener)}, with the source's opaque
* reference to the document being run. Carried so a failure can say which document it was
* about, and so the same document failing again folds into one incident.
*/
public PolicyRunHandle runPolicy(
Policy policy,
PolicyInputs inputs,
String fileIdentity,
PolicyProgressListener listener) {
// Bill the policy owner: trigger-fired runs have no security context, and the async worker
// doesn't inherit the caller's, so the owner (stamped at policy creation) is the reliable
// billing identity — and for org-wide policies the org/owner is meant to pay. But own the
@@ -145,7 +163,7 @@ public class PolicyEngine {
new PipelineDefinition(
policy.name(), policy.steps(), outputResolver.resolve(policy));
return submitForPrincipal(
policy.owner(), fileOwner, policy.id(), definition, inputs, listener);
policy.owner(), fileOwner, policy.id(), definition, inputs, fileIdentity, listener);
}
private PolicyRunHandle submitForPrincipal(
@@ -154,6 +172,7 @@ public class PolicyEngine {
String policyId,
PipelineDefinition definition,
PolicyInputs inputs,
String fileIdentity,
PolicyProgressListener listener) {
// Scope the run id to the current user (this request thread) so the file-download
// ownership check passes. No-op when security is off.
@@ -163,7 +182,7 @@ public class PolicyEngine {
if (policyId != null) {
taskManager.putMetadata(runId, "policyId", policyId);
}
PolicyRun run = new PolicyRun(runId, policyId, definition);
PolicyRun run = new PolicyRun(runId, policyId, definition, fileIdentity);
registry.register(run);
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
PolicyProgressListener tracking = trackingListener(runId, run, listener);
@@ -271,6 +290,7 @@ public class PolicyEngine {
e.getMessage());
run.fail(message);
taskManager.setError(runId, message);
recordFailure(run, message, e);
} catch (RestClientResponseException e) {
// A downstream tool call returned an error status. When it's a structured
// entitlement
@@ -294,17 +314,20 @@ public class PolicyEngine {
run.failWithCode(
message, code, DownstreamEntitlementError.extractSubscribed(e));
taskManager.setError(runId, message);
recordFailure(run, message, e);
} else {
String message = "Policy run failed: " + e.getMessage();
log.error("Policy run {} failed (downstream HTTP error)", runId, e);
run.fail(message);
taskManager.setError(runId, message);
recordFailure(run, message, e);
}
} catch (Exception e) {
String message = "Policy run failed: " + e.getMessage();
log.error("Policy run {} failed", runId, e);
run.fail(message);
taskManager.setError(runId, message);
recordFailure(run, message, e);
} finally {
// Always resolve so stream/await callers unblock.
completion.complete(run);
@@ -322,11 +345,29 @@ public class PolicyEngine {
// Transient admission rejection, not a processing failure (see QUEUE_FULL_CODE).
run.failWithCode(message, QUEUE_FULL_CODE, null);
taskManager.setError(run.getRunId(), message);
// No exception to classify here: nothing was thrown by a tool, the run simply was not
// admitted. Record it explicitly so a run lost to load pressure is still accounted for.
failureRecorder.recordRunFailureAs(
FailureKind.UNKNOWN, run.getRunId(), run.getPolicyId(), null, message);
completion.complete(run);
}
return null;
}
/**
* Record why a run failed. Called after the run's own state transition and task-manager update,
* so a recording problem cannot change the outcome the caller observes.
*/
private void recordFailure(PolicyRun run, String message, Throwable cause) {
failureRecorder.recordRunFailure(
run.getRunId(),
run.getPolicyId(),
MDC.get(AUDIT_PRINCIPAL_MDC_KEY),
run.getFileIdentity(),
message,
cause);
}
private WaitState suspend(PolicyInputRequiredException e) {
List<String> fileIds = new ArrayList<>();
for (Resource resource : e.getPendingFiles()) {
@@ -281,18 +281,33 @@ public class PolicyExecutor {
}
for (Resource file : files) {
if (!matchesType(file, accepted)) {
// Reports the extension rather than the filename, since this message becomes the
// run's error and is persisted on the failure record.
throw new IOException(
"Step "
+ operation
+ " accepts "
+ accepted
+ " but received '"
+ file.getFilename()
+ "'");
+ " but received a '"
+ extensionOf(file)
+ "' file");
}
}
}
/** The file's extension, or {@code unknown} when it has no usable name. */
private static String extensionOf(Resource file) {
String filename = file.getFilename();
if (filename == null) {
return "unknown";
}
int dot = filename.lastIndexOf('.');
if (dot < 0 || dot == filename.length() - 1) {
return "unknown";
}
return filename.substring(dot + 1).toLowerCase(Locale.ROOT);
}
private static boolean matchesType(Resource file, List<String> acceptedExtensions) {
String filename = file.getFilename();
if (filename == null) {
@@ -79,7 +79,7 @@ public class PolicyRunner {
// Generator pipeline: one run with no input. Still fall through to the cleanup
// below so rows recorded for its folder outputs are pruned like anything else,
// instead of accumulating until the policy is deleted.
runIds.add(startRun(policy, PolicyInputs.of(List.of()), unused -> {}));
runIds.add(startRun(policy, PolicyInputs.of(List.of()), null, unused -> {}));
}
for (PipelineInput input : inputs) {
String sourceId = input.sourceId();
@@ -167,17 +167,18 @@ public class PolicyRunner {
List<String> runIds = new ArrayList<>();
long docsFed = 0;
for (ResolvedInput unit : work) {
runIds.add(startRun(policy, unit.inputs(), unit.onComplete()));
runIds.add(startRun(policy, unit.inputs(), unit.fileIdentity(), unit.onComplete()));
docsFed += unit.inputs().primary().size();
}
docCounter.record(sourceId, docsFed);
return runIds;
}
private String startRun(Policy policy, PolicyInputs inputs, Consumer<Boolean> onComplete) {
private String startRun(
Policy policy, PolicyInputs inputs, String fileIdentity, Consumer<Boolean> onComplete) {
log.info("Running policy {} ({})", policy.id(), policy.name());
PolicyRunHandle handle =
policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP);
policyEngine.runPolicy(policy, inputs, fileIdentity, PolicyProgressListener.NOOP);
handle.completion()
.whenComplete((run, throwable) -> onComplete.accept(succeeded(run, throwable)));
return handle.runId();
@@ -118,8 +118,9 @@ public class FolderInputSource implements InputSource {
continue;
}
work.add(
new ResolvedInput(
ResolvedInput.forFile(
PolicyInputs.of(List.of(fileResource(file))),
identity,
success ->
completeConsumed(
ctx, identity, file, gate, contentHash, success)));
@@ -2,21 +2,40 @@ package stirling.software.proprietary.policy.input;
import java.util.function.Consumer;
import stirling.software.proprietary.policy.ledger.IdentityHasher;
import stirling.software.proprietary.policy.model.PolicyInputs;
/**
* One unit of work from an {@link InputSource}: the files to run plus a completion callback invoked
* with the run's success (e.g. a folder source routes the input to done/error). A source may return
* several of these, one per file.
*
* @param fileIdentity stable opaque reference to the document, or null when the source has none.
* Always hashed, never the source's own identity: a folder identity is a path, and a path is a
* filename. See {@link #forFile}.
*/
public record ResolvedInput(PolicyInputs inputs, Consumer<Boolean> onComplete) {
public record ResolvedInput(
PolicyInputs inputs, String fileIdentity, Consumer<Boolean> onComplete) {
public ResolvedInput {
onComplete = onComplete == null ? success -> {} : onComplete;
}
/** No completion side effect. */
/**
* One document, referenced by the hash of the source's identity for it. Stable across sweeps,
* which is what lets the same broken file fold into one incident instead of opening a fresh one
* every time the source re-lists it.
*/
public static ResolvedInput forFile(
PolicyInputs inputs, String identity, Consumer<Boolean> onComplete) {
return new ResolvedInput(
inputs,
identity == null ? null : IdentityHasher.identityHash(identity),
onComplete);
}
/** No document reference and no completion side effect. */
public static ResolvedInput of(PolicyInputs inputs) {
return new ResolvedInput(inputs, success -> {});
return new ResolvedInput(inputs, null, success -> {});
}
}
@@ -119,8 +119,9 @@ public class S3InputSource implements InputSource {
continue;
}
work.add(
new ResolvedInput(
ResolvedInput.forFile(
PolicyInputs.of(List.of(objectResource(client, config, object))),
identity,
success ->
completeConsumed(
ctx,
@@ -100,8 +100,9 @@ public class WebhookInputSource implements InputSource {
continue;
}
work.add(
new ResolvedInput(
ResolvedInput.forFile(
PolicyInputs.of(List.of(fileResource(file))),
identity,
success -> completeConsumed(ctx, identity, file, gate, success)));
}
return work;
@@ -22,6 +22,13 @@ public class PolicyRun {
private final String policyId;
private final PipelineDefinition definition;
/**
* The source's opaque reference to the document this run is about; null for an ad-hoc run or a
* source that names no document. Hashed upstream, so never a path or a filename.
*/
private final String fileIdentity;
private final Instant createdAt = Instant.now();
private volatile PolicyRunStatus status = PolicyRunStatus.PENDING;
@@ -49,10 +56,12 @@ public class PolicyRun {
private volatile List<ResultFile> outputs = List.of();
private volatile Instant updatedAt = Instant.now();
public PolicyRun(String runId, String policyId, PipelineDefinition definition) {
public PolicyRun(
String runId, String policyId, PipelineDefinition definition, String fileIdentity) {
this.runId = runId;
this.policyId = policyId;
this.definition = definition;
this.fileIdentity = fileIdentity;
}
public int stepCount() {
@@ -103,8 +103,9 @@ public class NetworkInputSource implements InputSource {
continue;
}
work.add(
new ResolvedInput(
ResolvedInput.forFile(
PolicyInputs.of(List.of(resource(config, file))),
identity,
success ->
completeConsumed(ctx, config, file, identity, gate, success)));
}
@@ -34,6 +34,7 @@ import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.ObjectLockMode;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
import software.amazon.awssdk.services.s3.model.S3Exception;
@@ -176,6 +177,7 @@ public class S3OutputSink implements PolicyOutputSink {
if (conditionalPuts) {
put.ifNoneMatch("*");
}
applyObjectLock(put, config);
try {
PutObjectResponse response =
client.putObject(put.build(), RequestBody.fromFile(staged));
@@ -267,4 +269,24 @@ public class S3OutputSink implements PolicyOutputSink {
throw new IllegalStateException("MD5 unavailable", e);
}
}
/**
* Write the object under Object Lock retention when the connection asks for it.
*
* <p>The retain-until date is computed per object from "now", so a policy that runs daily gives
* each document its own full retention window rather than a shared deadline.
*
* <p>Requires the bucket to have Object Lock enabled; S3 rejects the PUT otherwise, which is
* the correct outcome - silently storing a deletable object while an operator believes it is
* locked would be worse than failing.
*/
private static void applyObjectLock(PutObjectRequest.Builder put, S3Config config) {
if (config.objectLockMode() == null || config.retentionDays() == null) {
return;
}
put.objectLockMode(ObjectLockMode.fromValue(config.objectLockMode()))
.objectLockRetainUntilDate(
java.time.Instant.now()
.plus(config.retentionDays(), java.time.temporal.ChronoUnit.DAYS));
}
}
@@ -19,7 +19,9 @@ public record S3Config(
String endpoint,
String accessKeyId,
String secretAccessKey,
boolean snapshot) {
boolean snapshot,
String objectLockMode,
Integer retentionDays) {
private static final String BUCKET_OPTION = "bucket";
private static final String REGION_OPTION = "region";
@@ -28,6 +30,12 @@ public record S3Config(
private static final String ACCESS_KEY_ID_OPTION = "accessKeyId";
private static final String SECRET_ACCESS_KEY_OPTION = "secretAccessKey";
private static final String MODE_OPTION = "mode";
private static final String OBJECT_LOCK_MODE_OPTION = "objectLockMode";
private static final String RETENTION_DAYS_OPTION = "retentionDays";
private static final String LOCK_GOVERNANCE = "GOVERNANCE";
private static final String LOCK_COMPLIANCE = "COMPLIANCE";
private static final int MAX_RETENTION_DAYS = 36525;
private static final String MODE_CONSUME = "consume";
private static final String MODE_SNAPSHOT = "snapshot";
@@ -52,6 +60,41 @@ public record S3Config(
if (mode != null && !MODE_CONSUME.equals(mode) && !MODE_SNAPSHOT.equals(mode)) {
throw new IllegalArgumentException("s3 config 'mode' must be 'consume' or 'snapshot'");
}
// Object Lock: write-once retention, for records that must survive an administrator.
// COMPLIANCE cannot be shortened or deleted by anyone (not even the account root) before
// the retain-until date; GOVERNANCE can be bypassed with a specific IAM permission, so
// only COMPLIANCE is the answer to SEC 17a-4(f) / FINRA. The bucket must already have
// Object Lock enabled - it cannot be turned on per-object - and that in turn requires
// versioning, which can then never be suspended.
String objectLockMode = trimmed(options.get(OBJECT_LOCK_MODE_OPTION));
if (objectLockMode != null) {
objectLockMode = objectLockMode.toUpperCase(java.util.Locale.ROOT);
if (!LOCK_GOVERNANCE.equals(objectLockMode)
&& !LOCK_COMPLIANCE.equals(objectLockMode)) {
throw new IllegalArgumentException(
"s3 config 'objectLockMode' must be 'GOVERNANCE' or 'COMPLIANCE'");
}
}
Integer retentionDays = null;
Object rawRetention = options.get(RETENTION_DAYS_OPTION);
if (rawRetention != null && !rawRetention.toString().isBlank()) {
try {
retentionDays = Integer.valueOf(rawRetention.toString().trim());
} catch (NumberFormatException e) {
throw new IllegalArgumentException("s3 config 'retentionDays' must be a number");
}
if (retentionDays < 1 || retentionDays > MAX_RETENTION_DAYS) {
throw new IllegalArgumentException(
"s3 config 'retentionDays' must be between 1 and " + MAX_RETENTION_DAYS);
}
}
// S3 rejects one without the other, so catch it here where the operator can still fix it
// rather than at upload time on a worker thread.
if ((objectLockMode == null) != (retentionDays == null)) {
throw new IllegalArgumentException(
"s3 config 'objectLockMode' and 'retentionDays' must be set together");
}
return new S3Config(
bucket,
region == null ? "us-east-1" : region,
@@ -59,7 +102,9 @@ public record S3Config(
endpoint,
accessKeyId,
secretAccessKey,
MODE_SNAPSHOT.equals(mode));
MODE_SNAPSHOT.equals(mode),
objectLockMode,
retentionDays);
}
private static String validEndpoint(String endpoint) {
@@ -38,7 +38,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.ledger",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.repository",
"stirling.software.proprietary.integration.repository"
"stirling.software.proprietary.integration.repository",
"stirling.software.proprietary.failure"
})
@EntityScan({
"stirling.software.proprietary.security.model",
@@ -51,7 +52,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.ledger",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.model",
"stirling.software.proprietary.integration.model"
"stirling.software.proprietary.integration.model",
"stirling.software.proprietary.failure"
})
public class DatabaseConfig {
@@ -21,10 +21,14 @@ import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.cluster.s3.S3Clients;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
import stirling.software.proprietary.service.AuditService;
import stirling.software.proprietary.storage.crypto.AuditingStorageEncryptionListener;
import stirling.software.proprietary.storage.crypto.EncryptingStorageProvider;
import stirling.software.proprietary.storage.crypto.FileEncryptionKeyService;
import stirling.software.proprietary.storage.crypto.FileEncryptionMasterKey;
import stirling.software.proprietary.storage.crypto.StorageEncryptionAuditListener;
import stirling.software.proprietary.storage.crypto.StorageEncryptionState;
import stirling.software.proprietary.storage.provider.DatabaseStorageProvider;
import stirling.software.proprietary.storage.provider.LocalStorageProvider;
@@ -42,23 +46,33 @@ public class StorageProviderConfig {
private final StoredFileBlobRepository storedFileBlobRepository;
private final FileEncryptionKeyRepository fileEncryptionKeyRepository;
private final LicenseKeyChecker licenseKeyChecker;
private final AuditService auditService;
/**
* The encryption state behind the always-installed decorator. Key machinery is created eagerly
* when the write flag is on (licence-gated) or key rows already exist — so a wrong master key
* fails startup, not the first download — and lazily if encrypted content shows up later
* (config drift on one cluster node must fail loudly, never stream ciphertext). Turning the
* flag off or losing the licence only stops encrypting new writes; decryption stays available.
* The encryption state behind the always-installed decorator, shared with the admin API and
* migration job. Key machinery is created eagerly when the write flag is on (licence-gated) or
* key rows already exist — so a wrong master key fails startup, not the first download — and
* lazily if encrypted content shows up later (config drift on one cluster node must fail
* loudly, never stream ciphertext). Turning the flag off or losing the licence only stops
* encrypting new writes; decryption stays available.
*/
@Bean
public StorageEncryptionState storageEncryptionState(
@Value("${stirling.security.fileEncryptionKey:}") String configuredFileEncryptionKey,
@Value("${stirling.security.fileEncryptionKeyPrevious:}")
String previousFileEncryptionKey,
@Value("${stirling.security.fileEncryptionKeyVersion:1}") int fileEncryptionKeyVersion,
@Value("${cluster.enabled:false}") boolean clusterEnabled,
PlatformTransactionManager transactionManager) {
boolean writeEnabled = applicationProperties.getStorage().getEncryption().isEnabled();
if (writeEnabled) {
licenseKeyChecker.requireProOrEnterprise("storage.encryption");
warnIfAuditUnavailable();
}
StorageEncryptionAuditListener listener =
new AuditingStorageEncryptionListener(
auditService,
applicationProperties.getStorage().getEncryption().isAuditReads());
// Key creation must commit independently of any caller transaction (see
// FileEncryptionKeyService#createActive).
TransactionTemplate requiresNew = new TransactionTemplate(transactionManager);
@@ -68,8 +82,14 @@ public class StorageProviderConfig {
writeEnabled,
() ->
createKeyService(
configuredFileEncryptionKey, clusterEnabled, requiresNew),
fileEncryptionKeyRepository);
configuredFileEncryptionKey,
previousFileEncryptionKey,
fileEncryptionKeyVersion,
clusterEnabled,
listener,
requiresNew),
fileEncryptionKeyRepository,
listener);
// The registry table may not exist when storage is unused, so only probe if it is on.
boolean probeForExistingKeys =
!writeEnabled && applicationProperties.getStorage().isEnabled();
@@ -82,12 +102,33 @@ public class StorageProviderConfig {
return state;
}
/**
* Encryption at rest is available on Pro, but {@code AuditService} only records events on an
* Enterprise licence. Without this warning a Pro operator would enable encryption, be told it
* is audited, and silently get no encrypt/decrypt/revocation trail at all.
*/
private void warnIfAuditUnavailable() {
if (licenseKeyChecker.getPremiumLicenseEnabledResult() != License.ENTERPRISE) {
log.warn(
"Storage encryption at rest is enabled, but audit events require an Enterprise"
+ " licence: encrypt/decrypt, revocation and plaintext-export events"
+ " will NOT be recorded on this licence tier. Encryption itself is"
+ " unaffected. See devGuide/STORAGE_ENCRYPTION_AT_REST.md");
}
}
private FileEncryptionKeyService createKeyService(
String configuredKey, boolean clusterEnabled, TransactionOperations keyCreationTx) {
String configuredKey,
String previousKey,
int keyVersion,
boolean clusterEnabled,
StorageEncryptionAuditListener listener,
TransactionOperations keyCreationTx) {
FileEncryptionMasterKey masterKey =
new FileEncryptionMasterKey(configuredKey, clusterEnabled);
new FileEncryptionMasterKey(configuredKey, previousKey, keyVersion, clusterEnabled);
FileEncryptionKeyService keyService =
new FileEncryptionKeyService(fileEncryptionKeyRepository, masterKey, keyCreationTx);
new FileEncryptionKeyService(
fileEncryptionKeyRepository, masterKey, listener, keyCreationTx);
// Wrong key must fail fast, not silently start a second key hierarchy.
keyService.verifyMasterKey();
return keyService;
@@ -5,6 +5,7 @@ import java.net.URI;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.ContentDisposition;
@@ -31,7 +32,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.service.AuditService;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.model.api.CreateShareLinkRequest;
@@ -56,6 +59,7 @@ public class FileStorageController {
private final FileStorageService fileStorageService;
private final StorageProvider storageProvider;
private final AuditService auditService;
@PostMapping(
value = "/files",
@@ -262,6 +266,21 @@ public class FileStorageController {
private ResponseEntity<org.springframework.core.io.Resource> buildFileResponse(
StoredFile file, boolean inline) {
org.springframework.core.io.Resource resource = fileStorageService.loadFile(file);
if (file.getEncryptionKeyId() != null) {
// Compliance marker: a plaintext copy of encrypted-at-rest content left the platform
// (inline=true is an in-app view; false is a saved download).
auditService.audit(
AuditEventType.STORAGE_ENCRYPTION,
Map.of(
"action",
"plaintextExport",
"fileId",
file.getId(),
"inline",
inline,
"keyId",
file.getEncryptionKeyId()));
}
String contentType =
file.getContentType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE

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