Compare commits

..
Author SHA1 Message Date
James Brunton a243056c3e Merge branch 'main' into processor-restructure 2026-08-07 09:36:43 +01:00
James Brunton aa6ac5c7a3 (scripted) Restructure frontend so processor and editor are more distinct in the tree 2026-08-06 17:18:41 +01:00
James Brunton 71ef9dcde8 (scripted) Update paths for new frontend structure 2026-08-06 17:17:54 +01:00
James Brunton b9d3a3128b Fix Storybook Vite interaction 2026-08-06 17:17:20 +01:00
James Brunton 0bf35a98c0 Fix hidden circular dependency in watched folders code 2026-08-06 17:17:20 +01:00
James Brunton 10d9127d29 Drop me (Setup scripts) 2026-08-06 17:17:20 +01:00
Ludy fa2eb67124 deps(frontend): align dependency scopes and remove redundant ESLint packages (#6991)
# Description of Changes

This change reorganizes frontend dependencies by moving development-only
packages into `devDependencies`, removing obsolete packages, and
updating several development tooling dependencies to newer versions.

### What was changed

- Moved runtime-independent packages to `devDependencies`:
  - `@iconify/react`
  - `globals`
- Removed unused TypeScript ESLint packages:
  - `@typescript-eslint/eslint-plugin`
  - `@typescript-eslint/parser`
- Updated development dependencies:
  - `@iconify-json/material-symbols` → `1.2.83`
  - `@iconify/utils` → `3.1.4`
  - `globals` → `17.7.0`

These changes reduce redundant dependency declarations and ensure
packages are classified according to their actual usage. The main
challenge was distinguishing direct dependencies from packages already
provided transitively by frontend tooling.


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-06 14:56:12 +00:00
Ludy 86d4a34476 deps: Upgrade posthog-js to 1.405.2 (#7115)
# Description of Changes

- Updated the frontend `posthog-js` dependency from `^1.268.0` to
`^1.405.2`.
- Refreshed `frontend/package-lock.json` and updated related PostHog
transitive dependencies.
- Removed obsolete OpenTelemetry and protobuf-related transitive
packages no longer required by the newer PostHog version.
- The existing PostHog APIs used by Stirling-PDF remain compatible.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-06 13:54:44 +00:00
ConnorYoh 74c53001cf fix(saas): give auth-bootstrap data fetching a single owner (#7194)
## The problem

Three pieces of user data (pro status, avatar metadata, profile picture)
were being fetched from four different places: `initializeAuth` on
mount, the `SIGNED_IN` handler, the `TOKEN_REFRESHED` handler, and the
post-upgrade path.

On a fresh login the first two both see a session, so everything got
fetched twice. It didn't stop after login either — Supabase re-fires
`SIGNED_IN` on token refresh and tab-visibility wakeups, so a refresh
that emitted both events cost around 7 Supabase reads.

## The fix

All four call sites now go through one `loadUserData(session)` that is
idempotent per identity.

The guard key is `user.id` + `is_anonymous`:

- not the access token, which changes on every refresh and would defeat
the guard entirely
- the anonymous flag matters because a guest to authenticated upgrade
keeps the same user id, and that is the one case where the data
genuinely does need reloading

**Per login: 6 fetches to 3. A repeat `SIGNED_IN` or `TOKEN_REFRESHED`
fetches nothing.** The tests count real calls rather than asserting on
shape.

## Two behaviour changes worth naming

- `initializeAuth` now awaits the full load, so the initial spinner also
waits on the profile-picture URL. Net login is still faster, since an
entire duplicate pass is gone.
- A tab-wake `SIGNED_IN` no longer revalidates entitlements. That
revalidation was accidental rather than designed — `refreshProStatus()`
is the intended path, and post-checkout is already handled by
`CheckoutContext`.

## Scope

Supabase-origin traffic only. This does not touch the ~20 authenticated
requests hitting `SupabaseAuthenticationFilter`, because those go to the
Stirling backend rather than the hosted Supabase project. That is a
separate problem and is unmeasured, so it needs measuring before
anything is optimised. Remaining items (a double `/api/v1/team/my`
fetch, an effect keyed on `[user]` identity in `FolderContext`, the
`portalAccess` spinner flash, and caching the auth filter's per-request
Postgres round-trips) are tracked separately.

## Verification

```
npx tsc --noEmit --project editor/src/saas/tsconfig.json   # exit 0
npx eslint --max-warnings=0 editor/src/saas/auth            # exit 0
npx prettier --check editor/src/saas/auth/                  # clean
npx vitest run --project saas                               # 75 passed (20 files)
```
2026-08-06 13:05:24 +00:00
dependabot[bot] ad8830b645 build(deps): bump com.sun.xml.bind:jaxb-core from 4.0.7 to 4.0.9 (#7270)
Bumps com.sun.xml.bind:jaxb-core from 4.0.7 to 4.0.9.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=com.sun.xml.bind:jaxb-core&package-manager=gradle&previous-version=4.0.7&new-version=4.0.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 12:20:38 +00:00
dependabot[bot] de93a1b5e2 build(deps): bump org.sonarqube from 7.2.3.7755 to 7.3.1.8318 (#7271)
Bumps org.sonarqube from 7.2.3.7755 to 7.3.1.8318.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.sonarqube&package-manager=gradle&previous-version=7.2.3.7755&new-version=7.3.1.8318)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 12:20:33 +00:00
dependabot[bot] 8476d3cdec build(deps-dev): bump eslint from 10.1.0 to 10.8.0 in /frontend in the eslint group across 1 directory (#7274)
Bumps the eslint group with 1 update in the /frontend directory:
[eslint](https://github.com/eslint/eslint).

Updates `eslint` from 10.1.0 to 10.8.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/eslint/eslint/releases">eslint's
releases</a>.</em></p>
<blockquote>
<h2>v10.8.0</h2>
<h2>Features</h2>
<ul>
<li><a
href="https://github.com/eslint/eslint/commit/2fee9bb7416116cbed4d8c8100b1ae713b6356a1"><code>2fee9bb</code></a>
feat: export <code>ConfigObject</code> from <code>eslint/config</code>
(<a
href="https://redirect.github.com/eslint/eslint/issues/21082">#21082</a>)
(sethamus)</li>
</ul>
<h2>Bug Fixes</h2>
<ul>
<li><a
href="https://github.com/eslint/eslint/commit/6b8d2f7589b8a7c8b91b8ca2a2ef6d46178760d8"><code>6b8d2f7</code></a>
fix: escape reserved characters in rule id in <code>html</code>
formatter (<a
href="https://redirect.github.com/eslint/eslint/issues/21129">#21129</a>)
(Francesco Trotta)</li>
<li><a
href="https://github.com/eslint/eslint/commit/90910715011211a20d011d807d398a7005127f35"><code>9091071</code></a>
fix: prevent <code>no-unreachable-loop</code> crash when all loop types
are ignored (<a
href="https://redirect.github.com/eslint/eslint/issues/21116">#21116</a>)
(Pixel)</li>
<li><a
href="https://github.com/eslint/eslint/commit/e23fafe8d4b15355adef6cdebef414c3a2019454"><code>e23fafe</code></a>
fix: prefer-object-spread add semicolon when adding parenthesis (<a
href="https://redirect.github.com/eslint/eslint/issues/21081">#21081</a>)
(synthex-byte)</li>
<li><a
href="https://github.com/eslint/eslint/commit/20b5ad052360a443786a202e94624a3f81846511"><code>20b5ad0</code></a>
fix: quadratic-time regex in <code>prefer-template</code> (<a
href="https://redirect.github.com/eslint/eslint/issues/21096">#21096</a>)
(Milos Djermanovic)</li>
<li><a
href="https://github.com/eslint/eslint/commit/8b6f6c0b33411f34485512456d94f221daf7321f"><code>8b6f6c0</code></a>
fix: apply ignore configs to computed methods in class-methods-use-this
(<a
href="https://redirect.github.com/eslint/eslint/issues/21094">#21094</a>)
(Pixel)</li>
<li><a
href="https://github.com/eslint/eslint/commit/b2c608c014a396800a24a89343265d0616bee2d8"><code>b2c608c</code></a>
fix: NewExpression with parenthesized callee in
<code>preserve-caught-error</code> (<a
href="https://redirect.github.com/eslint/eslint/issues/21083">#21083</a>)
(Francesco Trotta)</li>
</ul>
<h2>Documentation</h2>
<ul>
<li><a
href="https://github.com/eslint/eslint/commit/6ddf8587e5cfeeb7328332601bb76d98b8a198ea"><code>6ddf858</code></a>
docs: fix broken Specify Parser Options anchor link (<a
href="https://redirect.github.com/eslint/eslint/issues/21106">#21106</a>)
(Minsu)</li>
<li><a
href="https://github.com/eslint/eslint/commit/784dfbe98f0222426fd200153a5ce2e5624151f0"><code>784dfbe</code></a>
docs: Clarify <code>no-eq-null</code> description (<a
href="https://redirect.github.com/eslint/eslint/issues/21120">#21120</a>)
(Park Harin)</li>
<li><a
href="https://github.com/eslint/eslint/commit/7ec733a3ba5acb053a73ac656030a3879aa48fda"><code>7ec733a</code></a>
docs: Fix typos and grammar in glossary (<a
href="https://redirect.github.com/eslint/eslint/issues/21095">#21095</a>)
(Marry (Subin Yang))</li>
<li><a
href="https://github.com/eslint/eslint/commit/92bb13f13065db72d62454b9830d3ae52db15c76"><code>92bb13f</code></a>
docs: replace quake link (<a
href="https://redirect.github.com/eslint/eslint/issues/21108">#21108</a>)
(Jung Hyeon Jun)</li>
<li><a
href="https://github.com/eslint/eslint/commit/68eb4a57572409dbdcfeb3bc5c92ddbba46bc770"><code>68eb4a5</code></a>
docs: fix broken Specify Globals anchor links in rule pages (<a
href="https://redirect.github.com/eslint/eslint/issues/21103">#21103</a>)
(Minsu)</li>
<li><a
href="https://github.com/eslint/eslint/commit/d28f697e0d32d7446c825fd89c362db028450134"><code>d28f697</code></a>
docs: replace Code Climate CLI links with Qlty CLI links (<a
href="https://redirect.github.com/eslint/eslint/issues/21099">#21099</a>)
(Jung Hyeon Jun)</li>
<li><a
href="https://github.com/eslint/eslint/commit/eccc68d42564e46bc9020d19ec52d2988f3b7bfa"><code>eccc68d</code></a>
docs: correct --suppressions-location option description (<a
href="https://redirect.github.com/eslint/eslint/issues/21093">#21093</a>)
(Ga eun Lee)</li>
<li><a
href="https://github.com/eslint/eslint/commit/c5963f74bfa82a5b7ccc0607dcdcc695b8c97a31"><code>c5963f7</code></a>
docs: Update README (GitHub Actions Bot)</li>
</ul>
<h2>Chores</h2>
<ul>
<li><a
href="https://github.com/eslint/eslint/commit/4fbf46d140120ae838db08cbe49544c985956490"><code>4fbf46d</code></a>
test: pin <code>webpack</code> version to 5.108.4 (<a
href="https://redirect.github.com/eslint/eslint/issues/21137">#21137</a>)
(Francesco Trotta)</li>
<li><a
href="https://github.com/eslint/eslint/commit/2d063e224e9bb0ccb0a28c120f7e380a8d62f320"><code>2d063e2</code></a>
chore: update HTTP URLs to HTTPS in JSDoc and comments (<a
href="https://redirect.github.com/eslint/eslint/issues/21101">#21101</a>)
(Bo Hyun Kim)</li>
<li><a
href="https://github.com/eslint/eslint/commit/eccbe7bc9257a6c99880468e452c8dfafbf47f74"><code>eccbe7b</code></a>
test: add error locations to <code>no-class-assign</code> (<a
href="https://redirect.github.com/eslint/eslint/issues/21123">#21123</a>)
(devoil)</li>
<li><a
href="https://github.com/eslint/eslint/commit/e7d1e4373bf626f8da76d62ce024c28928bc99bc"><code>e7d1e43</code></a>
ci: bump actions/setup-go from 6 to 7 (<a
href="https://redirect.github.com/eslint/eslint/issues/21118">#21118</a>)
(dependabot[bot])</li>
<li><a
href="https://github.com/eslint/eslint/commit/e9d66d0cde1a7752c29dee53ab7b62632835a87a"><code>e9d66d0</code></a>
ci: bump actions/setup-node from 6 to 7 (<a
href="https://redirect.github.com/eslint/eslint/issues/21119">#21119</a>)
(dependabot[bot])</li>
<li><a
href="https://github.com/eslint/eslint/commit/ee225b60c26f0cc5f4d71641888a5bd2ec5626d6"><code>ee225b6</code></a>
test: Add error location details to <code>no-eq-null</code> rule (<a
href="https://redirect.github.com/eslint/eslint/issues/21117">#21117</a>)
(Park Harin)</li>
<li><a
href="https://github.com/eslint/eslint/commit/044a627fa3e28ee1410d515acc5378eb4b49f8ba"><code>044a627</code></a>
chore: update minimatch to ^10.2.5 (<a
href="https://redirect.github.com/eslint/eslint/issues/21107">#21107</a>)
(김채영)</li>
<li><a
href="https://github.com/eslint/eslint/commit/fb09aa8ff09730d3ccf68859e065f99666b52466"><code>fb09aa8</code></a>
chore: update ecosystem plugins (<a
href="https://redirect.github.com/eslint/eslint/issues/21115">#21115</a>)
(ESLint Bot)</li>
<li><a
href="https://github.com/eslint/eslint/commit/5abd878740fe417fd8a910cac3741f2a0317b365"><code>5abd878</code></a>
test: add error locations to <code>no-proto</code> (<a
href="https://redirect.github.com/eslint/eslint/issues/21114">#21114</a>)
(Gihyeon Jeong / 정기현)</li>
<li><a
href="https://github.com/eslint/eslint/commit/9715887ec94a5ff936447d7b680d039fbe2f0541"><code>9715887</code></a>
test: Add error location details to <code>no-div-regex</code> (<a
href="https://redirect.github.com/eslint/eslint/issues/21110">#21110</a>)
(Park Harin)</li>
<li><a
href="https://github.com/eslint/eslint/commit/a746ec6ea4a2249b1eb3f512264da5f4d5f7c886"><code>a746ec6</code></a>
test: add error locations to <code>no-new-wrappers</code> (<a
href="https://redirect.github.com/eslint/eslint/issues/21109">#21109</a>)
(Gihyeon Jeong / 정기현)</li>
<li><a
href="https://github.com/eslint/eslint/commit/8dde64570e240f3ef8af873d59a752db6d8519aa"><code>8dde645</code></a>
test: add error locations to <code>no-ex-assign</code> (<a
href="https://redirect.github.com/eslint/eslint/issues/21102">#21102</a>)
(devoil)</li>
<li><a
href="https://github.com/eslint/eslint/commit/13ab0ec447650c079d8a5e5d67222f79e69c741e"><code>13ab0ec</code></a>
test: add error locations to <code>no-label-var</code> (<a
href="https://redirect.github.com/eslint/eslint/issues/21098">#21098</a>)
(Gihyeon Jeong / 정기현)</li>
<li><a
href="https://github.com/eslint/eslint/commit/a99906ffb8342dde03336b5c6372b5658f21d5f2"><code>a99906f</code></a>
test: Add error location details to <code>no-delete-var</code> rule (<a
href="https://redirect.github.com/eslint/eslint/issues/21105">#21105</a>)
(Park Harin)</li>
<li><a
href="https://github.com/eslint/eslint/commit/c47e8dc9bbfba797430db45ae08cb6f7392cfc9d"><code>c47e8dc</code></a>
chore: add missing backticks to <code>languages/js/index.js</code> (<a
href="https://redirect.github.com/eslint/eslint/issues/21104">#21104</a>)
(beeen)</li>
<li><a
href="https://github.com/eslint/eslint/commit/0174428dd7543d118cf67a823b98dd97032fe68c"><code>0174428</code></a>
chore: add missing backticks to <code>translate-cli-options.js</code>
(<a
href="https://redirect.github.com/eslint/eslint/issues/21097">#21097</a>)
(dongkyu lee)</li>
<li><a
href="https://github.com/eslint/eslint/commit/3d36589a4917326500f0707bb41745160f387d00"><code>3d36589</code></a>
chore: add missing backticks to <code>serialization.js</code> (<a
href="https://redirect.github.com/eslint/eslint/issues/21091">#21091</a>)
(이규환)</li>
<li><a
href="https://github.com/eslint/eslint/commit/dcc9312c1081931de0fe1b555fbb6aa82fe696b9"><code>dcc9312</code></a>
test: add error locations to <code>eqeqeq</code> (<a
href="https://redirect.github.com/eslint/eslint/issues/21090">#21090</a>)
(Ga eun Lee)</li>
<li><a
href="https://github.com/eslint/eslint/commit/2710b182472cd1a95e4aff6186cb2c4fbadb4ee0"><code>2710b18</code></a>
ci: Add explicit permissions to rebuild-docs-sites workflow (<a
href="https://redirect.github.com/eslint/eslint/issues/21089">#21089</a>)
(Marry (Subin Yang))</li>
<li><a
href="https://github.com/eslint/eslint/commit/5d2f8663ee60701e9036b0b0933a12efd9d93269"><code>5d2f866</code></a>
chore: update dependency prettier to v3.9.5 (<a
href="https://redirect.github.com/eslint/eslint/issues/21086">#21086</a>)
(renovate[bot])</li>
<li><a
href="https://github.com/eslint/eslint/commit/d584e31098f262563b97dc5d5f23a63a187879a0"><code>d584e31</code></a>
chore: fix failing ecosystem test for <code>eslint-plugin-unicorn</code>
(<a
href="https://redirect.github.com/eslint/eslint/issues/21084">#21084</a>)
(Francesco Trotta)</li>
<li><a
href="https://github.com/eslint/eslint/commit/bf3eda049bd690f1e9a5a0c4520a6329b3e4ec85"><code>bf3eda0</code></a>
chore: update ecosystem plugins (<a
href="https://redirect.github.com/eslint/eslint/issues/21079">#21079</a>)
(ESLint Bot)</li>
</ul>
<h2>v10.7.0</h2>
<h2>Features</h2>
<ul>
<li><a
href="https://github.com/eslint/eslint/commit/cf2a9bf2d982642f760370c15f1196f4658f4e27"><code>cf2a9bf</code></a>
feat: add errorClassNames option to preserve-caught-error rule (<a
href="https://redirect.github.com/eslint/eslint/issues/21032">#21032</a>)
(sethamus)</li>
<li><a
href="https://github.com/eslint/eslint/commit/f8b873aae53610b80b5c1005606716e05ed3b91f"><code>f8b873a</code></a>
feat: max-nested-callbacks option for constructor callbacks (<a
href="https://redirect.github.com/eslint/eslint/issues/21063">#21063</a>)
(fnx)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/eslint/eslint/commit/749dfed106f44d77bf3af2402ebfae20cdbf59ee"><code>749dfed</code></a>
10.8.0</li>
<li><a
href="https://github.com/eslint/eslint/commit/4bd0d75b22ffd44809a92659f113c972c5be0770"><code>4bd0d75</code></a>
Build: changelog update for 10.8.0</li>
<li><a
href="https://github.com/eslint/eslint/commit/4fbf46d140120ae838db08cbe49544c985956490"><code>4fbf46d</code></a>
test: pin <code>webpack</code> version to 5.108.4 (<a
href="https://redirect.github.com/eslint/eslint/issues/21137">#21137</a>)</li>
<li><a
href="https://github.com/eslint/eslint/commit/6ddf8587e5cfeeb7328332601bb76d98b8a198ea"><code>6ddf858</code></a>
docs: fix broken Specify Parser Options anchor link (<a
href="https://redirect.github.com/eslint/eslint/issues/21106">#21106</a>)</li>
<li><a
href="https://github.com/eslint/eslint/commit/784dfbe98f0222426fd200153a5ce2e5624151f0"><code>784dfbe</code></a>
docs: Clarify <code>no-eq-null</code> description (<a
href="https://redirect.github.com/eslint/eslint/issues/21120">#21120</a>)</li>
<li><a
href="https://github.com/eslint/eslint/commit/6b8d2f7589b8a7c8b91b8ca2a2ef6d46178760d8"><code>6b8d2f7</code></a>
fix: escape reserved characters in rule id in <code>html</code>
formatter (<a
href="https://redirect.github.com/eslint/eslint/issues/21129">#21129</a>)</li>
<li><a
href="https://github.com/eslint/eslint/commit/2d063e224e9bb0ccb0a28c120f7e380a8d62f320"><code>2d063e2</code></a>
chore: update HTTP URLs to HTTPS in JSDoc and comments (<a
href="https://redirect.github.com/eslint/eslint/issues/21101">#21101</a>)</li>
<li><a
href="https://github.com/eslint/eslint/commit/eccbe7bc9257a6c99880468e452c8dfafbf47f74"><code>eccbe7b</code></a>
test: add error locations to <code>no-class-assign</code> (<a
href="https://redirect.github.com/eslint/eslint/issues/21123">#21123</a>)</li>
<li><a
href="https://github.com/eslint/eslint/commit/2fee9bb7416116cbed4d8c8100b1ae713b6356a1"><code>2fee9bb</code></a>
feat: export <code>ConfigObject</code> from <code>eslint/config</code>
(<a
href="https://redirect.github.com/eslint/eslint/issues/21082">#21082</a>)</li>
<li><a
href="https://github.com/eslint/eslint/commit/e7d1e4373bf626f8da76d62ce024c28928bc99bc"><code>e7d1e43</code></a>
ci: bump actions/setup-go from 6 to 7 (<a
href="https://redirect.github.com/eslint/eslint/issues/21118">#21118</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/eslint/eslint/compare/v10.1.0...v10.8.0">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 12:20:27 +00:00
dependabot[bot] 94fbc74271 build(deps): bump the uv group across 1 directory with 3 updates (#7287)
Bumps the uv group with 3 updates in the /engine directory:
[cryptography](https://github.com/pyca/cryptography),
[aiohttp](https://github.com/aio-libs/aiohttp) and
[datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator).

Updates `cryptography` from 49.0.0 to 50.0.0
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst">cryptography's
changelog</a>.</em></p>
<blockquote>
<p>50.0.0 - 2026-07-31</p>
<pre><code>
* **SECURITY ISSUE**:

:func:`~cryptography.hazmat.primitives.serialization.pkcs7.pkcs7_decrypt_der`
and its PEM and S/MIME variants no longer expose distinguishable errors
or
timing when unwrapping a ``RecipientInfo``'s ``encryptedKey``, which
could
act as a Bleichenbacher oracle for callers that decrypt untrusted
messages.
A random key is now substituted on failure, as described in :rfc:`3218`.
  Credit to **@X1AOxiang** for reporting the issue. **CVE-2026-69247**
* Deprecated Diffie-Hellman key exchange over finite fields (FFDH).
  Everything FFDH is deprecated, including the types in
``cryptography.hazmat.primitives.asymmetric.dh`` and loading FFDH keys
or
  parameters with the key loading APIs. Users should migrate to a more
  modern key exchange algorithm.
* Added ``xof()`` class methods to
  :class:`~cryptography.hazmat.primitives.hashes.SHAKE128` and
:class:`~cryptography.hazmat.primitives.hashes.SHAKE256` for
constructing
  algorithm instances configured for use with
  :class:`~cryptography.hazmat.primitives.hashes.XOFHash`.
* The :mod:`X.509 verification &lt;cryptography.x509.verification&gt;`
APIs are now
  considered stable and are subject to our API stability policy.
* Added the :doc:`/cobblestone` recipe, an implementation of the
  Cobblestone-128 and Cobblestone-256 instantiations of the `C2SP
  chunked-encryption specification
&lt;https://c2sp.org/chunked-encryption&gt;`_ for streaming
authenticated
  encryption of large messages.
* Parsing a Signed Certificate Timestamp list now rejects encodings that
carry trailing bytes after the list or after an individual SCT, instead
of
  silently ignoring them.
* Added support for using :class:`~cryptography.x509.Name` as a field
type in
  the :doc:`/hazmat/asn1/index` module.
* Loading a public key or an EC private key now rejects DER where the
``subjectPublicKey`` (or EC ``publicKey``) ``BIT STRING`` declares a
non-zero
  number of unused bits, instead of silently ignoring it.
* Parsing a CRL entry's ``InvalidityDate`` extension now rejects a
``GeneralizedTime`` that carries fractional seconds or another non-DER
form,
matching the strict encoding already required for every other X.509 time
  field.
* :func:`~cryptography.x509.ocsp.load_der_ocsp_request` and
:func:`~cryptography.x509.ocsp.load_der_ocsp_response` now reject a
request
or response whose ``version`` field is not ``v1``, the only version
defined
by RFC 6960, matching the version validation already performed when
loading
  certificates, CSRs and CRLs.
* :class:`~cryptography.hazmat.primitives.hashes.XOFHash` is now
supported
  when building against AWS-LC.
* HMAC (and therefore PBKDF2-HMAC) with SHA-3 hashes is now supported
when
  building against AWS-LC.
* Diffie-Hellman (:doc:`/hazmat/primitives/asymmetric/dh`) is now
supported
  when building against AWS-LC.
&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pyca/cryptography/commit/dcb7050b807b00392fa9fe2eac7cb362fcf355cc"><code>dcb7050</code></a>
Prepare for 50.0.0 release (<a
href="https://redirect.github.com/pyca/cryptography/issues/15372">#15372</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/53fccd93413a8d7f07d6d8999681f27b75cffa3f"><code>53fccd9</code></a>
Don't leak how PKCS#7 encryptedKey decryption failed (<a
href="https://redirect.github.com/pyca/cryptography/issues/15369">#15369</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/d472f978470fbefa521b86d98b2ecccbbb4d1dd8"><code>d472f97</code></a>
Add <code>from __future__ import annotations</code> to all src/ Python
files (<a
href="https://redirect.github.com/pyca/cryptography/issues/15371">#15371</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/908773d53829fb1466c6db364b31321c3cd8eb9a"><code>908773d</code></a>
Bump downstream dependencies in CI (<a
href="https://redirect.github.com/pyca/cryptography/issues/15368">#15368</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/2cc07cc948948211899bcb0cddd1fddf86e95812"><code>2cc07cc</code></a>
Bump BoringSSL, OpenSSL, AWS-LC in CI (<a
href="https://redirect.github.com/pyca/cryptography/issues/15367">#15367</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/c94ede9f040fa44942f7139772603419000acf66"><code>c94ede9</code></a>
chore(deps): bump ruff from 0.16.0 to 0.16.1 (<a
href="https://redirect.github.com/pyca/cryptography/issues/15366">#15366</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/67a8308dc9ea4cce6056e0f1438f903c208c3f35"><code>67a8308</code></a>
chore(deps): bump virtualenv from 21.7.0 to 21.7.1 (<a
href="https://redirect.github.com/pyca/cryptography/issues/15365">#15365</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/95018ffcdbbc510fd92fc872e3a3e80aa6e58596"><code>95018ff</code></a>
Release the GIL in one-shot AEAD encrypt/decrypt (<a
href="https://redirect.github.com/pyca/cryptography/issues/15361">#15361</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/6954733eaf55a0074abf88f06f7242dfca3a5d02"><code>6954733</code></a>
Release the GIL during DH and DSA parameter generation (<a
href="https://redirect.github.com/pyca/cryptography/issues/15364">#15364</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/6893b94c33e948f6240082461424cfb5da2dacc6"><code>6893b94</code></a>
Import _serialization instead of serialization in x509/extensions (<a
href="https://redirect.github.com/pyca/cryptography/issues/15363">#15363</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/pyca/cryptography/compare/49.0.0...50.0.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `aiohttp` from 3.14.1 to 3.14.3
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst">aiohttp's
changelog</a>.</em></p>
<blockquote>
<h1>3.14.3 (2026-07-22)</h1>
<h2>Bug fixes</h2>
<ul>
<li>
<p>Fixed the client dropping only the first <code>Authorization</code>,
<code>Cookie</code> and
<code>Proxy-Authorization</code> header when a redirect crossed an
origin -- by :user:<code>arshsmith1</code>.</p>
<p><em>Related issues and pull requests on GitHub:</em>
:issue:<code>13180</code>.</p>
</li>
<li>
<p>Fixed error message construction in the C HTTP parser -- by
:user:<code>bdraco</code>.</p>
<p><em>Related issues and pull requests on GitHub:</em>
:issue:<code>13222</code>.</p>
</li>
</ul>
<hr />
<h1>3.14.2 (2026-07-20)</h1>
<h2>Bug fixes</h2>
<ul>
<li>
<p>Fixed :py:attr:<code>~aiohttp.web.StreamResponse.last_modified</code>
rounding a
:class:<code>datetime.datetime</code> with a fractional second down.</p>
<p><em>Related issues and pull requests on GitHub:</em>
:issue:<code>5303</code>.</p>
</li>
<li>
<p>Fixed resolving <code>localhost</code> on Windows to fall back
without <code>AI_ADDRCONFIG</code>
when the first lookup fails, so <code>localhost</code> still works
without an active
network.</p>
<p><em>Related issues and pull requests on GitHub:</em>
:issue:<code>5357</code>.</p>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/aio-libs/aiohttp/commit/5e392ce0456f5235a4ee6ad46f0e806df2f15873"><code>5e392ce</code></a>
Release v3.14.3 (<a
href="https://redirect.github.com/aio-libs/aiohttp/issues/13225">#13225</a>)</li>
<li><a
href="https://github.com/aio-libs/aiohttp/commit/49f65d54150397892f7bcc4aae887767d51c322d"><code>49f65d5</code></a>
[PR <a
href="https://redirect.github.com/aio-libs/aiohttp/issues/13222">#13222</a>/f4866933
backport][3.14] Build C parser error message from bounded...</li>
<li><a
href="https://github.com/aio-libs/aiohttp/commit/240099e5216a01b32919dcd8dd5c6c0b1bf83671"><code>240099e</code></a>
[PR <a
href="https://redirect.github.com/aio-libs/aiohttp/issues/13180">#13180</a>/ee53d655
backport][3.14] drop every copy of credential headers on ...</li>
<li><a
href="https://github.com/aio-libs/aiohttp/commit/d93f30a302f8b930074fe14a2be7b2088ba28111"><code>d93f30a</code></a>
Bump version (<a
href="https://redirect.github.com/aio-libs/aiohttp/issues/13202">#13202</a>)</li>
<li><a
href="https://github.com/aio-libs/aiohttp/commit/c1b9212ad3d93c24b5fc66ad0849597166bc816e"><code>c1b9212</code></a>
Release v3.14.2 (<a
href="https://redirect.github.com/aio-libs/aiohttp/issues/13201">#13201</a>)</li>
<li><a
href="https://github.com/aio-libs/aiohttp/commit/380d4b55e8df48dfd62f1addfb530426f6bc4106"><code>380d4b5</code></a>
[PR <a
href="https://redirect.github.com/aio-libs/aiohttp/issues/13054">#13054</a>/ed8b040c
backport][3.14] escape backslashes in digest auth quoted-...</li>
<li><a
href="https://github.com/aio-libs/aiohttp/commit/e1e1bee363dfba04a9a75c8801717da2ed5bdcb9"><code>e1e1bee</code></a>
Make llhttp method array size dynamic (<a
href="https://redirect.github.com/aio-libs/aiohttp/issues/13174">#13174</a>)
(<a
href="https://redirect.github.com/aio-libs/aiohttp/issues/13196">#13196</a>)</li>
<li><a
href="https://github.com/aio-libs/aiohttp/commit/aa4cf29b6a5ad6f4d21fa1dd3f69193dc2f5d505"><code>aa4cf29</code></a>
[PR <a
href="https://redirect.github.com/aio-libs/aiohttp/issues/13170">#13170</a>/2b906869
backport][3.14] Fix StreamResponse.last_modified rounding...</li>
<li><a
href="https://github.com/aio-libs/aiohttp/commit/71b57b40d85a0723c92b0a5a37ebdf518210d2ea"><code>71b57b4</code></a>
[PR <a
href="https://redirect.github.com/aio-libs/aiohttp/issues/13172">#13172</a>/a57747ed
backport][3.14] Fix C parser folding fragment into query_...</li>
<li><a
href="https://github.com/aio-libs/aiohttp/commit/64a03fb620b623e5a5a1b7103c07ae3e536a0d40"><code>64a03fb</code></a>
[PR <a
href="https://redirect.github.com/aio-libs/aiohttp/issues/13169">#13169</a>/1adc0cd7
backport][3.14] Upgrade http:// to https:// in README.rst...</li>
<li>Additional commits viewable in <a
href="https://github.com/aio-libs/aiohttp/compare/v3.14.1...v3.14.3">compare
view</a></li>
</ul>
</details>
<br />

Updates `datamodel-code-generator` from 0.56.0 to 0.64.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/koxudaxi/datamodel-code-generator/releases">datamodel-code-generator's
releases</a>.</em></p>
<blockquote>
<h2>0.64.0</h2>
<h2>Breaking Changes</h2>
<h3>Code Generation Changes</h3>
<ul>
<li>Self-referencing fields are now quoted with
<code>--disable-future-imports</code> - When
<code>--disable-future-imports</code> is set (no <code>from __future__
import annotations</code> and no native PEP 649 deferred evaluation on
Python &lt; 3.14), self-referencing and forward-referencing field
annotations in regular <code>BaseModel</code> classes are now emitted as
quoted forward references instead of bare names. Previously such
annotations were left unquoted, producing invalid code that raised
<code>NameError</code> (Ruff F821) at class-evaluation time. Output for
the common case (with <code>from __future__ import annotations</code> or
Python 3.14 native deferred annotations) is unchanged. Users who
snapshot/golden-file generated output for the
<code>--disable-future-imports</code> configuration with
self-referencing models will see the annotation change from unquoted to
quoted, e.g. <code>children: Optional[List[Node]]</code> →
<code>children: Optional[List[&quot;Node&quot;]]</code>. (<a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/issues/3387">#3387</a>)</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Update CHANGELOG for 0.63.0 by <a
href="https://github.com/dcg-generated-docs"><code>@​dcg-generated-docs</code></a>[bot]
in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3345">koxudaxi/datamodel-code-generator#3345</a></li>
<li>Deduplicate module content builder by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3346">koxudaxi/datamodel-code-generator#3346</a></li>
<li>Deduplicate import reference helpers by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3348">koxudaxi/datamodel-code-generator#3348</a></li>
<li>Refactor jsonschema root model registration by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3352">koxudaxi/datamodel-code-generator#3352</a></li>
<li>Refactor XML Schema literal helpers by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3349">koxudaxi/datamodel-code-generator#3349</a></li>
<li>Move builtin formatter helpers by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3351">koxudaxi/datamodel-code-generator#3351</a></li>
<li>Deduplicate Pydantic v2 config helpers by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3350">koxudaxi/datamodel-code-generator#3350</a></li>
<li>Deduplicate DataType type hint rendering by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3354">koxudaxi/datamodel-code-generator#3354</a></li>
<li>Fix <code>constr()</code> for string fields carrying
minItems/maxItems by <a
href="https://github.com/DarkaMaul"><code>@​DarkaMaul</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3353">koxudaxi/datamodel-code-generator#3353</a></li>
<li>Cover non-finite import idempotence by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3367">koxudaxi/datamodel-code-generator#3367</a></li>
<li>Deduplicate input text detection by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3357">koxudaxi/datamodel-code-generator#3357</a></li>
<li>Remove stale protobuf coverage pragma by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3358">koxudaxi/datamodel-code-generator#3358</a></li>
<li>Cover explicit null OpenAPI media schemas by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3360">koxudaxi/datamodel-code-generator#3360</a></li>
<li>Simplify Python version feature checks by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3361">koxudaxi/datamodel-code-generator#3361</a></li>
<li>Speed up CI checks by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3378">koxudaxi/datamodel-code-generator#3378</a></li>
<li>Add maintainer link to docs footer and README by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3379">koxudaxi/datamodel-code-generator#3379</a></li>
<li>Use builtin formatter in CI by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3380">koxudaxi/datamodel-code-generator#3380</a></li>
<li>Split coverage by OS by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3381">koxudaxi/datamodel-code-generator#3381</a></li>
<li>Simplify import removal cleanup by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3362">koxudaxi/datamodel-code-generator#3362</a></li>
<li>Pin deprecation warning stacklevel by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3363">koxudaxi/datamodel-code-generator#3363</a></li>
<li>Pin public module exports by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3364">koxudaxi/datamodel-code-generator#3364</a></li>
<li>Cover to_hashable branch cases by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3366">koxudaxi/datamodel-code-generator#3366</a></li>
<li>Cover stable toposort behavior by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3369">koxudaxi/datamodel-code-generator#3369</a></li>
<li>Extract registry render helpers by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3371">koxudaxi/datamodel-code-generator#3371</a></li>
<li>Fix minItems for arrays of URI strings by <a
href="https://github.com/sjh9714"><code>@​sjh9714</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3377">koxudaxi/datamodel-code-generator#3377</a></li>
<li>Deduplicate config value validators by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3372">koxudaxi/datamodel-code-generator#3372</a></li>
<li>Cover CLI option metadata helpers by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3374">koxudaxi/datamodel-code-generator#3374</a></li>
<li>Cover Pydantic v2 version fallback by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3368">koxudaxi/datamodel-code-generator#3368</a></li>
<li>Fix nullable JSON Schema const enums by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3355">koxudaxi/datamodel-code-generator#3355</a></li>
<li>Pin patchable generation seams by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3365">koxudaxi/datamodel-code-generator#3365</a></li>
<li>Cover utility helper behavior by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3375">koxudaxi/datamodel-code-generator#3375</a></li>
<li>Cover DefaultPutDict behavior by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3376">koxudaxi/datamodel-code-generator#3376</a></li>
<li>Cover validator config normalization by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3373">koxudaxi/datamodel-code-generator#3373</a></li>
<li>Avoid expensive runtime type checks by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3382">koxudaxi/datamodel-code-generator#3382</a></li>
<li>Avoid eager builtin formatter import by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3383">koxudaxi/datamodel-code-generator#3383</a></li>
<li>Avoid eager TOML parser import by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3384">koxudaxi/datamodel-code-generator#3384</a></li>
<li>Stabilize msgspec payload tests by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3385">koxudaxi/datamodel-code-generator#3385</a></li>
<li>Avoid eager input parser imports by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3386">koxudaxi/datamodel-code-generator#3386</a></li>
<li>Avoid eager parser model imports by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3388">koxudaxi/datamodel-code-generator#3388</a></li>
<li>Avoid eager AsyncAPI converter imports by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3389">koxudaxi/datamodel-code-generator#3389</a></li>
<li>Dispose parser on parse errors by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3390">koxudaxi/datamodel-code-generator#3390</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/koxudaxi/datamodel-code-generator/blob/main/CHANGELOG.md">datamodel-code-generator's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/koxudaxi/datamodel-code-generator/releases/tag/0.64.0">0.64.0</a>
- 2026-06-14</h2>
<h2>Breaking Changes</h2>
<h3>Code Generation Changes</h3>
<ul>
<li>Self-referencing fields are now quoted with
<code>--disable-future-imports</code> - When
<code>--disable-future-imports</code> is set (no <code>from __future__
import annotations</code> and no native PEP 649 deferred evaluation on
Python &lt; 3.14), self-referencing and forward-referencing field
annotations in regular <code>BaseModel</code> classes are now emitted as
quoted forward references instead of bare names. Previously such
annotations were left unquoted, producing invalid code that raised
<code>NameError</code> (Ruff F821) at class-evaluation time. Output for
the common case (with <code>from __future__ import annotations</code> or
Python 3.14 native deferred annotations) is unchanged. Users who
snapshot/golden-file generated output for the
<code>--disable-future-imports</code> configuration with
self-referencing models will see the annotation change from unquoted to
quoted, e.g. <code>children: Optional[List[Node]]</code> →
<code>children: Optional[List[&quot;Node&quot;]]</code>. (<a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/issues/3387">#3387</a>)</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Update CHANGELOG for 0.63.0 by <a
href="https://github.com/dcg-generated-docs"><code>@​dcg-generated-docs</code></a>[bot]
in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3345">koxudaxi/datamodel-code-generator#3345</a></li>
<li>Deduplicate module content builder by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3346">koxudaxi/datamodel-code-generator#3346</a></li>
<li>Deduplicate import reference helpers by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3348">koxudaxi/datamodel-code-generator#3348</a></li>
<li>Refactor jsonschema root model registration by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3352">koxudaxi/datamodel-code-generator#3352</a></li>
<li>Refactor XML Schema literal helpers by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3349">koxudaxi/datamodel-code-generator#3349</a></li>
<li>Move builtin formatter helpers by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3351">koxudaxi/datamodel-code-generator#3351</a></li>
<li>Deduplicate Pydantic v2 config helpers by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3350">koxudaxi/datamodel-code-generator#3350</a></li>
<li>Deduplicate DataType type hint rendering by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3354">koxudaxi/datamodel-code-generator#3354</a></li>
<li>Fix <code>constr()</code> for string fields carrying
minItems/maxItems by <a
href="https://github.com/DarkaMaul"><code>@​DarkaMaul</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3353">koxudaxi/datamodel-code-generator#3353</a></li>
<li>Cover non-finite import idempotence by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3367">koxudaxi/datamodel-code-generator#3367</a></li>
<li>Deduplicate input text detection by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3357">koxudaxi/datamodel-code-generator#3357</a></li>
<li>Remove stale protobuf coverage pragma by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3358">koxudaxi/datamodel-code-generator#3358</a></li>
<li>Cover explicit null OpenAPI media schemas by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3360">koxudaxi/datamodel-code-generator#3360</a></li>
<li>Simplify Python version feature checks by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3361">koxudaxi/datamodel-code-generator#3361</a></li>
<li>Speed up CI checks by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3378">koxudaxi/datamodel-code-generator#3378</a></li>
<li>Add maintainer link to docs footer and README by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3379">koxudaxi/datamodel-code-generator#3379</a></li>
<li>Use builtin formatter in CI by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3380">koxudaxi/datamodel-code-generator#3380</a></li>
<li>Split coverage by OS by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3381">koxudaxi/datamodel-code-generator#3381</a></li>
<li>Simplify import removal cleanup by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3362">koxudaxi/datamodel-code-generator#3362</a></li>
<li>Pin deprecation warning stacklevel by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3363">koxudaxi/datamodel-code-generator#3363</a></li>
<li>Pin public module exports by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3364">koxudaxi/datamodel-code-generator#3364</a></li>
<li>Cover to_hashable branch cases by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3366">koxudaxi/datamodel-code-generator#3366</a></li>
<li>Cover stable toposort behavior by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3369">koxudaxi/datamodel-code-generator#3369</a></li>
<li>Extract registry render helpers by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3371">koxudaxi/datamodel-code-generator#3371</a></li>
<li>Fix minItems for arrays of URI strings by <a
href="https://github.com/sjh9714"><code>@​sjh9714</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3377">koxudaxi/datamodel-code-generator#3377</a></li>
<li>Deduplicate config value validators by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3372">koxudaxi/datamodel-code-generator#3372</a></li>
<li>Cover CLI option metadata helpers by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3374">koxudaxi/datamodel-code-generator#3374</a></li>
<li>Cover Pydantic v2 version fallback by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3368">koxudaxi/datamodel-code-generator#3368</a></li>
<li>Fix nullable JSON Schema const enums by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3355">koxudaxi/datamodel-code-generator#3355</a></li>
<li>Pin patchable generation seams by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3365">koxudaxi/datamodel-code-generator#3365</a></li>
<li>Cover utility helper behavior by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3375">koxudaxi/datamodel-code-generator#3375</a></li>
<li>Cover DefaultPutDict behavior by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3376">koxudaxi/datamodel-code-generator#3376</a></li>
<li>Cover validator config normalization by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3373">koxudaxi/datamodel-code-generator#3373</a></li>
<li>Avoid expensive runtime type checks by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3382">koxudaxi/datamodel-code-generator#3382</a></li>
<li>Avoid eager builtin formatter import by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3383">koxudaxi/datamodel-code-generator#3383</a></li>
<li>Avoid eager TOML parser import by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3384">koxudaxi/datamodel-code-generator#3384</a></li>
<li>Stabilize msgspec payload tests by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3385">koxudaxi/datamodel-code-generator#3385</a></li>
<li>Avoid eager input parser imports by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3386">koxudaxi/datamodel-code-generator#3386</a></li>
<li>Avoid eager parser model imports by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3388">koxudaxi/datamodel-code-generator#3388</a></li>
<li>Avoid eager AsyncAPI converter imports by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3389">koxudaxi/datamodel-code-generator#3389</a></li>
<li>Dispose parser on parse errors by <a
href="https://github.com/koxudaxi"><code>@​koxudaxi</code></a> in <a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/pull/3390">koxudaxi/datamodel-code-generator#3390</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/koxudaxi/datamodel-code-generator/commit/53a25ab8ddb132ac68a2795247fc855b8f445d84"><code>53a25ab</code></a>
Fast path schema output (<a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/issues/3410">#3410</a>)</li>
<li><a
href="https://github.com/koxudaxi/datamodel-code-generator/commit/ee2087f32e6100f5c3642e7ea8506aa38e9df26c"><code>ee2087f</code></a>
Skip discriminator import scan (<a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/issues/3411">#3411</a>)</li>
<li><a
href="https://github.com/koxudaxi/datamodel-code-generator/commit/bdf5ddfc27f94a06ba8d289759193bb09daadd34"><code>bdf5ddf</code></a>
fix: quote self-referencing fields when --disable-future-imports is set
(<a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/issues/3387">#3387</a>)</li>
<li><a
href="https://github.com/koxudaxi/datamodel-code-generator/commit/ad4ec877fa6708baebdaaf820171d24bfe5bf0cb"><code>ad4ec87</code></a>
Cache payload validation strategies (<a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/issues/3409">#3409</a>)</li>
<li><a
href="https://github.com/koxudaxi/datamodel-code-generator/commit/b191d52a0a1d83edeac9553119f70b2f5c131126"><code>b191d52</code></a>
Shard Python tests (<a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/issues/3408">#3408</a>)</li>
<li><a
href="https://github.com/koxudaxi/datamodel-code-generator/commit/29dd6d74c95dd7799d51f2c707db24862809eb23"><code>29dd6d7</code></a>
Cache parsed sources (<a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/issues/3407">#3407</a>)</li>
<li><a
href="https://github.com/koxudaxi/datamodel-code-generator/commit/93e2fe3cf5774d5e4d2083fac365e5bcbf0a647a"><code>93e2fe3</code></a>
Defer generation refresh (<a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/issues/3406">#3406</a>)</li>
<li><a
href="https://github.com/koxudaxi/datamodel-code-generator/commit/bb01d9c628f9077cc5dd72320ae60a18abd5b790"><code>bb01d9c</code></a>
Lazy root format exports (<a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/issues/3405">#3405</a>)</li>
<li><a
href="https://github.com/koxudaxi/datamodel-code-generator/commit/48237ed8c3af3bb58b5e6b274925ebb646412d3a"><code>48237ed</code></a>
Fast path JSON schemas (<a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/issues/3404">#3404</a>)</li>
<li><a
href="https://github.com/koxudaxi/datamodel-code-generator/commit/b21d106c88ac22f137cd4562389ad95a50c2e912"><code>b21d106</code></a>
Slot generation facts (<a
href="https://redirect.github.com/koxudaxi/datamodel-code-generator/issues/3403">#3403</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/koxudaxi/datamodel-code-generator/compare/0.56.0...0.64.0">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 12:20:17 +00:00
posthog-eu[bot] 5c319f13cb Fix pt-BR download label mislabeled as "Baixar (JSON)" (#7043)
# Description of Changes

- **What:** Corrected the pt-BR (Brazilian Portuguese) `download`
translation from `"Baixar (JSON)"` to `"Baixar"` in
`frontend/editor/public/locales/pt-BR/translation.toml`, in both the
root table (line 32) and the `[fileManager]` section (line 3577).
- **Why:** The generic `download` key flows through
`useFileActionTerminology` (`download: t("download", "Download")`) into
the shared download button rendered on tool-result screens (e.g.
`ReviewToolStep`). Because the string was `"Baixar (JSON)"`, every
tool's Download button showed "Baixar (JSON)" for pt-BR users — implying
a JSON export regardless of the actual output format. This mislabeling
was locale-wide (all pt-BR users, all tool downloads). Session
autocapture confirmed the confusion: a pt-BR user on `/convert`
repeatedly clicked a button whose text was exactly "Baixar (JSON)", then
abandoned the flow. Nothing crashed — it's a confusing label, not a
functional break.
- **Scope / verification:** en-US uses plain `"Download"` for this key
and pt-PT already uses `"Transferir"`; no other locale carried the
`"(JSON)"` suffix on the download key, so the defect was isolated to
pt-BR. Only translation values changed — no keys added/removed, so
translation counts are unaffected.

Note: I scoped this to the mislabel — the exact symptom users observed.
The report also mentions the download being a silent anchor-click with
no success toast; that's a separate, broader UX enhancement in
`ReviewToolStep`/`WorkbenchBar`/`downloadService`, so it's intentionally
left out of this focused translation fix.

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Translations (if applicable)

- [x] Only a value correction in `pt-BR`; no translation tags added or
removed.

---
*Created with [PostHog Code](https://posthog.com/code?ref=pr) from [an
inbox
report](posthog-code://inbox/019f655d-bd60-78c2-ba59-98c23243ed57).*

Co-authored-by: posthog-eu[bot] <226701856+posthog-eu[bot]@users.noreply.github.com>
2026-08-06 12:13:30 +00:00
dependabot[bot] 7aeec93032 build(deps): bump softprops/action-gh-release from 3.0.0 to 3.0.2 (#7273)
Bumps
[softprops/action-gh-release](https://github.com/softprops/action-gh-release)
from 3.0.0 to 3.0.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/softprops/action-gh-release/releases">softprops/action-gh-release's
releases</a>.</em></p>
<blockquote>
<h2>v3.0.2</h2>
<p><code>3.0.2</code> is a patch release focused on release reliability
and compatibility. It
reuses existing draft releases when publishing prereleases, supports
replacing
release assets on Gitea, hardens streamed asset uploads, and provides
clearer
release-creation diagnostics. It also includes TypeScript, coverage, and
tooling
maintenance merged since <code>3.0.1</code>.</p>
<p>This release fixes <a
href="https://redirect.github.com/softprops/action-gh-release/issues/795">#795</a>,
<a
href="https://redirect.github.com/softprops/action-gh-release/issues/438">#438</a>,
and <a
href="https://redirect.github.com/softprops/action-gh-release/issues/803">#803</a>.
The upload transport hardening covers the
historical failure reported in <a
href="https://redirect.github.com/softprops/action-gh-release/issues/790">#790</a>,
although current hosted Node 24 runners did
not reproduce it naturally. The diagnostics work is related to <a
href="https://redirect.github.com/softprops/action-gh-release/issues/786">#786</a>
and does not
claim a reproducible release-creation fix.</p>
<h2>What's Changed</h2>
<h3>Exciting New Features 🎉</h3>
<ul>
<li>feat: improve release error reporting and test coverage by <a
href="https://github.com/chenrui333"><code>@​chenrui333</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/813">softprops/action-gh-release#813</a></li>
</ul>
<h3>Bug fixes 🐛</h3>
<ul>
<li>fix: publish existing draft releases as prereleases by <a
href="https://github.com/godfengliang"><code>@​godfengliang</code></a>
in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/801">softprops/action-gh-release#801</a></li>
<li>fix: upload small checksum assets reliably by <a
href="https://github.com/chenrui333"><code>@​chenrui333</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/815">softprops/action-gh-release#815</a></li>
<li>fix: replace existing release assets on Gitea by <a
href="https://github.com/chenrui333"><code>@​chenrui333</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/816">softprops/action-gh-release#816</a></li>
<li>fix: clarify release creation 404 errors by <a
href="https://github.com/chenrui333"><code>@​chenrui333</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/817">softprops/action-gh-release#817</a></li>
</ul>
<h3>Other Changes 🔄</h3>
<ul>
<li>chore(deps): upgrade TypeScript to 7 by <a
href="https://github.com/chenrui333"><code>@​chenrui333</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/812">softprops/action-gh-release#812</a></li>
<li>chore(deps): remove unused TypeScript tooling by <a
href="https://github.com/chenrui333"><code>@​chenrui333</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/814">softprops/action-gh-release#814</a></li>
<li>dependency, Node 24 pin, and CI maintenance merged since
<code>3.0.1</code></li>
</ul>
<h2>v3.0.1</h2>
<h2>3.0.1</h2>
<ul>
<li>maintenance release with updated dependencies</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md">softprops/action-gh-release's
changelog</a>.</em></p>
<blockquote>
<h2>3.0.2</h2>
<p><code>3.0.2</code> is a patch release focused on release reliability
and compatibility. It
reuses existing draft releases when publishing prereleases, supports
replacing
release assets on Gitea, hardens streamed asset uploads, and provides
clearer
release-creation diagnostics. It also includes TypeScript, coverage, and
tooling
maintenance merged since <code>3.0.1</code>.</p>
<p>This release fixes <a
href="https://redirect.github.com/softprops/action-gh-release/issues/795">#795</a>,
<a
href="https://redirect.github.com/softprops/action-gh-release/issues/438">#438</a>,
and <a
href="https://redirect.github.com/softprops/action-gh-release/issues/803">#803</a>.
The upload transport hardening covers the
historical failure reported in <a
href="https://redirect.github.com/softprops/action-gh-release/issues/790">#790</a>,
although current hosted Node 24 runners did
not reproduce it naturally. The diagnostics work is related to <a
href="https://redirect.github.com/softprops/action-gh-release/issues/786">#786</a>
and does not
claim a reproducible release-creation fix.</p>
<h2>What's Changed</h2>
<h3>Exciting New Features 🎉</h3>
<ul>
<li>feat: improve release error reporting and test coverage by <a
href="https://github.com/chenrui333"><code>@​chenrui333</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/813">softprops/action-gh-release#813</a></li>
</ul>
<h3>Bug fixes 🐛</h3>
<ul>
<li>fix: publish existing draft releases as prereleases by <a
href="https://github.com/godfengliang"><code>@​godfengliang</code></a>
in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/801">softprops/action-gh-release#801</a></li>
<li>fix: upload small checksum assets reliably by <a
href="https://github.com/chenrui333"><code>@​chenrui333</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/815">softprops/action-gh-release#815</a></li>
<li>fix: replace existing release assets on Gitea by <a
href="https://github.com/chenrui333"><code>@​chenrui333</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/816">softprops/action-gh-release#816</a></li>
<li>fix: clarify release creation 404 errors by <a
href="https://github.com/chenrui333"><code>@​chenrui333</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/817">softprops/action-gh-release#817</a></li>
</ul>
<h3>Other Changes 🔄</h3>
<ul>
<li>chore(deps): upgrade TypeScript to 7 by <a
href="https://github.com/chenrui333"><code>@​chenrui333</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/812">softprops/action-gh-release#812</a></li>
<li>chore(deps): remove unused TypeScript tooling by <a
href="https://github.com/chenrui333"><code>@​chenrui333</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/814">softprops/action-gh-release#814</a></li>
<li>dependency, Node 24 pin, and CI maintenance merged since
<code>3.0.1</code></li>
</ul>
<h2>3.0.1</h2>
<ul>
<li>maintenance release with updated dependencies</li>
</ul>
<h2>3.0.0</h2>
<p><code>3.0.0</code> is a major release that moves the action runtime
from Node 20 to Node 24.
Use <code>v3</code> on GitHub-hosted runners and self-hosted fleets that
already support the
Node 24 Actions runtime. <code>v2.6.2</code> was the final Node
20-compatible release and is
no longer maintained or supported.</p>
<h2>What's Changed</h2>
<h3>Other Changes 🔄</h3>
<ul>
<li>Move the action runtime and bundle target to Node 24</li>
<li>Update <code>@types/node</code> to the Node 24 line and allow future
Dependabot updates</li>
<li>Keep the floating major tag on <code>v3</code>; freeze
<code>v2</code> at the final <code>v2.6.2</code> release</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/softprops/action-gh-release/commit/3d0d9888cb7fd7b750713d6e236d1fcb99157228"><code>3d0d988</code></a>
release 3.0.2 (<a
href="https://redirect.github.com/softprops/action-gh-release/issues/818">#818</a>)</li>
<li><a
href="https://github.com/softprops/action-gh-release/commit/7e13ed4ac596a4adf801d3812be5a089356949aa"><code>7e13ed4</code></a>
fix: clarify release creation 404 errors (<a
href="https://redirect.github.com/softprops/action-gh-release/issues/817">#817</a>)</li>
<li><a
href="https://github.com/softprops/action-gh-release/commit/e6c70a53cf67373fbff7eeebca7782b4fe8f106c"><code>e6c70a5</code></a>
fix: replace existing release assets on Gitea (<a
href="https://redirect.github.com/softprops/action-gh-release/issues/816">#816</a>)</li>
<li><a
href="https://github.com/softprops/action-gh-release/commit/f3453378888d5ef6208e2788af1c5ba50d8a898d"><code>f345337</code></a>
fix: publish existing draft releases as prereleases (<a
href="https://redirect.github.com/softprops/action-gh-release/issues/801">#801</a>)</li>
<li><a
href="https://github.com/softprops/action-gh-release/commit/d8a89a206684ded8435bf16d6f698bf04ab05164"><code>d8a89a2</code></a>
fix: upload small checksum assets reliably (<a
href="https://redirect.github.com/softprops/action-gh-release/issues/815">#815</a>)</li>
<li><a
href="https://github.com/softprops/action-gh-release/commit/45ece40c3178522904ac6c51c21d8a4e3565f3c8"><code>45ece40</code></a>
chore(deps): remove unused TypeScript tooling (<a
href="https://redirect.github.com/softprops/action-gh-release/issues/814">#814</a>)</li>
<li><a
href="https://github.com/softprops/action-gh-release/commit/f6b913c3f95302d88e8ef7cb2859c2e7656aa01e"><code>f6b913c</code></a>
feat: improve release error reporting and test coverage (<a
href="https://redirect.github.com/softprops/action-gh-release/issues/813">#813</a>)</li>
<li><a
href="https://github.com/softprops/action-gh-release/commit/15f193d7d8aa9623b5181913a31fafceb4e8cef9"><code>15f193d</code></a>
chore(deps): upgrade TypeScript to 7 (<a
href="https://redirect.github.com/softprops/action-gh-release/issues/812">#812</a>)</li>
<li><a
href="https://github.com/softprops/action-gh-release/commit/cc8268d46a81d57ec9b061f27b0dd68ebd7fb17f"><code>cc8268d</code></a>
chore(deps): bump actions/checkout in the github-actions group (<a
href="https://redirect.github.com/softprops/action-gh-release/issues/810">#810</a>)</li>
<li><a
href="https://github.com/softprops/action-gh-release/commit/fd0ed1e85b6730f87f5c67d7355751c46ad513d6"><code>fd0ed1e</code></a>
chore(deps): bump the npm group with 3 updates (<a
href="https://redirect.github.com/softprops/action-gh-release/issues/811">#811</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/softprops/action-gh-release/compare/b4309332981a82ec1c5618f44dd2e27cc8bfbfda...3d0d9888cb7fd7b750713d6e236d1fcb99157228">compare
view</a></li>
</ul>
</details>
<br />

<details>
<summary>Most Recent Ignore Conditions Applied to This Pull
Request</summary>

| Dependency Name | Ignore Conditions |
| --- | --- |
| softprops/action-gh-release | [>= 2.2.a, < 2.3] |
</details>


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=softprops/action-gh-release&package-manager=github_actions&previous-version=3.0.0&new-version=3.0.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 12:01:20 +00:00
dependabot[bot] ddc0baa41d build(deps-dev): bump ip-address from 10.2.0 to 10.4.0 in /frontend (#7278)
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from
10.2.0 to 10.4.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/beaugunderson/ip-address/releases">ip-address's
releases</a>.</em></p>
<blockquote>
<h2>v10.4.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Add GitHub Actions CI by <a
href="https://github.com/beaugunderson"><code>@​beaugunderson</code></a>
in <a
href="https://redirect.github.com/beaugunderson/ip-address/pull/213">beaugunderson/ip-address#213</a></li>
<li>Keep the package loadable on node 12, and enforce it by <a
href="https://github.com/beaugunderson"><code>@​beaugunderson</code></a>
in <a
href="https://redirect.github.com/beaugunderson/ip-address/pull/216">beaugunderson/ip-address#216</a></li>
<li>Validate the byte arrays Address6 is given by <a
href="https://github.com/beaugunderson"><code>@​beaugunderson</code></a>
in <a
href="https://redirect.github.com/beaugunderson/ip-address/pull/217">beaugunderson/ip-address#217</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/beaugunderson/ip-address/compare/v10.3.1...v10.4.0">https://github.com/beaugunderson/ip-address/compare/v10.3.1...v10.4.0</a></p>
<h2>v10.3.1</h2>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/beaugunderson/ip-address/compare/v10.3.0...v10.3.1">https://github.com/beaugunderson/ip-address/compare/v10.3.0...v10.3.1</a></p>
<h2>v10.3.0</h2>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/beaugunderson/ip-address/compare/v10.2.2...v10.3.0">https://github.com/beaugunderson/ip-address/compare/v10.2.2...v10.3.0</a></p>
<h2>v10.2.2</h2>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/beaugunderson/ip-address/compare/v10.2.1...v10.2.2">https://github.com/beaugunderson/ip-address/compare/v10.2.1...v10.2.2</a></p>
<h2>v10.2.1</h2>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.2.1">https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.2.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/beaugunderson/ip-address/commit/fbb8db28f1559842b7191cab7d8ea6408ed82f7b"><code>fbb8db2</code></a>
10.4.0</li>
<li><a
href="https://github.com/beaugunderson/ip-address/commit/45a2b11ec254a2e5620de66e248adcb33d16e669"><code>45a2b11</code></a>
Validate the byte arrays Address6 is given (<a
href="https://redirect.github.com/beaugunderson/ip-address/issues/217">#217</a>)</li>
<li><a
href="https://github.com/beaugunderson/ip-address/commit/bac8810b3935cab123316a4bc5ebaa22db140299"><code>bac8810</code></a>
Keep the package loadable on node 12, and enforce it (<a
href="https://redirect.github.com/beaugunderson/ip-address/issues/216">#216</a>)</li>
<li><a
href="https://github.com/beaugunderson/ip-address/commit/9b3d8488d15e6bfe5f5503867b088ce056723e08"><code>9b3d848</code></a>
Add a security policy and a README section on security posture</li>
<li><a
href="https://github.com/beaugunderson/ip-address/commit/e84a7b381d02cb97ed114023e44133efae151254"><code>e84a7b3</code></a>
Order the README API reference Address4, Address6, AddressError</li>
<li><a
href="https://github.com/beaugunderson/ip-address/commit/015160b85ee60b39548219817a5de3c4e828a6d6"><code>015160b</code></a>
Collapse each class in the README API reference</li>
<li><a
href="https://github.com/beaugunderson/ip-address/commit/34061a897d526b7a063c3605402cd30a8363a035"><code>34061a8</code></a>
Pin checkout and setup-node to commits in the release job</li>
<li><a
href="https://github.com/beaugunderson/ip-address/commit/c5fae5d9bdfe8ded7f4ca01a3d3ea8d97f8f1277"><code>c5fae5d</code></a>
Pin action-gh-release to a commit and move it to 3.0.2</li>
<li><a
href="https://github.com/beaugunderson/ip-address/commit/e0ef0484193218b0d28cfbb53795bc44ddb3cc21"><code>e0ef048</code></a>
Replace CircleCI with GitHub Actions</li>
<li><a
href="https://github.com/beaugunderson/ip-address/commit/5e3ceb779aee6ad3f33264e66225e8e7584ab612"><code>5e3ceb7</code></a>
Add GitHub Actions CI across Node 20, 22, 24 and 25 (<a
href="https://redirect.github.com/beaugunderson/ip-address/issues/213">#213</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for ip-address since your current version.</p>
</details>
<details>
<summary>Install script changes</summary>
<p>This version adds <code>prepare</code> script that runs during
installation. Review the package contents before updating.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ip-address&package-manager=npm_and_yarn&previous-version=10.2.0&new-version=10.4.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 12:00:50 +00:00
Ludy 8094765bab build(licenses): Module-specific license. Add dependency overrides. (#7049)
# Description of Changes

This change adds a version-scoped override mechanism for dependencies
whose published metadata does not expose a detectable license.

- Added `app/license-overrides.json` with verified Apache License 2.0
metadata for:
  - `com.hubspot.immutables:immutables-exceptions:1.9`
  - `com.hubspot:algebra:1.5`
- Added `ModuleLicenseOverrideFilter` as custom `buildSrc` logic for the
Gradle dependency license report plugin.
- Applied overrides only when the exact `group:artifact:version` matches
and no usable license metadata was detected.
- Added automatic maintenance of the override file:
  - Removes overrides when the dependency is no longer resolved.
- Removes overrides when the dependency starts publishing valid license
metadata.
- Migrates stale overrides to newer unresolved versions and clears their
metadata for re-verification.
- Adds null-valued placeholders for newly detected dependencies without
license metadata.
- Preserves populated overrides for newer versions when already present.
- Added Gradle version-aware dependency ordering for override migration.
- Registered `app/license-overrides.json` as an input for license-report
and license-check preparation tasks.
- Centralized the dependency license report plugin version in
`buildSrc`.
- Added unit tests covering override application, cleanup, migration,
exact-version matching, concurrent versions, placeholder generation, and
numeric version ordering.
- Added documentation describing the override lifecycle, verification
requirements, maintenance workflow, and validation commands.
- Replaced broad null-license allowances for the two HubSpot modules
with explicit Apache License 2.0 metadata.
- Added accepted GNU Lesser General Public License name variants
encountered in dependency metadata.

The change was made because some dependencies have known upstream
licenses but do not publish license metadata in a form detected by the
Gradle license report plugin. Previously, these dependencies were
permitted through module-specific null-license exceptions, leaving
incomplete information in the generated report. The new mechanism
supplies verified metadata without overriding valid metadata published
by dependencies.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-06 11:44:12 +00:00
Ludy 2265e48b32 chore(ci): optimize GitHub Actions Gradle caching across workflows (#7299)
# Description of Changes

This PR refactors Gradle caching across the GitHub Actions workflows to
improve cache reuse, reduce dependency resolution overhead, and shorten
CI execution times.

### What was changed

- Replaced multiple `gradle/actions/setup-gradle` steps with a unified
`actions/cache`-based Gradle User Home cache strategy.
- Standardized cache paths across workflows to include:
  - `~/.gradle/caches`
  - `~/.gradle/wrapper`
- Introduced consistent cache keys using:
  - Runner OS
  - Runner architecture
  - JDK version
- Hashes of Gradle wrapper, version catalog, Gradle build files, and
project build scripts.
- Added restore keys to maximize cache hit rates across similar
environments.
- Added a new **`gradle-cache-prime`** job in the main build workflow
that:
  - Restores or creates the shared Gradle cache.
  - Resolves backend dependencies before downstream jobs execute.
  - Makes the populated cache available to subsequent jobs.
- Updated workflow dependencies so Gradle-based jobs wait for the cache
priming job before execution.
- Simplified and unified Gradle cache handling across numerous CI
workflows, including backend builds, OpenAPI generation, database
migration tests, Docker tests, Tauri builds, Swagger generation,
enterprise builds, release workflows, and license generation.
- Updated workflow comments to reflect the new caching strategy and
shared cache behavior.

### Why the change was made

The previous workflows used a mixture of Gradle setup actions and
partial dependency caches, leading to duplicated dependency downloads,
inconsistent cache behavior, and longer CI runtimes. Consolidating all
workflows onto a shared Gradle User Home cache with a dedicated cache
priming job improves cache reuse, reduces unnecessary dependency
resolution, and makes CI execution more consistent.



---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-06 11:43:53 +00:00
Ludy b10fc1b2de fix(java): prevent executor, task, regex, and stream resource leaks (#7284)
# Description of Changes

- Added graceful shutdown handling for service-owned executors in
`JobExecutorService`, `PolicyEngine`, and `AsyncConfig`.
- Added expiration and cleanup for abandoned pending jobs in
`TaskManager`.
- Replaced the unbounded regex pattern cache with a bounded cache
limited to 512 entries.
- Ensured `Files.walk()` is closed correctly in `MobileScannerService`.
- These changes prevent unbounded heap growth, lingering virtual-thread
executors, and file-descriptor leaks.
- Added configurable pending-job expiration through
`stirling.job.pendingExpiryMinutes`, defaulting to 24 hours.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-06 11:06:28 +00:00
Ludy 866e56728d fix(storage): delete share access records before expired share links (#7161)
# Description of Changes

- Updated expired share-link cleanup to delete related `FileShareAccess`
records before deleting their parent `FileShare` records.
- Wrapped the cleanup operation in a transaction to ensure the deletion
order is enforced atomically.
- Prevents foreign-key constraint violations and scheduled-task failures
during cleanup.
- The full backend check was limited by a Gradle distribution
download/network error.

```cmd
[backend:dev:proprietary] 16:25:43.362 [scheduled-vt-2] WARN  org.hibernate.orm.jdbc.error - HHH000247: ErrorCode: 23503, SQLState: 23503
[backend:dev:proprietary] 16:25:43.362 [scheduled-vt-2] WARN  org.hibernate.orm.jdbc.error - Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"
[backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement:
[backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240]
[backend:dev:proprietary] 16:25:43.380 [scheduled-vt-2] ERROR o.s.s.s.TaskUtils$LoggingErrorHandler - Unexpected error occurred in scheduled task
[backend:dev:proprietary] org.springframework.dao.DataIntegrityViolationException: could not execute statement [Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"
[backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement:
[backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240]] [delete from file_shares where file_share_id=?]; SQL [delete from file_shares where file_share_id=?]; constraint [FKQ6V4QH5LFCAWII0ABRVSJO5SG]
[backend:dev:proprietary]       at org.springframework.orm.jpa.hibernate.HibernateExceptionTranslator.convertHibernateAccessException(HibernateExceptionTranslator.java:169)
[backend:dev:proprietary]       at org.springframework.orm.jpa.hibernate.HibernateExceptionTranslator.convertHibernateAccessException(HibernateExceptionTranslator.java:131)
[backend:dev:proprietary]       at org.springframework.orm.jpa.hibernate.HibernateExceptionTranslator.translateExceptionIfPossible(HibernateExceptionTranslator.java:105)
[backend:dev:proprietary]       at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:223)
[backend:dev:proprietary]       at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:557)
[backend:dev:proprietary]       at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:794)
[backend:dev:proprietary]       at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:757)
[backend:dev:proprietary]       at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:687)
[backend:dev:proprietary]       at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:408)
[backend:dev:proprietary]       at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:130)
[backend:dev:proprietary]       at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
[backend:dev:proprietary]       at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:135)
[backend:dev:proprietary]       at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
[backend:dev:proprietary]       at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:166)
[backend:dev:proprietary]       at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
[backend:dev:proprietary]       at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:222)
[backend:dev:proprietary]       at jdk.proxy4/jdk.proxy4.$Proxy246.deleteAll(Unknown Source)
[backend:dev:proprietary]       at stirling.software.proprietary.storage.service.StorageCleanupService.cleanupExpiredShareLinks(StorageCleanupService.java:71)
[backend:dev:proprietary]       at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
[backend:dev:proprietary]       at java.base/java.lang.reflect.Method.invoke(Method.java:565)
[backend:dev:proprietary]       at org.springframework.scheduling.support.ScheduledMethodRunnable.runInternal(ScheduledMethodRunnable.java:128)
[backend:dev:proprietary]       at org.springframework.scheduling.support.ScheduledMethodRunnable.lambda$run$1(ScheduledMethodRunnable.java:122)
[backend:dev:proprietary]       at io.micrometer.observation.Observation.observe(Observation.java:569)
[backend:dev:proprietary]       at org.springframework.scheduling.support.ScheduledMethodRunnable.run(ScheduledMethodRunnable.java:122)
[backend:dev:proprietary]       at org.springframework.scheduling.config.Task$OutcomeTrackingRunnable.run(Task.java:88)
[backend:dev:proprietary]       at org.springframework.scheduling.support.DelegatingErrorHandlingRunnable.run(DelegatingErrorHandlingRunnable.java:54)
[backend:dev:proprietary]       at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:545)
[backend:dev:proprietary]       at java.base/java.util.concurrent.FutureTask.runAndReset(FutureTask.java:369)
[backend:dev:proprietary]       at java.base/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:310)
[backend:dev:proprietary]       at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090)
[backend:dev:proprietary]       at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614)
[backend:dev:proprietary]       at java.base/java.lang.VirtualThread.run(VirtualThread.java:460)
[backend:dev:proprietary] Caused by: org.hibernate.exception.ConstraintViolationException: could not execute statement [Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"
[backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement:
[backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240]] [delete from file_shares where file_share_id=?]
[backend:dev:proprietary]       at org.hibernate.dialect.H2Dialect.lambda$buildSQLExceptionConversionDelegate$0(H2Dialect.java:840)
[backend:dev:proprietary]       at org.hibernate.exception.internal.StandardSQLExceptionConverter.convert(StandardSQLExceptionConverter.java:34)
[backend:dev:proprietary]       at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:115)
[backend:dev:proprietary]       at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:184)
[backend:dev:proprietary]       at org.hibernate.engine.jdbc.mutation.internal.AbstractMutationExecutor.performNonBatchedMutation(AbstractMutationExecutor.java:145)
[backend:dev:proprietary]       at org.hibernate.engine.jdbc.mutation.internal.MutationExecutorSingleNonBatched.performNonBatchedOperations(MutationExecutorSingleNonBatched.java:53)
[backend:dev:proprietary]       at org.hibernate.engine.jdbc.mutation.internal.AbstractMutationExecutor.execute(AbstractMutationExecutor.java:66)
[backend:dev:proprietary]       at org.hibernate.persister.entity.mutation.AbstractDeleteCoordinator.doStaticDelete(AbstractDeleteCoordinator.java:268)
[backend:dev:proprietary]       at org.hibernate.persister.entity.mutation.AbstractDeleteCoordinator.delete(AbstractDeleteCoordinator.java:79)
[backend:dev:proprietary]       at org.hibernate.action.internal.EntityDeleteAction.execute(EntityDeleteAction.java:119)
[backend:dev:proprietary]       at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:634)
[backend:dev:proprietary]       at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:505)
[backend:dev:proprietary]       at org.hibernate.event.internal.AbstractFlushingEventListener.performExecutions(AbstractFlushingEventListener.java:381)
[backend:dev:proprietary]       at org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:40)
[backend:dev:proprietary]       at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:138)
[backend:dev:proprietary]       at org.hibernate.internal.SessionImpl.fireFlush(SessionImpl.java:1484)
[backend:dev:proprietary]       at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:481)
[backend:dev:proprietary]       at org.hibernate.internal.SessionImpl.flushBeforeTransactionCompletion(SessionImpl.java:2111)
[backend:dev:proprietary]       at org.hibernate.internal.SessionImpl.beforeTransactionCompletion(SessionImpl.java:2033)
[backend:dev:proprietary]       at org.hibernate.engine.jdbc.internal.JdbcCoordinatorImpl.beforeTransactionCompletion(JdbcCoordinatorImpl.java:410)
[backend:dev:proprietary]       at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.beforeCompletionCallback(JdbcResourceLocalTransactionCoordinatorImpl.java:166)
[backend:dev:proprietary]       at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commitNoRollbackOnly(JdbcResourceLocalTransactionCoordinatorImpl.java:248)
[backend:dev:proprietary]       at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commit(JdbcResourceLocalTransactionCoordinatorImpl.java:242)
[backend:dev:proprietary]       at org.hibernate.engine.transaction.internal.TransactionImpl.commit(TransactionImpl.java:89)
[backend:dev:proprietary]       at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:553)
[backend:dev:proprietary]       ... 27 common frames omitted
[backend:dev:proprietary] Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"
[backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement:
[backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240]
[backend:dev:proprietary]       at org.h2.message.DbException.getJdbcSQLException(DbException.java:520)
[backend:dev:proprietary]       at org.h2.message.DbException.getJdbcSQLException(DbException.java:489)
[backend:dev:proprietary]       at org.h2.message.DbException.get(DbException.java:223)
[backend:dev:proprietary]       at org.h2.message.DbException.get(DbException.java:199)
[backend:dev:proprietary]       at org.h2.constraint.ConstraintReferential.checkRow(ConstraintReferential.java:363)
[backend:dev:proprietary]       at org.h2.constraint.ConstraintReferential.checkRowRefTable(ConstraintReferential.java:380)
[backend:dev:proprietary]       at org.h2.constraint.ConstraintReferential.checkRow(ConstraintReferential.java:254)
[backend:dev:proprietary]       at org.h2.table.Table.fireConstraints(Table.java:1208)
[backend:dev:proprietary]       at org.h2.table.Table.fireAfterRow(Table.java:1226)
[backend:dev:proprietary]       at org.h2.command.dml.Delete.update(Delete.java:81)
[backend:dev:proprietary]       at org.h2.command.dml.DataChangeStatement.update(DataChangeStatement.java:77)
[backend:dev:proprietary]       at org.h2.command.CommandContainer.update(CommandContainer.java:139)
[backend:dev:proprietary]       at org.h2.command.Command.executeUpdate(Command.java:306)
[backend:dev:proprietary]       at org.h2.command.Command.executeUpdate(Command.java:250)
[backend:dev:proprietary]       at org.h2.jdbc.JdbcPreparedStatement.executeUpdateInternal(JdbcPreparedStatement.java:213)
[backend:dev:proprietary]       at org.h2.jdbc.JdbcPreparedStatement.executeUpdate(JdbcPreparedStatement.java:172)
[backend:dev:proprietary]       at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)
[backend:dev:proprietary]       at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeUpdate(HikariProxyPreparedStatement.java)
[backend:dev:proprietary]       at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:181)
[backend:dev:proprietary]       ... 48 common frames omitted
```

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-06 11:06:16 +00:00
brios e560ee4cc4 chore(deps): update junrar dependency to version 8.0.0 (#7210)
# Description of Changes

Since this was a major version update i tested manually, afterwards
figured i'll submit as PR.

This version of junrar adds long-awaited (by me) RAR 5 support to the
library. RAR 5 is newest version of the RAR file format and was not
available in previous Junrar version, but is somewhat common for CBR
files to be RAR 5.


For junrar release notes see:
https://github.com/junrar/junrar/releases/tag/v8.0.0

Changes:
- Bumped junrar dep to version 8.0.0


<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-06 10:25:22 +00:00
brios 7cccee4c34 refactor(get-info): remove redundant PDF validation logic (#7213)
# Description of Changes

Could not get past validation, since very few endpoint have such
validation, i think redundant.

Changes:
* Removed the `validatePdfFile` method, which previously checked for
file presence, size limits, and content type, from `GetInfoOnPDF.java`.
* Deleted the invocation of `validatePdfFile` and its associated error
handling from the `getPdfInfo` method, so uploaded files are no longer
validated at this layer.

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-06 10:17:27 +00:00
brios 934ad180cb fix(ui): handle 404 policy error on upload without toast popup (#7254)
# Description of Changes

Fixes policy error pop-ups that sometimes happen upon upload.

Changes:
- Improved the error handling in `runPolicyOnFile` to log detailed debug
information when policy dispatch fails, making it easier to trace issues
such as missing policies or backend errors.
- Updated the `runStoredPolicy` function to pass `{ suppressErrorToast:
true }` to the API client, preventing error toasts from appearing in the
UI when the policy run fails.

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-06 10:16:15 +00:00
briosandAnthony Stirling 9aaf417303 perf(ui): lazy-load MobileScannerPage and optimize bundle splitting (#7122)
# Description of Changes


This pull request improves the frontend's performance and code
organization by optimizing how certain pages are loaded and by updating
the application's bundle splitting strategy.

Changes:
* Updated the `manualChunks` configuration in `vite.config.ts` to more
granularly split vendor dependencies into separate chunks based on their
library or usage, which can improve caching and load performance.
* Updated MobileScanner code to be lazy loaded

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-08-06 10:11:26 +00:00
brios cc1c6bc9e3 style(people): Fix convert dropdown single-category formatting and admin table header styling (#7139)
# Description of Changes

The dropdown table with the people looked out-place mainly due to the
blue, i think... this simplifies and make more consistent with the rest
of the "new" UI and not so aggresive with the colour schema.


### New:

<img width="2204" height="852" alt="image"
src="https://github.com/user-attachments/assets/0e329092-a532-445b-a27d-7afbd0a16d4c"
/>


### Old:

<img width="2210" height="838" alt="image"
src="https://github.com/user-attachments/assets/6e251d6f-f22c-4b9e-aebf-96e7fea40144"
/>



<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [X] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-06 10:01:46 +00:00
brios 732a602503 style(scanner-effect): remove padding from ToolButton of Scanner-effect (#7205)
# Description of Changes


### New
<img width="1650" height="612" alt="image"
src="https://github.com/user-attachments/assets/3046ad8e-b7a5-4fb6-b941-04ae7dcda4e4"
/>



### Old
<img width="1756" height="730" alt="image"
src="https://github.com/user-attachments/assets/e722146e-bebb-4744-9e95-4eee11444623"
/>

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [X] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-06 10:00:43 +00:00
brios e5c6ceedc5 fix(ui): resolve double scrollbar issue in Sidebar Categories modal (#7142)
# Description of Changes

Resolves double scrollbar design bug.

### New

<img width="1036" height="990" alt="image"
src="https://github.com/user-attachments/assets/454a521c-7ccf-4ebc-98be-22f13b107abe"
/>


### Old

<img width="1036" height="984" alt="image"
src="https://github.com/user-attachments/assets/c2847698-e625-48da-a5bc-e6b68050a426"
/>


<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-06 10:00:25 +00:00
brios a2dd0298dc refactor(api): replace length checks with isEmpty (#7214)
# Description of Changes

Stylistic problem reported by static analyzer. 


Changes:
* Replaced `sb.length() > 0` and `sb.length() == 0` with `!sb.isEmpty()`
and `sb.isEmpty()` for `StringBuilder`, `String`, and collections
throughout the codebase, improving readability and aligning with modern
Java best practices.


<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-06 10:00:04 +00:00
EthanHealy01 789be2d351 Non-blocking classification, pipelined batch enforcement, and selector-based file-state re-renders (#7085)
## Goal

Three related improvements to how policies and file state behave in the
editor: classification no longer blocks the user, policy enforcement
pipelines across a batch upload instead of waiting for the whole drop,
and file-state changes no longer re-render the entire UI.

## 1. Classification never blocks (and never versions)

Classification is metadata-only — it reads a document and records
labels; it never rewrites the file. Previously it ran like an
enforcement policy: it blocked viewing/editing behind the "Enforcing
policy…" overlay, forked a new versioned child (an `automate` entry in
version history), and could run before other policies — letting the user
in, then a later enforcement policy would fork a version and drop their
edits.

Now classification:
- **Never blocks.** A classification run never marks a file `enforcing`
(badge map + viewer overlay both skip it), so the file stays fully
viewable/editable while it runs.
- **No version bump, no history entry.** Its result is stamped onto the
file's existing stub in place (workspace + IndexedDB) — the labels just
appear as tags. It targets the document's *current leaf*, so an edit
made during the async run still gets the tags; a run that completes with
no outputs settles cleanly instead of pinning in-flight.
- **Always runs last** in an enforcement chain (regardless of configured
order, pinned at persist-time too), so every enforcement policy finishes
forking versions before the user is let in.

## 2. Pipeline policy enforcement across a batch upload

Dropping ~50 files enforced policies only *after the whole drop finished
scanning* — every file got the "Enforcing policy" overlay together, then
processing began. Root cause: the chunked `ADD_FILES` dispatches in
`addFiles` were never separated by an event-loop yield, so React batched
them into a single commit and the enforcement effect fired once over the
full list.

**Fix** (`core/contexts/file/fileActions.ts`): after each chunk, `await`
that chunk's IndexedDB writes, then yield a macrotask so React commits
the rows and runs the enforcement dispatch *before* the next chunk
scans. Files start enforcing as their rows land, overlapping with the
rest of the drop. Persistence is streamed per chunk (the policy auto-run
reads bytes from IndexedDB with no in-memory fallback).

**Second fix — bounded dispatch window**
(`proprietary/components/policies/usePolicyAutoRun.ts`): even with
streamed dispatch, the drop still *looked* serial — each dispatch POSTs
the file's bytes, and firing them all at once saturates the browser's
per-origin connection pool, so the status polls and output downloads of
already-running files queued behind the pending uploads; nothing visibly
progressed until the last upload drained. Dispatch is now gated behind a
small concurrency window (4), keeping connections free so early files
run, poll, and complete while later ones are still dispatching. The
first status poll also fires at 500ms (then the normal 2s cadence) so
fresh runs show real progress immediately. The batch test asserts the
window (dispatches overlap but never exceed 4).

## 3. Selector subscriptions for file state (no more whole-UI
re-renders)

`FileContext` published `{state, selectors}` through a plain React
context, so **every** consumer re-rendered on **every** state change —
one file's new version re-rendered the entire workspace.

**Phase 1 — infra** (`file/contexts.ts`, `file/fileHooks.ts`,
`FileContext.tsx`): the state context is replaced by a stable
subscription store (`FileStoreContext`); hooks are rebuilt on
`useSyncExternalStoreWithSelector` (the `use-sync-external-store` shim
react-redux uses — new direct dep, React 19 compatible). Each consumer
now re-renders only when its selected slice changes:
- `useStirlingFileStub(id)` → only that file's record
- `useAllFiles` → file-list changes only (immune to selection/UI churn)
- `useFileSelection`/`useSelectedFiles` → selection + the *selected*
files' records only
- `useFileUI` → its three UI scalars; `useFileContext` → files + pinned
slices
- `useFileState` keeps its whole-state contract for existing broad
consumers

A render-count test (`fileHooks.selector.test.tsx`) locks the bail-out
contract.

**Phase 2 — hot-path rows**: sidebar `FileItem` is memoized (with stable
empty-array props), so one file's change re-renders one row, not the
list. Active Files thumbnails were already memoized.

**Phase 3 — narrow the hottest consumers**: always-mounted whole-state
consumers migrated to slices — `Workbench`, `EmbedPdfViewer`, `Viewer`,
`NonPdfViewer`, `WorkbenchBar`, `ViewerContext`, `ViewerShareButton`,
`ZoomAPIBridge`, `ViewerAnnotationControls`, `ConvertSettings`,
`DismissAllErrorsButton`, `FileEditorThumbnail`,
`usePageEditorDropdownState`, `useSaveShortcut`, plus a new
non-subscribing `useFileSelectors()` for event-time reads
(`ReviewToolStep`, `useViewerReadAloud`, `useExitWarning`). Net effect:
selection/UI churn no longer re-renders the viewer/workbench, and a
version landing touches only components observing the files slice.

Broad readers (`FileSidebar`, `PageEditor`, `FileEditor`, `Redact`,
`FormFill`) deliberately stay on `useFileState` — they read most of the
state anyway.

**Hardening**: store notifications run in a layout effect (subscribers
re-render before paint — no stale frames), and outside production
`useFileSelectors()` wraps its selectors to `console.error` if one is
invoked during render (those reads don't subscribe, so render-time use
would silently go stale — not statically lintable, so it's guarded at
runtime; the full test suite passes under the guard).

## 4. Policy indicators: shared icons, non-blocking run chip, no pulse

- Badges and enforcement overlays now take their glyph from the shared
`policyCategoryIcon` map (the same source the processor's catalogue
uses) — label icon for classification, shield for security — instead of
a hardcoded shield everywhere.
- A non-blocking run (classification) shows a small accent-tinted pill
in the top-right of the Active Files card (category icon + loader) and
the normal spinning badge in the sidebar, via a new `background` badge
flag that nothing gates on. When the run finishes, the tagged files keep
a plain category badge.
- The post-run pulse/glow on sidebar badges is gone (with its `recent`
plumbing): spinner while running, static category icon when done.

## Verification

Full CI gate locally: `og:check`, `typecheck:all` (all variants),
`lint`, `format:check`, `build`, `test` (1366 — incl. the render-count
contract test, the classification-order/import unit tests, and the
61-file batch integration test driving the real dispatch → poll → import
→ chain effects), `storybook:build` — all green.

## Held for follow-up (not in this PR)

- **Reuse one PDFium engine across viewer file switches** (kills the
per-open "Loading PDF Engine" rebuild). Implemented on branch
`viewer/reuse-pdfium-engine`, but review found a confirmed leak
(orphaned PDFium handles when switching files mid-load); needs an
in-flight-load teardown before shipping.
2026-08-05 16:31:24 +00:00
ConnorYoh d0d197f09f Procurement: draft Enterprise Agreement + signature, legal pages & consent, quote/agreement split (#7021)
Consolidates the enterprise procurement and legal work into one PR off
`main`. Supersedes #7020 (closed; every commit from it is contained
here). Sits on top of PAYG prepaid bundles (#7032) and the `--color-*` →
`--c-*` portal token rename.

## Why

Enterprise procurement was a mock. The stage screens read from a fake
state machine, the "agreement" was prose hardcoded in a component, and
nothing a buyer did was recorded anywhere. To actually sell to an
enterprise we need three things it didn't have: a real document they can
read and sign, a record that proves they signed that exact version, and
a licence that flips when they pay.

## What

**The agreement is a real versioned document**

- Registry at `resources/legal/manifest.json` +
`legal/<id>/<version>/*.md`. Publishing a new version is a markdown file
and a manifest bump, no code change. `@`-prefixed parts are generated
sections.
- `AgreementAssembler` builds MSA (Part A) + generated Order Form (Part
B) + DPA (Part C) as one document. Only the Order Form varies per deal.
- `AgreementPdfRenderer` goes through our own pipeline (commonmark →
`FileToPdf`/WeasyPrint), so we dogfood it.
- Immutable signature record pinning document id and version, a SHA-256
of the exact rendered markdown, the variable snapshot, typed signatory
details, timestamp and IP.

**Legal document pages and consent logging**

- `GET /api/v1/legal/{docId}` serves any registry document; a viewer
modal renders it with a draft badge. The SLA exhibit is viewable for the
first time.
- `legal_consent` + `POST /api/v1/legal/consent`. EULA clickwrap is
recorded once: at trial start, or at the quote step only if there was no
trial.

**Quote and Agreement are separate steps**

The quote step is a plain itemised review (figures, renewal, PO) with
download and "Accept quote". Accepting advances to the agreement and
does not charge Stripe. Signing the agreement is still the commitment
point.

**One quote number**

We no longer mint our own reference. The Stripe quote number is the
identifier everywhere, so the UI and the memo can't disagree.
`quote_number` is nullable until Stripe assigns it at finalisation
(`20260808000000`).

**Payment takes the deal live**

`invoice.paid` on the stripe-webhook moves the deal to live and the UI
reflects it. Nothing watched for payment before, so a paid customer sat
in "payment" forever. Needs `invoice.paid` enabled on the webhook
endpoint in the Stripe dashboard.

**Security**

Any signup could self-issue a $0 enterprise licence, from three things
compounding: leader-on-signup, no entitlement gate, and no ACV floor.
So: `startTrial` now has a stage guard (it was replacing committed
licences), the offline `.lic` is gated on entitlement, the ACV floor is
enforced before the quote persists, and the air-gap check reads the
quote's deployment rather than the deal's. Invitee emails are redacted
in logs. Dev and Storybook were hitting real Stripe; both now route
through `resolveDemoResponse`.

**Removed the dead procurement island**

The original stage-by-stage page survived the rebuild with no route and
no consumer, so it was invisible to review but still cost a reader's
time. 16 unreferenced files, 182 lines of superseded API, 53 orphaned
en-US keys, and `Procurement.css` from 1665 to 968 lines. Nothing
deleted had a live consumer.

## Screenshots

Home, deal underway (hero card footer):

<!-- home-in-procurement.png -->

Quote builder, step 1:

<!-- quote-builder.png -->

Agreement, ready to sign:

<!-- agreement-signing.png -->

Payment and live:

<!-- stage-payment.png / stage-live.png -->

## How to test

**Storybook** covers every state without a backend:

```bash
cd frontend && npm run storybook
```

Then `Portal/Procurement/*`:

| Story | What to look at |
| --- | --- |
| `DealStatusHero` — Trial / Quote / Agreement / Payment / Live | One
hero per stage: progress band, one-line status, stage CTA |
| `QuoteBuilder` — Default | 4 steps. Users + volume drive the price;
Governance and PDF size are multipliers; step 4 is the itemised review |
| `ProcurementAgreement` — Default / Signing | Header actions,
always-visible scrollbar on the paper, one-line signature row |
| `ProcurementStages` — Payment / Live / License | "View & pay invoice"
opens Stripe directly; licence key and `.lic` download |
| `Views/Home` — Subscribed In Procurement | The hero in real page
context |

Note: `ProcurementAgreement` renders "Could not load the agreement" in
Storybook because it fetches the document from the backend. The chrome
is accurate, the paper body needs the app.

**Full flow** needs SaaS running and a linked team:

1. Home → **Explore enterprise** → trial setup (deployment + seats).
EULA is recorded here.
2. **Build your quote** → 4 steps → Generate. Buyer details are required
first.
3. Review the itemised quote → **Accept quote**. Confirm Stripe was
*not* charged.
4. Agreement → tick, fill signatory, **Sign agreement**. Check
`procurement_signature` for the version and content hash.
5. **View & pay invoice** → pay in Stripe test mode → deal should move
to live on the `invoice.paid` webhook.

Worth reviewing specifically: the licence cannot be issued without
entitlement (step 3 before payment), and `startTrial` on an
already-committed deal is rejected rather than overwriting.

## Verification

- `:saas compileJava` + `spotlessJavaCheck`
- `task frontend:check:all` green end to end: 9 typecheck variants,
eslint at zero warnings, `theme-lint`, `lint:css`, prettier, build,
**1656 tests across 188 files**
- 7 deno tests on the `invoice.paid` handler, covering all four shapes
Stripe uses for the subscription reference

## Open, not addressed here

- **The commercial model contradicts itself in three places.** The Order
Form says annual-in-advance, the MSA §2.3/§3.2 implies otherwise, the
quote engine computes `tcv = annualNet × termYears` flat, and Stripe
only invoices one year. Needs a decision before this is customer-facing.
- The 25 MB data-processing increments vs the ×1.4/×2.4 size multiplier,
deferred pending Matt.
- All legal text is **draft**. It renders with a draft badge and is not
presented as executed; counsel's read is still a publish gate.
- `{{subprocessor_url}}` / `{{eula_url}}` awaiting marketing's final
links.
- `frontend-a11y` is red on pre-existing portal contrast debt, deferred
by decision.

## Schema notes

Two migrations land on the SaaS side (`v3`), both applied by that repo's
PR CI:

- `20260808000000` drops the NOT NULL on
`procurement_quote.quote_number`, which is required rather than cosmetic
— the number now comes from Stripe at finalisation, so a draft holds
NULL, and `ddl-auto` cannot drop an existing NOT NULL itself.
- `20260809000000` adds `procurement_deal.last_paid_invoice_id`,
nullable.

Nothing here needs a migration in this repo: Flyway is not on the
classpath, so the Java side only ever adds via `ddl-auto`, and Postgres
migrations run ahead of the app deploy.
2026-08-05 15:23:50 +00:00
Ludy 8f5344ec7d build(tool-models): separate API model generation from Swagger setup (#7300)
# Description of Changes

- Added internal `_generate` tasks for frontend and engine tool-model
generation.
- Kept the public `tool-models` tasks responsible for dependency
installation and Swagger generation.
- Separated environment preparation from the actual model generation
commands.
- This allows higher-level tasks to prepare the OpenAPI specification
once and invoke both generators without repeating the backend setup.


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-05 14:25:44 +00:00
Anthony StirlingandReece Browne 62e5e28039 Avoid renderer OOM when adding large PDFs to the workbench (#7175)
# Description of Changes

- "Out of Memory" tab crash on merge with big PDFs (Discord report) is a
WebView2 renderer OOM, not system RAM; adding files did 2 full parses +
a parallel full read per file
- IndexedDB now stores files as Blobs (by reference, no JS-side copy);
old ArrayBuffer records stay readable
- Both thumbnail variants now come from one PDFium parse instead of two
- PDFs over 100MB never get a full client-side parse: 2MB
linearized-prefix attempt, placeholder icon otherwise
- Follow-ups if placeholders aren't enough: backend-rendered thumbnails,
PDFium ranged reads in a worker

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2026-08-05 13:10:28 +01:00
Anthony StirlingandReece Browne d7c130fca9 Serve SPA shell for deep frontend routes (#7145)
# Description of Changes

stops the /new urls crashing page on f5 


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2026-08-05 11:03:12 +00:00
EthanHealy01 4d8a86ad28 Fix the two nightly frontend failures (a11y contrast + Firefox Copy menu test) (#7286)
Fixes the two nightly frontend jobs that started failing after #7163
(new design, part one). Two unrelated causes, one small fix each.

## Accessibility scan (`--c-primary-hover`)

The full a11y scan flagged a colour-contrast violation on the portal
pipelines ToolPicker story. #7163 moved the light canvas from
`--p-gray-50` (#f9fafb) to the slightly darker `--p-paper` (#f5f4f1),
but the accent text colour stayed put. That token doubles as the label
colour for quiet and tertiary buttons, so the pairing slipped from
4.64:1 to 4.41:1 purely from the background change.

Darkening the custom-theme mix from 85% to 80% primary puts it back at
4.89:1. It is also the hover fill for primary buttons, where a
marginally deeper blue is if anything more correct.

Only the nightly caught this because PR runs scan just the stories whose
files changed, and #7163 did not touch that story file.

## Cross-browser Playwright (right-click Copy menu)

Failed in Firefox only. The feature itself is fine in every browser. The
test hit-tested a word using a fixed fraction of the page box, and the
page is auto-fit to the viewer, so the rendered text scales with the
viewport. The Firefox and WebKit projects run at 1280x720, where the
page renders about 375px wide and the first line of text is only a few
pixels tall. #7163 shrank the viewer area slightly (the rails now float
with a gutter), which shrank the auto-fit page just enough to tip that
fraction to landing below the glyphs. Nothing was selected, so no menu
appeared.

Pinning 1920x1080 for that one test makes the glyphs comfortably larger
than the click tolerance everywhere, rather than re-tuning a fraction
that was only ever a couple of pixels from failing.

A Firefox skip was considered and rejected: the sibling clipboard test
is already Chromium-only and its comment states the Copy menu is covered
cross-browser by this test, so skipping would leave the menu with no
Firefox coverage at all.

## Verification

Test pinned run is green across chromium, firefox and webkit. Contrast
checked with the theme linter's contrast report.
2026-08-04 20:40:18 +00:00
ConnorYoh 030f9f541e feat(editor): adopt TanStack Query, convert three read-only fetches (#7264)
# Description of Changes

## The problem

The editor has no query client. ~295 `apiClient` call sites, each mount
refetching what the last one just got, and three module-level caches
reimplementing dedupe, retry and invalidation by hand — each shaped
differently.

The Processor (`frontend/editor/src/portal`) has run on TanStack Query
since #7135. The editor never got it.

## End state

The editor has a query client, and the three read-only fetch sites that
convert safely now use it. `@tanstack/react-query` is already a
dependency — no new package.

**Foundation**

| File | |
|---|---|
| `core/query/queryClient.ts` | `baseQueryOptions` + client factory. The
portal now builds its client from the same options. `networkMode:
"always"` — `navigator.onLine` describes internet reachability, which
says nothing about a bundled backend on 127.0.0.1 or a self-hosted
server on the LAN. |
| `core/query/keys.ts` | `["editor", resource, ...params]` |
| `core/query/staleTime.ts` + `desktop/query/staleTime.ts` | Config
staleTime: `Infinity` on web, 5 min on desktop |
| `core/api/config.ts`, `core/api/users.ts` | Fetch functions, mirroring
`portal/api/*` |
| `core/tests/utils/TestQueryProvider.tsx` | |
| `desktop/components/DesktopQueryCacheReset.tsx` | |

`QueryClientProvider` mounts at the top of
`core/components/AppProviders.tsx`. That diff looks large but is one
wrapper plus the reindent underneath it.

**Converted.** All three keep their existing return shape, so no
consumer changes.

| | Before |
|---|---|
| `useFooterInfo` | Fetched twice — Footer and admin legal section |
| `useGroupEnabled` | Refetched on every mount |
| `UserSelector` | Refetched the whole roster on each of two mount
sites, and again whenever `t` or `user` changed identity |

**Desktop needs more than the provider.** `operationRouter` resolves the
same relative path to the local bundled backend, a self-hosted server,
or the SaaS backend. Query caches by key, not by resolved URL, so a
cached entry can outlive the backend that filled it. `group-enabled`
routes this way, so this PR introduces the hazard and carries the fix:
`DesktopQueryCacheReset` calls `resetQueries()` when the connection mode
changes or the self-hosted server goes up or down, and
`CONFIG_STALE_TIME` is finite on desktop as a backstop.

**Behaviour changes**

- All three sites now retry once on failure (client default). None
retried before, so a failing request sits in `loading` for one extra
attempt plus backoff.
- `staleTime: Infinity` on web means admin edits to legal links no
longer appear on remount within a session. Saving those already prompts
a restart, so this is accepted rather than incidental.
- Desktop `useGroupEnabled` shows the *translated* offline reason on
first render. The old code showed raw English for one render.
- `UserSelector` drops three `console.log`s that were dumping user
records to the console.

## Decisions

**1. The foundation doesn't ship alone.** A provider nothing consumes
gives a reviewer nothing to react to and rots if the follow-up stalls,
so it lands with the cheapest safe conversions.

**2. Hooks keep their existing return shape.** The alternative is
switching to `{ data, isPending, error }` and updating consumers now.
Cost of my choice: we carry a `loading`-shaped façade indefinitely, and
consumers don't get `isFetching`/`refetch` without a second pass. Taken
because it's what keeps each later migration a one-file diff.

**3. Shared defaults, separate instances.** The editor and the Processor
mount as *sibling* routes, not nested — they never coexist in one tree.
Both clients now come from the same `baseQueryOptions`, so behaviour
can't drift. A single shared instance would only buy cache surviving
navigation between the two products, which is worth little while they
share no keys, and it breaks the contract three portal tests rely on
(`createPortalQueryClient()` returning a fresh client per test). That
belongs in the collapse PR. Consequence meanwhile: the desktop reset
covers the editor client only — harmless, since the portal isn't in
desktop builds.

**4. The desktop reset is wholesale.** A mode switch already remounts
the SaaS provider tree, so there's nothing to preserve, and an allowlist
of "mode-sensitive" keys would be a trap every new query has to remember
to join.

## Coming next

Ordered by consumers per line changed.

| PR | Scope |
|---|---|
| 2 | `AppConfigContext` + `useEndpointConfig` — ~80 consumers, deletes
~200 lines of hand-rolled cache, retry and dedupe |
| 3 | `useAdminSettings` (20 consumers) and the config sections |
| 4 | Polling loops → `refetchInterval` |
| 5 | Finish the Processor's remaining files, collapse to one client |
| 6 | Tool execution — mutation state only, narrowly scoped |

Not in scope, deliberately: `usePdfLibLinks` (its cache is a refcounted
ArrayBuffer lifetime manager), thumbnail hooks, watched-folder IndexedDB
reads, the desktop health monitors. Unifying
`endpointAvailabilityService` / `saasAppConfigService` with the query
cache would mean handing `operationRouter` a query client — its own PR
if a second reason appears.

## Testing

`task frontend:check` green: 1666 tests across 191 files, typecheck on
all five flavours, eslint `--max-warnings=0`, dpdm, prettier.

New tests cover request de-duplication, per-group key isolation, the
desktop offline short-circuit, and the cache reset. The reset test was
verified to fail against the `clear()` implementation it replaced.
`UserSelector` has no test beyond its existing stories.

One existing test needed a wrapper: `Login.test.tsx` renders `<Login />`
in isolation, and `AuthLayout` → `Footer` → `useFooterInfo` now needs a
client. The real `/login` route is already inside `AppProviders`, so
this is test isolation, not a runtime gap.

Rollback is a clean revert — nothing persists outside the React tree.
2026-08-04 13:08:06 +00:00
Anthony Stirling 921bdac4b7 Desktop installer fixes (#7174)
# Description of Changes

* Adds a `windows-11-arm` CI/release leg (NSIS, Microsoft JDK 25,
updater keys); JPDFium natives deliberately excluded
(`jpdfiumPlatforms=none`) until published, so don't ship ARM64
installers to users yet
* Defaults `WEBKIT_DISABLE_DMABUF_RENDERER=1` on Linux (crash switching
tools on NVIDIA)
* Strips the bundled libwayland from AppImages (blank window on Fedora
Wayland)
* Blocks off-app webview navigation + window drop guard + close failsafe
(drag-drop bricks the app)
* 120s startup grace before the backend is declared unhealthy, restart
success only announced after a real health check ("Backend stopped
unexpectedly" spam and likely the OAuth port churn)
* Verified: green `windows-arm64` build (234 MB NSIS artifact) and green
Linux run with libwayland confirmed stripped
* JPDFium fixes for multi threading issues
---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-04 14:40:41 +01:00
briosandCopilot Autofix powered by AI 120678260a docs(contributing): update CONTRIBUTING.md to include desktop application startup command (#7211)
# Description of Changes

May spare some headaches down the line.

Changes:

- Added desktop dev command to the contributing guide

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-04 12:06:31 +00:00
brios cb45fb159a fix(search): Fix PDF viewer search showing 0 of 0 results (#7228)
# Description of Changes


Fixes that search always showing 0 of 0 result even when there are
multiple hits in the search.

### New

<img width="706" height="284" alt="image"
src="https://github.com/user-attachments/assets/f3507647-2a41-4e60-8c9c-eca43005a5c4"
/>


### Old

<img width="708" height="292" alt="image"
src="https://github.com/user-attachments/assets/7b0a6a66-f7c5-4df8-af89-134afef72eba"
/>


On the same PDF.

Changes:
* Refactored the effect in `SearchInterface` to fetch the latest search
state from `viewerContext` on each interval, preventing stale closure
values and ensuring up-to-date search results and active index are used.
* Updated the "previous" and "next" result buttons to be disabled
whenever there are zero search results, rather than relying on the
current index, making the UI more robust and intuitive.

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-04 12:02:18 +00:00
Reece Browne a8b2957b74 Fix preview on crop/rotate (#7266)
Preview on crop and rotate shows just the original file you uploaded, if
you switch to a new one it doesn't update.
2026-08-04 11:24:43 +00:00
3442 changed files with 24586 additions and 18486 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ vs `main`. Flags: `--html` (also emit a rendered HTML twin), `--no-screens`.
- Read the PR description / commit messages for stated intent. Do **not** invent
history or motivation that isn't evidenced (state current behavior in present tense).
- Classify touched files by layer:
- **Frontend**: tools (`frontend/editor/src/core/components/tools/*` or `.../core/tools/*`),
- **Frontend**: tools (`frontend/src/editor/core/components/tools/*` or `.../core/tools/*`),
hooks (`core/hooks/tools/*`, `useToolOperation`), contexts, routes, i18n
(`public/locales/en-US`).
- **Java backend**: controllers (`.../controller/api/...`), services, models, config.
+5 -5
View File
@@ -53,11 +53,11 @@ gh pr diff <pr> --name-only # or: git diff --name-only <base>...HEAD
Map changed frontend files to URLs generically:
- **Tools**: a changed `components/tools/<toolDir>/…` or `hooks/tools/<tool>/…`
toolId → URL via the repo's own rule `getToolUrlPath` in
[toolsTaxonomy.ts:200](frontend/editor/src/core/data/toolsTaxonomy.ts): `/` + the
[toolsTaxonomy.ts:200](frontend/src/editor/core/data/toolsTaxonomy.ts): `/` + the
id kebab-cased (`addPageNumbers``/add-page-numbers`).
- **Pages/routes**: changed `filesPage/*``/files`, etc.
- `--all`: enumerate every tool in the registry instead of just changed ones.
Write `frontend/editor/screenshots/ui-diff/targets.json` =
Write `frontend/screenshots/ui-diff/targets.json` =
`[{ "id":"compress", "url":"/compress", "name":"Compress" }]`. This is what makes
it generic - the spec never names a tool.
@@ -69,12 +69,12 @@ the full viewport - or the `--scope` container if given). Ensure the harness is
(node_modules + icons).
```
# after = current head
cd frontend/editor && PR_SHOT_SIDE=after PR_SHOT_THEME=light \
cd frontend && PR_SHOT_SIDE=after PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# before = base, in an isolated worktree (copy the spec + targets.json in)
git worktree add ../ba-base origin/<baseRefName> # or the merge-base
# set up its frontend, copy spec + screenshots/ui-diff/targets.json across, then:
cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
cd ../ba-base/frontend && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# copy its screenshots/ui-diff/before/ back next to after/. Repeat with
# PR_SHOT_THEME=dark if --theme includes dark. Remove worktree when done.
@@ -82,7 +82,7 @@ cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
### 4. Auto-diff (surface what changed)
```
cd frontend/editor && node <skill>/diff-shots.mjs \
cd frontend && node <skill>/diff-shots.mjs \
screenshots/ui-diff/before screenshots/ui-diff/after screenshots/ui-diff
```
Produces `diff-report.json` classifying each view `unchanged | changed | added |
@@ -2,7 +2,7 @@
// unchanged | changed | added | removed, and CROP each changed pair to the
// affected region (bounding box of differing pixels + padding) - unless the
// change spans most of the page, in which case the full frame is kept.
// Run from frontend/editor (so deps resolve):
// Run from frontend (so deps resolve):
// node <skill>/diff-shots.mjs <beforeDir> <afterDir> [outDir]
// Env:
// DIFF_THRESHOLD min fraction of differing pixels to count as changed (default 0.001)
@@ -1,5 +1,5 @@
// Render each .tab-section of a montage HTML into its own PNG (the PR-ready image).
// Run from frontend/editor (so @playwright/test resolves):
// Run from frontend (so @playwright/test resolves):
// node <skill>/shoot-sections.mjs <montage.html> <outDir>
import path from "node:path";
import { pathToFileURL } from "node:url";
+6 -6
View File
@@ -28,13 +28,13 @@ current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
## What this repo gives you (use it, don't reinvent)
- **Stubbed Playwright project** = backend-free screenshots via `page.route()` mocks.
Reference implementation: `frontend/editor/src/core/tests/stubbed/files-page-screenshots.spec.ts`.
Reference implementation: `frontend/src/editor/core/tests/stubbed/files-page-screenshots.spec.ts`.
It already shows the light / **dark** / **RTL** passes, JWT seeding, IndexedDB
seeding, and dumping PNGs to a `screenshots/<area>/` folder. Copy its shape.
- Helpers: `frontend/editor/src/core/tests/helpers/ui-helpers.ts`
- Helpers: `frontend/src/editor/core/tests/helpers/ui-helpers.ts`
(`uploadFiles`, `openSettings`, `waitForModalOpen`, `dismissTourTooltip`, …)
and the `stub-test-base` fixtures (`autoGoto`, `seedJwt`, `viewport`).
- Config: `frontend/editor/playwright.config.ts` (run from `frontend/editor/`).
- Config: `frontend/playwright.config.ts` (run from `frontend/`).
- Report template: [report-template.html](report-template.html) - self-contained,
one big image at a time, a global light/dark slider that flips every shot,
thumbnail rail, prev/next + arrow keys, and a Findings tab.
@@ -54,13 +54,13 @@ current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
Worktrees have no `node_modules` and no generated icons. From repo root:
```
cd frontend && npm ci # or junction main's node_modules (see memory)
cd frontend/editor && node scripts/generate-icons.js
cd frontend && node scripts/generate-icons.js
```
Kill any stale dev server first (it serves old modules):
`Get-NetTCPConnection -LocalPort 5173 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`
### 3. Write the capture spec
Create `frontend/editor/src/core/tests/stubbed/<feature>-walkthrough.spec.ts`,
Create `frontend/src/editor/core/tests/stubbed/<feature>-walkthrough.spec.ts`,
modeled on `files-page-screenshots.spec.ts`. For each enumerated view:
- stub the APIs it needs, drive the UI to that state, wait on a real locator
(not a fixed sleep), `await settle(page)` for Mantine portals, then
@@ -71,7 +71,7 @@ modeled on `files-page-screenshots.spec.ts`. For each enumerated view:
- Name shots `NN_<view>_<theme>.png` so light/dark pair up by suffix.
- Prefer **stable test-ids** over translated accessible names (RTL/i18n breaks text locators).
Run it: `cd frontend/editor && npx playwright test --project=stubbed <feature>-walkthrough.spec.ts`.
Run it: `cd frontend && npx playwright test --project=stubbed <feature>-walkthrough.spec.ts`.
Add `--project=stubbed-firefox`/`-webkit` only if cross-browser layout matters.
### 4. Build the report
+4 -4
View File
@@ -26,16 +26,16 @@ version_builds/
node_modules/
**/node_modules/
frontend/node_modules/
frontend/editor/dist/
frontend/editor/playwright-report/
frontend/dist/
frontend/playwright-report/
.npm/
.yarn/
# Tauri/desktop builds
src-tauri/target/
src-tauri/dist/
frontend/editor/src-tauri/target/
frontend/editor/src-tauri/dist/
frontend/src-tauri/target/
frontend/src-tauri/dist/
# IDE and editor
.idea/
+10 -10
View File
@@ -92,12 +92,12 @@ frontend: &frontend
# job on changes to any of these.
tauri: &tauri
- *ci
- frontend/editor/src-tauri/**
- frontend/editor/src/desktop/**
- frontend/editor/tsconfig.desktop.vite.json
- frontend/src-tauri/**
- frontend/src/editor/desktop/**
- frontend/tsconfig.desktop.vite.json
- frontend/package.json
- frontend/package-lock.json
- frontend/editor/vite.config.ts
- frontend/vite.config.ts
- .github/workflows/tauri-build.yml
- Taskfile.yml
- .taskfiles/desktop.yml
@@ -121,9 +121,9 @@ engine: &engine
generated-models: &generated-models
- *ci
- *openapi
- frontend/editor/scripts/generate-tool-api-types.mts
- frontend/editor/src/core/types/toolApiTypes.ts
- frontend/editor/src/core/types/toolIO.ts
- frontend/scripts/generate-tool-api-types.mts
- frontend/src/editor/core/types/toolApiTypes.ts
- frontend/src/editor/core/types/toolIO.ts
- engine/scripts/generate_tool_models.py
- engine/src/stirling/models/tool_models.py
- engine/src/stirling/models/tool_io.py
@@ -135,7 +135,7 @@ licenses-frontend: &licenses-frontend
- ".github/workflows/frontend-backend-licenses-update.yml"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/editor/scripts/generate-licenses.js"
- "frontend/scripts/generate-licenses.js"
licenses-backend: &licenses-backend
- ".github/workflows/frontend-backend-licenses-update.yml"
@@ -146,8 +146,8 @@ licenses-backend: &licenses-backend
proprietary: &proprietary
- *ci
- app/proprietary/**
- frontend/editor/src/proprietary/**
- frontend/editor/src/core/tests/enterprise/**
- frontend/src/editor/proprietary/**
- frontend/src/editor/core/tests/enterprise/**
- testing/compose/docker-compose-keycloak-oauth.yml
- testing/compose/docker-compose-keycloak-saml.yml
- testing/compose/keycloak-realm-oauth.json
+3 -3
View File
@@ -128,9 +128,9 @@ updates:
- package-ecosystem: cargo
directories:
- /frontend/editor/src-tauri
- /frontend/editor/src-tauri/thumbnail-handler
- /frontend/editor/src-tauri/provisioner
- /frontend/src-tauri
- /frontend/src-tauri/thumbnail-handler
- /frontend/src-tauri/provisioner
schedule:
interval: "weekly"
cooldown:
+3 -3
View File
@@ -51,7 +51,7 @@ labels:
- label: 'Translation'
files:
- 'frontend/editor/public/locales/[a-zA-Z]{2}-[a-zA-Z\-]{2,7}/translation.toml'
- 'frontend/public/locales/[a-zA-Z]{2}-[a-zA-Z\-]{2,7}/translation.toml'
- 'scripts/ignore_translation.toml'
- 'scripts/remove_translation_keys.sh'
- 'scripts/replace_translation_line.sh'
@@ -70,8 +70,8 @@ labels:
- label: 'Tauri'
files:
- 'frontend/editor/src-tauri/**'
- 'frontend/editor/src-tauri/.*'
- 'frontend/src-tauri/**'
- 'frontend/src-tauri/.*'
- label: 'engine'
files:
+2 -2
View File
@@ -13,7 +13,7 @@ Usage:
"""
# Sample for Windows:
# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-US/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-US/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
import argparse
import glob
@@ -308,7 +308,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-US/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-US/translation.toml)"
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-US/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-US/translation.toml)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
+1 -3
View File
@@ -14,9 +14,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
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/src-tauri/tauri.conf.json")
def load_pubkey():
@@ -211,10 +211,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+6 -10
View File
@@ -40,20 +40,16 @@ jobs:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+11 -1
View File
@@ -60,6 +60,16 @@ jobs:
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -284,7 +294,7 @@ jobs:
if: always()
working-directory: frontend
run: >
npx tsx editor/scripts/report-flaky-tests.mts
npx tsx scripts/report-flaky-tests.mts
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
+44 -9
View File
@@ -60,8 +60,43 @@ jobs:
with:
filters: .github/config/.files.yaml
build:
gradle-cache-prime:
name: Prime shared Gradle cache
needs: [files-changed]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Resolve backend dependencies
run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon
env:
STIRLING_FLAVOR: saas
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
build:
needs: [files-changed, gradle-cache-prime]
permissions:
actions: read
contents: read
@@ -76,7 +111,7 @@ jobs:
# works after Hibernate's ddl-auto=update migrates the schema. Gated on
# the `project` filter so doc-only PRs skip this ~5-minute job.
if: needs.files-changed.outputs.project == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/db-migration-test.yml
@@ -84,7 +119,7 @@ jobs:
check-generateOpenApiDocs:
if: needs.files-changed.outputs.openapi == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/check-openapi.yml
@@ -120,7 +155,7 @@ jobs:
playwright-e2e-live:
if: needs.files-changed.outputs.frontend == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/e2e-live.yml
@@ -128,7 +163,7 @@ jobs:
playwright-e2e-enterprise:
if: needs.files-changed.outputs.proprietary == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/build-enterprise.yml
@@ -136,7 +171,7 @@ jobs:
check-licence:
if: needs.files-changed.outputs.build == 'true'
needs: [files-changed, build]
needs: [files-changed, build, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/check-licence.yml
@@ -144,7 +179,7 @@ jobs:
docker-compose-tests:
if: needs.files-changed.outputs.project == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
actions: write
contents: read
@@ -156,7 +191,7 @@ jobs:
test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
needs: [files-changed, build, check-generateOpenApiDocs, check-licence]
needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime]
permissions:
contents: read
packages: read
@@ -199,7 +234,7 @@ jobs:
# frontend filter, so a CSS-only PR does not pay for a backend build.
generated-models:
if: needs.files-changed.outputs.generated-models == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
pull-requests: write
+9 -3
View File
@@ -42,10 +42,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.0
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+6 -10
View File
@@ -26,20 +26,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+6 -10
View File
@@ -27,20 +27,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+5 -5
View File
@@ -6,7 +6,7 @@ on:
pull_request_target:
types: [opened, synchronize, reopened]
paths:
- "frontend/editor/public/locales/*/translation.toml"
- "frontend/public/locales/*/translation.toml"
- ".github/scripts/check_language_toml.py"
- ".github/workflows/check_toml.yml"
@@ -76,7 +76,7 @@ jobs:
exit 1
fi
# Get changed files and filter for TOML translation files
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/editor/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
# Check if any files were found
if [ ! -s changed_files.txt ]; then
echo "No TOML translation files changed in this PR"
@@ -166,12 +166,12 @@ jobs:
// Determine reference file
let referenceFilePath;
if (changedFiles.includes("frontend/editor/public/locales/en-US/translation.toml")) {
if (changedFiles.includes("frontend/public/locales/en-US/translation.toml")) {
console.log("Using PR branch reference file.");
const { data: fileContent } = await github.rest.repos.getContent({
owner: prRepoOwner,
repo: prRepoName,
path: "frontend/editor/public/locales/en-US/translation.toml",
path: "frontend/public/locales/en-US/translation.toml",
ref: branch,
});
@@ -183,7 +183,7 @@ jobs:
const { data: fileContent } = await github.rest.repos.getContent({
owner: repoOwner,
repo: repoName,
path: "frontend/editor/public/locales/en-US/translation.toml",
path: "frontend/public/locales/en-US/translation.toml",
ref: "main",
});
+9 -13
View File
@@ -46,20 +46,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -81,7 +77,7 @@ jobs:
# Each lands as a sibling dir under coverage-execs/, with the .exec
# files preserving their original relative paths.
- name: Download all .exec artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: jacoco-exec-*
path: coverage-execs/
@@ -206,7 +202,7 @@ jobs:
# absence on backend-only runs by skipping the download entirely
# when the producer job was not part of this workflow run.
if: inputs.frontend-validation-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-coverage
path: matrix-inputs/vitest/
@@ -216,7 +212,7 @@ jobs:
# e2e-live uploads the artifact with a stable name. Skip the
# download entirely when the producer job did not run.
if: inputs.playwright-e2e-live-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: playwright-frontend-coverage
path: matrix-inputs/playwright/
+8 -12
View File
@@ -30,23 +30,19 @@ jobs:
java-version: 25
distribution: temurin
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
# No `-PnoSpotless` here yet because the upstream cache layer matches the
# backend build's; reuse keeps cold-cache cost identical.
# Keep the normal formatting path here so this smoke test exercises the
# same Gradle configuration as the backend build.
- name: Build Stirling-PDF JAR
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
+6 -10
View File
@@ -38,20 +38,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# When the PR changes the base image, test.sh builds it locally
# (stirling-pdf-base:local) into the daemon image store. A buildx
+10 -15
View File
@@ -25,21 +25,16 @@ jobs:
with:
java-version: "25"
distribution: "temurin"
# Same cache layer as backend-build.yml. Without it every run resolved the
# whole classpath cold and eventually got HTTP 429 from Maven Central.
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# Gradle does not retry 429s, and a cold cache resolving the buildscript
# classpath is exactly where Maven Central rate-limits us. Retry it here,
# where a failure is cheap, instead of inside the backgrounded bootRun.
@@ -98,7 +93,7 @@ jobs:
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
run: npx tsx scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Generate JaCoCo report from e2e:live .exec
@@ -235,8 +230,8 @@ jobs:
run: |
echo "::group::Playwright output dirs"
# Playwright anchors its default outputDir + HTML report to the
# nearest package.json, which is frontend/ (frontend/editor has
# none), so artifacts land under frontend/, not frontend/editor/.
# nearest package.json, which is frontend/ (frontend has
# none), so artifacts land under frontend/, not frontend/.
ls -la frontend/playwright-report 2>/dev/null \
|| echo "no playwright-report at frontend/"
ls -la frontend/test-results 2>/dev/null \
@@ -251,7 +246,7 @@ jobs:
# test-results/ holds the per-test trace.zip (with browser console
# logs) + screenshots/video; playwright-report/ is the HTML report.
# Both live under frontend/ (Playwright anchors them to the nearest
# package.json, which is frontend/; frontend/editor has none).
# package.json, which is frontend/; frontend has none).
path: |
frontend/playwright-report/
frontend/test-results/
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
run: npx tsx scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Upload Playwright report
@@ -113,31 +113,31 @@ jobs:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
working-directory: frontend
run: |
mkdir -p editor/src/assets
npx --yes license-report --only=prod --output=json > editor/src/assets/3rdPartyLicenses.json
mkdir -p src/assets
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
- name: Postprocess with project script (BASE version)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
env:
PR_IS_FORK: "true"
run: |
node base/frontend/editor/scripts/generate-licenses.js \
--input frontend/editor/src/assets/3rdPartyLicenses.json
node base/frontend/scripts/generate-licenses.js \
--input frontend/src/assets/3rdPartyLicenses.json
- name: Copy postprocessed artifacts back (fork PRs)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
run: |
mkdir -p frontend/editor/src/assets
if [ -f "base/frontend/editor/src/assets/3rdPartyLicenses.json" ]; then
cp base/frontend/editor/src/assets/3rdPartyLicenses.json frontend/editor/src/assets/3rdPartyLicenses.json
mkdir -p frontend/src/assets
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
fi
if [ -f "base/frontend/editor/src/assets/license-warnings.json" ]; then
cp base/frontend/editor/src/assets/license-warnings.json frontend/editor/src/assets/license-warnings.json
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
fi
- name: Check for license warnings
run: |
if [ -f "frontend/editor/src/assets/license-warnings.json" ]; then
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
else
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
@@ -185,10 +185,10 @@ jobs:
echo ""
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
echo "❌ **Failed** incompatible or unknown licenses found."
if [ -f "frontend/editor/src/assets/license-warnings.json" ]; then
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo ""
echo "### Warnings"
jq -r '.warnings[] | "- \(.message)"' frontend/editor/src/assets/license-warnings.json || true
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
fi
else
echo "✅ **Passed** no license warnings detected."
@@ -214,7 +214,7 @@ jobs:
const fs = require('fs');
let warningDetails = '';
try {
const warnings = JSON.parse(fs.readFileSync('frontend/editor/src/assets/license-warnings.json', 'utf8'));
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
} catch (e) {
warningDetails = 'Unable to read warning details';
@@ -254,7 +254,7 @@ jobs:
- name: Commit changes (Push only)
if: github.event_name == 'push'
run: |
git add frontend/editor/src/assets/3rdPartyLicenses.json
git add frontend/src/assets/3rdPartyLicenses.json
# Note: Do NOT commit license-warnings.json - it's only for PR review
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
@@ -272,7 +272,7 @@ jobs:
The following licenses may require review for corporate compatibility:
$(cat frontend/editor/src/assets/license-warnings.json | jq -r '.warnings[].message')
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
Please review these licenses to ensure they are acceptable for your use case."
fi
@@ -350,10 +350,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+3 -3
View File
@@ -130,19 +130,19 @@ jobs:
run: |
python scripts/coverage-summary.py \
--title "Frontend Vitest coverage" \
--vitest frontend/editor/coverage/coverage-summary.json \
--vitest frontend/coverage/coverage-summary.json \
--github-step-summary
- name: Upload vitest coverage report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-coverage
path: frontend/editor/coverage/
path: frontend/coverage/
retention-days: 7
if-no-files-found: warn
- name: Upload frontend build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-build
path: frontend/editor/dist/
path: frontend/dist/
retention-days: 3
+136 -36
View File
@@ -12,13 +12,14 @@ on:
- "true"
- "false"
platform:
description: "Platform to build (windows, macos, linux, or all)"
description: "Platform to build (windows, windows-arm64, macos, linux, or all)"
required: true
default: "all"
type: choice
options:
- all
- windows
- windows-arm64
- macos
- linux
sign:
@@ -56,20 +57,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependencies
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -87,24 +84,35 @@ jobs:
- name: Determine build matrix
id: set-matrix
run: |
# windows-arm64: NSIS only (WiX MSI has no arm64 support in Tauri) and no
# JPDFium natives yet - flip to windows-arm64 once JPDFium ships them.
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}'
WINDOWS_ARM64='{"platform":"windows-11-arm","args":"--target aarch64-pc-windows-msvc --bundles nsis","name":"windows-arm64","jpdfium_platforms":"none"}'
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}'
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}'
ALL="$WINDOWS,$WINDOWS_ARM64,$MACOS,$LINUX"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
case "${{ github.event.inputs.platform }}" in
"windows")
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}]}' >> $GITHUB_OUTPUT
echo "matrix={\"include\":[$WINDOWS,$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT
;;
"windows-arm64")
echo "matrix={\"include\":[$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}]}' >> $GITHUB_OUTPUT
echo "matrix={\"include\":[$MACOS]}" >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
echo "matrix={\"include\":[$LINUX]}" >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT
;;
esac
else
# For push/release events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT
fi
build-jars:
@@ -139,10 +147,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -236,16 +250,23 @@ jobs:
if: matrix.platform == 'macos-15'
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -271,7 +292,7 @@ jobs:
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -281,7 +302,7 @@ jobs:
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
@@ -316,7 +337,7 @@ jobs:
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
@@ -384,7 +405,7 @@ jobs:
# Without this, signCommand failures are opaque (Tauri captures but drops
# smctl's stderr) - running these loudly surfaces auth/env/keypair issues.
- name: Preflight smctl
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
shell: pwsh
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -415,12 +436,12 @@ jobs:
# smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD
# from env (set by prior DigiCert setup step). No --config-file needed.
- name: Configure Windows code signing
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
shell: bash
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
run: |
cat > ./frontend/editor/src-tauri/tauri.windows.conf.json <<EOF
cat > ./frontend/src-tauri/tauri.windows.conf.json <<EOF
{
"bundle": {
"windows": {
@@ -433,7 +454,7 @@ jobs:
}
EOF
echo "Generated tauri.windows.conf.json (alias masked):"
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/src-tauri/tauri.windows.conf.json
- name: Import release GPG signing key (Linux)
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
@@ -481,11 +502,59 @@ jobs:
SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}
CI: true
with:
projectPath: ./frontend/editor
projectPath: ./frontend
tauriScript: npx tauri
args: ${{ matrix.args }}
updaterJsonKeepUniversal: true
# Bundled libwayland conflicts with the host's on some distros (Fedora
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). Repack without it,
# then regenerate the updater .sig (repack invalidates the original) and
# GPG-sign again when release signing is on.
- name: Strip bundled Wayland libs from AppImage
if: matrix.platform == 'ubuntu-22.04'
continue-on-error: true
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
run: |
set -euo pipefail
AI=$(find "$PWD/frontend/src-tauri/target" -name "*.AppImage" | head -1)
if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi
chmod +x "$AI"
WORK=$(mktemp -d)
(cd "$WORK" && "$AI" --appimage-extract >/dev/null)
if ! ls "$WORK/squashfs-root/usr/lib/"libwayland-* >/dev/null 2>&1; then
echo "No bundled libwayland - nothing to strip"
rm -rf "$WORK"
exit 0
fi
rm -f "$WORK/squashfs-root/usr/lib/"libwayland-*
curl -fsSL -o "$WORK/appimagetool" \
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
# Pinned checksum: never execute an unverified downloaded binary. On
# mismatch (upstream rebuilt continuous) the step aborts and the
# original AppImage ships unchanged - update the pin deliberately.
echo "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 $WORK/appimagetool" | sha256sum -c -
chmod +x "$WORK/appimagetool"
SIGN_ARGS=()
if [ "$GPG_SIGN" = "1" ] && [ -n "${SIGN_KEY:-}" ]; then
SIGN_ARGS=(--sign --sign-key "$SIGN_KEY")
fi
"$WORK/appimagetool" --appimage-extract-and-run "${SIGN_ARGS[@]}" "$WORK/squashfs-root" "$AI.new"
# Updater payload signature must match the repacked bytes. The CLI
# reads the key/password from env - never pass secrets as argv.
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
(cd frontend && npx tauri signer sign "$AI.new")
mv "$AI.new.sig" "$AI.sig"
fi
mv "$AI.new" "$AI"
rm -rf "$WORK"
echo "Stripped bundled libwayland from $(basename "$AI")"
- name: Clear release GPG key from runner keyring (Linux)
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
env:
@@ -502,14 +571,35 @@ jobs:
# artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw
# cargo output unsigned, so checking it produces false negatives.
- name: Verify Windows Code Signature
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
timeout-minutes: 15
shell: pwsh
run: |
# arm64 ships an NSIS installer, not an MSI. Tauri's signCommand signs the
# inner exe before packing and the setup exe after, so verifying the setup
# exe is the arm64 equivalent of the MSI + inner-exe check below.
if ("${{ matrix.platform }}" -eq "windows-11-arm") {
$setupExes = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*-setup.exe" -Recurse -File
if ($setupExes.Count -eq 0) {
Write-Host "[ERROR] No NSIS installer found under target/"
exit 1
}
foreach ($exe in $setupExes) {
$sig = Get-AuthenticodeSignature -FilePath $exe.FullName
Write-Host "NSIS installer: $($exe.Name) Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)"
if ($sig.Status -ne "Valid") {
Write-Host "[ERROR] NSIS installer is not signed"
exit 1
}
}
Write-Host "[SUCCESS] NSIS installer is properly signed"
exit 0
}
$allSigned = $true
# Check MSI installer (outer wrapper - what users download)
$msiFiles = Get-ChildItem -Path "./frontend/editor/src-tauri/target" -Filter "*.msi" -Recurse -File
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
if ($msiFiles.Count -eq 0) {
Write-Host "[ERROR] No MSI found under target/"
exit 1
@@ -569,7 +659,7 @@ jobs:
# but drops stderr when the command exits non-zero, making failures opaque.
# The real errors live in smctl's log files - surface them here for debugging.
- name: Dump smctl logs on failure
if: ${{ failure() && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
if: ${{ failure() && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' }}
shell: pwsh
run: |
$logDir = "$env:USERPROFILE\.signingmanager\logs"
@@ -592,7 +682,7 @@ jobs:
# Absolute dist path so the cd below can't break the copy targets.
DIST="$GITHUB_WORKSPACE/dist"
mkdir -p "$DIST"
cd ./frontend/editor/src-tauri/target
cd ./frontend/src-tauri/target
echo "=== tauri bundle artifacts ==="
find . -path "*/bundle/*" \( -name "*.msi" -o -name "*.deb" \
@@ -605,6 +695,11 @@ jobs:
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
find . -name "*.msi.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.sig" \;
elif [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
# arm64 ships the NSIS installer (WiX MSI has no arm64 support in Tauri).
# The setup exe is also its own updater payload (-> sibling .sig).
find . -name "*-setup.exe" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe" \;
find . -name "*-setup.exe.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe.sig" \;
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
# DMG = manual install; .app.tar.gz (+ .sig) = updater payload.
# Raw .app is intentionally not shipped (hundreds of MB of uncompressed input).
@@ -648,7 +743,7 @@ jobs:
with:
sparse-checkout: |
.github/scripts/verify-updater-signatures.py
frontend/editor/src-tauri/tauri.conf.json
frontend/src-tauri/tauri.conf.json
sparse-checkout-cone-mode: false
- name: Download all Tauri artifacts
@@ -719,6 +814,10 @@ jobs:
'bundles': ['Stirling-PDF-windows-x86_64.msi'],
'targets': ['windows-x86_64-msi', 'windows-x86_64'],
},
{
'bundles': ['Stirling-PDF-windows-arm64-setup.exe'],
'targets': ['windows-aarch64-nsis', 'windows-aarch64'],
},
{
'bundles': ['Stirling-PDF-macos-universal.app.tar.gz'],
'targets': ['darwin-x86_64', 'darwin-aarch64'],
@@ -793,7 +892,7 @@ jobs:
run: |
python3 -m pip install --quiet 'cryptography==44.0.0'
python3 .github/scripts/verify-updater-signatures.py \
./artifacts/tauri frontend/editor/src-tauri/tauri.conf.json
./artifacts/tauri frontend/src-tauri/tauri.conf.json
# workflow_dispatch path requires platform=='all' so a single-platform
# dispatch can't overwrite an existing release's full latest.json with a
@@ -803,7 +902,7 @@ jobs:
# instead of silently shipping a broken auto-update.
- name: Upload binaries to Release
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
# Don't regenerate/append notes on re-runs, and don't force this into the
@@ -817,6 +916,7 @@ jobs:
files: |
./artifacts/**/*.jar
./artifacts/**/*.msi
./artifacts/**/*-setup.exe
./artifacts/**/*.dmg
./artifacts/**/*.app.tar.gz
./artifacts/**/*.deb
+4 -8
View File
@@ -65,20 +65,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependencies
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Docker Buildx
id: buildx
+9 -3
View File
@@ -39,10 +39,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+2 -2
View File
@@ -84,12 +84,12 @@ jobs:
body: |
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot].
Regenerates `frontend/editor/src/portal/generated/docsManifest.json`
Regenerates `frontend/src/processor/proprietary/generated/docsManifest.json`
from the Stirling docs repo via `npm run docs:sync`.
labels: |
Documentation
github-actions
Front End
add-paths: frontend/editor/src/portal/generated/docsManifest.json
add-paths: frontend/src/processor/proprietary/generated/docsManifest.json
delete-branch: true
sign-commits: true
+5 -5
View File
@@ -12,7 +12,7 @@ on:
- "app/proprietary/build.gradle"
- "gradle/spotless.gradle"
- "README.md"
- "frontend/editor/public/locales/*/translation.toml"
- "frontend/public/locales/*/translation.toml"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
@@ -72,7 +72,7 @@ jobs:
- 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
python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-US/translation.toml" --branch main
- name: Sort translation TOML files
run: |
@@ -80,7 +80,7 @@ jobs:
- name: Commit translation files
run: |
git add frontend/editor/public/locales/*/translation.toml
git add frontend/public/locales/*/translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
- name: Sync README.md
@@ -110,7 +110,7 @@ jobs:
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
#### **1. Synchronization of Translation Files**
- Updated translation files (`frontend/editor/public/locales/*/translation.toml`) to reflect changes in the reference file `en-US/translation.toml`.
- Updated translation files (`frontend/public/locales/*/translation.toml`) to reflect changes in the reference file `en-US/translation.toml`.
- Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations.
- **Format**: TOML
@@ -135,5 +135,5 @@ jobs:
sign-commits: true
add-paths: |
README.md
frontend/editor/public/locales/*/translation.toml
frontend/public/locales/*/translation.toml
scripts/ignore_translation.toml
+91 -25
View File
@@ -12,7 +12,7 @@ on:
workflow_call:
inputs:
platform:
description: "Platform to build (windows, macos, linux, windows-macos, or all)."
description: "Platform to build (windows, windows-arm64, macos, linux, windows-macos, or all)."
required: false
type: string
default: "all"
@@ -29,13 +29,14 @@ on:
workflow_dispatch:
inputs:
platform:
description: "Platform to build (windows, macos, linux, windows-macos, or all)"
description: "Platform to build (windows, windows-arm64, macos, linux, windows-macos, or all)"
required: true
default: "all"
type: choice
options:
- all
- windows
- windows-arm64
- macos
- linux
- windows-macos
@@ -73,15 +74,19 @@ jobs:
PLATFORM: ${{ inputs.platform }}
run: |
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}'
# ARM64: NSIS only (WiX MSI has no arm64 support in Tauri) and no JPDFium
# natives yet - flip jpdfium_platforms to windows-arm64 once JPDFium ships it.
WINDOWS_ARM64='{"platform":"windows-11-arm","args":"--target aarch64-pc-windows-msvc --bundles nsis","name":"windows-arm64","jpdfium_platforms":"none"}'
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}'
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}'
case "$PLATFORM" in
windows) ENTRIES=("$WINDOWS") ;;
windows) ENTRIES=("$WINDOWS" "$WINDOWS_ARM64") ;;
windows-arm64) ENTRIES=("$WINDOWS_ARM64") ;;
macos) ENTRIES=("$MACOS") ;;
linux) ENTRIES=("$LINUX") ;;
windows-macos) ENTRIES=("$WINDOWS" "$MACOS") ;;
*) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;;
*) ENTRIES=("$WINDOWS" "$WINDOWS_ARM64" "$MACOS" "$LINUX") ;;
esac
# Drop macOS entries when Apple certificate secret is unavailable
@@ -147,7 +152,7 @@ jobs:
- name: Cache Rust build
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: frontend/editor/src-tauri
workspaces: frontend/src-tauri
# Stable key shared across workflows so the nightly warmer.
# rust-cache still appends OS + rustc + Cargo.lock.
shared-key: tauri-${{ matrix.name }}
@@ -167,16 +172,23 @@ jobs:
if: matrix.platform == 'macos-15'
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -203,7 +215,7 @@ jobs:
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -213,7 +225,7 @@ jobs:
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
@@ -248,7 +260,7 @@ jobs:
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
@@ -323,7 +335,7 @@ jobs:
ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Preflight smctl
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -336,12 +348,12 @@ jobs:
if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" }
- name: Configure Windows code signing
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: bash
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
run: |
cat > ./frontend/editor/src-tauri/tauri.windows.conf.json <<EOF
cat > ./frontend/src-tauri/tauri.windows.conf.json <<EOF
{
"bundle": {
"windows": {
@@ -400,7 +412,7 @@ jobs:
SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}
CI: true
with:
projectPath: ./frontend/editor
projectPath: ./frontend
tauriScript: npx tauri
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
# AppImage runs in its own continue-on-error step below so its
@@ -420,7 +432,7 @@ jobs:
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
CI: true
with:
projectPath: ./frontend/editor
projectPath: ./frontend
tauriScript: npx tauri
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
# AppImage runs in its own continue-on-error step below so its
@@ -448,10 +460,41 @@ jobs:
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
CI: true
with:
projectPath: ./frontend/editor
projectPath: ./frontend
tauriScript: npx tauri
args: --bundles appimage
# Bundled libwayland conflicts with the host's on some distros (Fedora
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). The AppImage
# ecosystem excludelist agrees these libs must come from the system.
- name: Strip bundled Wayland libs from AppImage
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
continue-on-error: true
run: |
set -euo pipefail
AI=$(find "$PWD/frontend/src-tauri/target" -name "*.AppImage" | head -1)
if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi
chmod +x "$AI"
WORK=$(mktemp -d)
(cd "$WORK" && "$AI" --appimage-extract >/dev/null)
if ! ls "$WORK/squashfs-root/usr/lib/"libwayland-* >/dev/null 2>&1; then
echo "No bundled libwayland - nothing to strip"
rm -rf "$WORK"
exit 0
fi
rm -f "$WORK/squashfs-root/usr/lib/"libwayland-*
curl -fsSL -o "$WORK/appimagetool" \
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
# Pinned checksum: never execute an unverified downloaded binary. On
# mismatch (upstream rebuilt continuous) the step aborts and the
# original AppImage ships unchanged - update the pin deliberately.
echo "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 $WORK/appimagetool" | sha256sum -c -
chmod +x "$WORK/appimagetool"
"$WORK/appimagetool" --appimage-extract-and-run "$WORK/squashfs-root" "$AI.new"
mv "$AI.new" "$AI"
rm -rf "$WORK"
echo "Stripped bundled libwayland from $(basename "$AI")"
- name: Clear release GPG key from runner keyring (Linux)
if: always() && inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
env:
@@ -466,7 +509,7 @@ jobs:
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/editor/src-tauri/target
cd ./frontend/src-tauri/target
DMG_FILE=$(find . -name "*.dmg" | head -1)
if [ -n "$DMG_FILE" ]; then
echo "Found DMG: $DMG_FILE"
@@ -483,13 +526,16 @@ jobs:
# Absolute dist path so the cd below can't break the copy targets.
DIST="$GITHUB_WORKSPACE/dist"
mkdir -p "$DIST"
cd ./frontend/editor/src-tauri/target
cd ./frontend/src-tauri/target
# Find and rename artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
# Only ship the MSI installer. The loose exe and WiX toolset exes
# are not the user-facing installer - the MSI contains the signed inner exe.
find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
# arm64 ships the NSIS installer (WiX MSI has no arm64 support in Tauri).
find . -name "*-setup.exe" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe" \;
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \;
else
@@ -501,9 +547,28 @@ jobs:
# Verify the MSI AND the inner exe extracted from it are signed.
# The inner exe is what gets installed on users' machines and what AV scans.
- name: Verify Windows Code Signature
if: inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
if: inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
shell: pwsh
run: |
# arm64 ships an NSIS installer, not an MSI. Tauri's signCommand signs the
# inner exe before packing and the setup exe after, so verifying the setup
# exe is the arm64 equivalent of the MSI + inner-exe check below.
if ("${{ matrix.platform }}" -eq "windows-11-arm") {
$exePath = "./dist/Stirling-PDF-${{ matrix.name }}-setup.exe"
if (-not (Test-Path $exePath)) {
Write-Host "[ERROR] NSIS installer not found at $exePath"
exit 1
}
$sig = Get-AuthenticodeSignature -FilePath $exePath
Write-Host "NSIS installer: Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)"
if ($sig.Status -ne "Valid") {
Write-Host "[ERROR] NSIS installer is not signed"
exit 1
}
Write-Host "[SUCCESS] NSIS installer is properly signed"
exit 0
}
$allSigned = $true
$msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi"
@@ -549,7 +614,7 @@ jobs:
Write-Host "[SUCCESS] MSI and inner exe are properly signed"
- name: Dump smctl logs on failure
if: ${{ failure() && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
if: ${{ failure() && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' }}
shell: pwsh
run: |
$logDir = "$env:USERPROFILE\.signingmanager\logs"
@@ -573,10 +638,10 @@ jobs:
- name: Verify build artifacts
shell: bash
run: |
cd ./frontend/editor/src-tauri/target
cd ./frontend/src-tauri/target
# Check for expected artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
if [ "${{ matrix.platform }}" = "windows-latest" ] || [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
echo "Checking for Windows artifacts..."
find . -name "*.exe" -o -name "*.msi" | head -5
if [ $(find . -name "*.exe" | wc -l) -eq 0 ]; then
@@ -604,7 +669,7 @@ jobs:
- name: Test artifact sizes
shell: bash
run: |
cd ./frontend/editor/src-tauri/target
cd ./frontend/src-tauri/target
echo "Artifact sizes for ${{ matrix.name }}:"
find . -name "*.exe" -o -name "*.dmg" -o -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.msi" | while read file; do
if [ -f "$file" ]; then
@@ -655,6 +720,7 @@ jobs:
// Map of expected artifact names to display info
const artifactMap = {
'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
'Stirling-PDF-windows-arm64': { icon: '🪟', platform: 'Windows ARM64', files: '-setup.exe (NSIS)' },
'Stirling-PDF-macos-universal': { icon: '🍎', platform: 'macOS Universal', files: '.dmg' },
'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .rpm, .AppImage' }
};
+6 -10
View File
@@ -84,20 +84,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+9 -3
View File
@@ -38,10 +38,16 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Build with Gradle
run: ./gradlew build
+7 -4
View File
@@ -22,6 +22,9 @@ pipeline/
customFiles/
configs/
watchedFolders/
# The rule above targets the app's runtime watched-folders working dir, but it
# also matches this frontend source component dir; keep the source tracked.
!frontend/src/editor/proprietary/components/watchedFolders/
clientWebUI/
# Scratch dir used by local fixture-regeneration runs (see
# app/proprietary/src/test/resources/db-migration-fixtures/README.md).
@@ -171,7 +174,7 @@ app/proprietary/build
common/build
proprietary/build
stirling-pdf/build
frontend/editor/src-tauri/provisioner/target
frontend/src-tauri/provisioner/target
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -222,7 +225,7 @@ out/
*.asc
# Allow test fixture certificates (synthetic, no real credentials)
!frontend/editor/src/core/tests/test-fixtures/certs/**
!frontend/src/editor/core/tests/test-fixtures/certs/**
# SSH Keys
*.pub
@@ -264,7 +267,7 @@ node_modules/
*compact*.json
test_batch.json
*.backup.*.json
frontend/editor/public/locales/*/translation.backup*.json
frontend/public/locales/*/translation.backup*.json
# Development/build artifacts
.gradle-cache/
@@ -289,4 +292,4 @@ docs/type3/signatures/
*.playwright-mcp.png
# Local screenshot artifacts from *-screenshots.spec.ts
frontend/editor/screenshots/
frontend/screenshots/
+2 -2
View File
@@ -15,10 +15,10 @@ testing/compose/validate-mcp-test.sh:curl-auth-header:92
testing/compose/validate-mcp-test.sh:curl-auth-header:116
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
frontend/src/editor/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api-key:30
frontend/src/processor/proprietary/components/docs/GettingStartedSection.tsx:generic-api-key:30
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"ignoredFiles": [
"frontend/editor/src-tauri/icons/icon.png"
"frontend/src-tauri/icons/icon.png"
]
}
+1
View File
@@ -146,6 +146,7 @@ tasks:
swagger:
desc: "Generate OpenAPI docs"
run: once
cmds:
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:copySwaggerDoc"
platforms: [windows]
+8 -18
View File
@@ -22,6 +22,7 @@ vars:
linux-amd64) echo "linux-x64";;
linux-arm64) echo "linux-arm64";;
windows-amd64) echo "windows-x64";;
windows-arm64) echo "none";; # no JPDFium windows-arm64 natives published yet
*) echo "all";;
esac
fi
@@ -38,7 +39,6 @@ tasks:
provisioner:
desc: "Build installer provisioner"
platforms: [windows]
dir: editor
cmds:
- node scripts/build-provisioner.mjs
@@ -46,55 +46,48 @@ tasks:
desc: "Start Tauri desktop dev mode"
deps: [prepare]
ignore_error: true
dir: editor
cmds:
- npx tauri dev --no-watch
build:
desc: "Build Tauri desktop app (production)"
deps: [prepare]
dir: editor
cmds:
- npx tauri build
build:dev:
desc: "Build Tauri desktop app (dev, no bundling)"
deps: [prepare]
dir: editor
cmds:
- npx tauri build --no-bundle
build:dev:mac:
desc: "Build Tauri desktop .app bundle (macOS)"
deps: [prepare]
dir: editor
cmds:
- npx tauri build --bundles app --config '{"bundle":{"createUpdaterArtifacts":false}}'
build:dev:windows:
desc: "Build Tauri desktop NSIS installer (Windows)"
deps: [prepare]
dir: editor
cmds:
- npx tauri build --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":false}}'
build:dev:linux:
desc: "Build Tauri desktop AppImage (Linux)"
deps: [prepare]
dir: editor
cmds:
- npx tauri build --bundles appimage --config '{"bundle":{"createUpdaterArtifacts":false}}'
test:
desc: "Run Tauri/Cargo tests"
deps: [prepare]
dir: editor/src-tauri
dir: src-tauri
cmds:
- cargo test
clean:
desc: "Clean Tauri/Cargo build artifacts"
dir: editor
cmds:
- task: jlink:clean
- cd src-tauri && cargo clean
@@ -116,7 +109,6 @@ tasks:
jlink:verify:
desc: "Fail the build if the bundled JRE is older than the app JAR requires"
dir: editor
env:
REQUIRED_JAVA: "{{.REQUIRED_JAVA}}"
cmds:
@@ -134,15 +126,15 @@ tasks:
platforms: [windows]
- cmd: ./gradlew bootJar --no-daemon -PjpdfiumPlatforms={{.JPDFIUM_PLATFORMS}}
platforms: [linux, darwin]
- mkdir -p frontend/editor/src-tauri/libs
- cp app/core/build/libs/stirling-pdf-*.jar frontend/editor/src-tauri/libs/
- mkdir -p frontend/src-tauri/libs
- cp app/core/build/libs/stirling-pdf-*.jar frontend/src-tauri/libs/
status:
- test -f frontend/editor/src-tauri/libs/stirling-pdf-*.jar
- test -f frontend/src-tauri/libs/stirling-pdf-*.jar
jlink:runtime:
desc: "Create custom JRE with jlink"
deps: [jlink:jar]
dir: editor/src-tauri
dir: src-tauri
cmds:
- rm -rf runtime/jre
- mkdir -p runtime
@@ -194,15 +186,14 @@ tasks:
# `desktop:build` run `jlink:clean` first to force a fresh build.
- cmd: chmod -R u+w runtime/jre
platforms: [linux, darwin]
# Escape the $ so Task's shell doesn't expand $_/$false to empty before PowerShell sees them.
- cmd: powershell -NoProfile -Command "Get-ChildItem -Recurse runtime/jre | ForEach-Object { \$_.IsReadOnly = \$false }"
- cmd: powershell -NoProfile -Command "Get-ChildItem -Recurse runtime/jre | ForEach-Object { $_.IsReadOnly = $false }"
platforms: [windows]
status:
- test -f runtime/jre/release
jlink:clean:
desc: "Remove JLink runtime and bundled JARs"
dir: editor/src-tauri
dir: src-tauri
cmds:
- rm -rf libs runtime
@@ -215,7 +206,6 @@ tasks:
desc: "Create universal (arm64+x86_64) JRE for the macOS Tauri build"
deps: [jlink:jar]
platforms: [darwin]
dir: editor
env:
JLINK_MODULES: "{{.JLINK_MODULES}}"
OUTPUT_DIR: src-tauri/runtime/jre
+5 -5
View File
@@ -3,14 +3,14 @@ version: '3'
tasks:
install:
desc: "Install Playwright browsers"
dir: frontend/editor
dir: frontend
deps: [ ':frontend:install' ]
cmds:
- npx playwright install {{.CLI_ARGS}} --with-deps
stubbed:
desc: "Run stubbed E2E tests"
dir: frontend/editor
dir: frontend
deps: [ ':frontend:prepare' ]
cmds:
- npx playwright test --project=stubbed {{.CLI_ARGS}}
@@ -82,7 +82,7 @@ tasks:
live:runner:
internal: true
deps: [ ':frontend:prepare' ]
dir: frontend/editor
dir: frontend
vars:
BASE_DIR: '{{.ROOT_DIR}}/.test-state/playwright'
cmds:
@@ -151,14 +151,14 @@ tasks:
up first with:
task e2e:oauth:up
task e2e:saml:up
dir: frontend/editor
dir: frontend
deps: [ ':frontend:prepare' ]
cmds:
- npx playwright test --project=enterprise {{.CLI_ARGS}}
cross-browser:
desc: "Run stubbed E2E tests on Chromium, Firefox, and WebKit"
dir: frontend/editor
dir: frontend
deps: [ ':frontend:prepare' ]
cmds:
- npx playwright test --project=stubbed --project=stubbed-firefox --project=stubbed-webkit {{.CLI_ARGS}}
+49 -49
View File
@@ -3,7 +3,7 @@ version: '3'
# Tasks operate from the workspace root (frontend/). Editor commands pass
# `editor` as the vite project root (positional after `build` / before the
# mode flag) or use `--project editor/...` for tsc — so the editor lives
# under frontend/editor/ without each task needing a cd.
# under frontend/ without each task needing a cd.
tasks:
install:
@@ -26,34 +26,34 @@ tasks:
vars:
MODE: '{{.MODE | default ""}}'
cmds:
- npx tsx editor/scripts/setup-env.mts{{if .MODE}} --{{.MODE}}{{end}}
- npx tsx scripts/setup-env.mts{{if .MODE}} --{{.MODE}}{{end}}
sources:
- editor/scripts/setup-env.mts
- scripts/setup-env.mts
generates:
- editor/.env.local
- editor/.env{{if .MODE}}.{{.MODE}}{{end}}.local
- .env.local
- .env{{if .MODE}}.{{.MODE}}{{end}}.local
prepare:icons:
internal: true
run: once
deps: [install]
cmds:
- node editor/scripts/generate-icons.js
- node scripts/generate-icons.js
prepare:og:
internal: true
run: when_changed
desc: "Regenerate OG/social-preview metadata from the tool registry"
cmds:
- node editor/scripts/generate-og-metadata.mjs
- node scripts/generate-og-metadata.mjs
sources:
- editor/src/core/types/toolId.ts
- editor/src/core/utils/urlMapping.ts
- editor/src/core/data/useTranslatedToolRegistry.tsx
- editor/public/og_images/*.png
- src/editor/core/types/toolId.ts
- src/editor/core/utils/urlMapping.ts
- src/editor/core/data/useTranslatedToolRegistry.tsx
- public/og_images/*.png
generates:
- editor/src/core/data/ogImageMap.json
- editor/public/og-metadata.json
- src/editor/core/data/ogImageMap.json
- public/og-metadata.json
prepare:
desc: "Set up dev environment"
@@ -88,7 +88,7 @@ tasks:
sh: >-
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
cmds:
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
- npx vite --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
dev:
desc: "Start frontend dev server"
@@ -143,13 +143,13 @@ tasks:
desc: "Production build (default mode)"
deps: [prepare]
cmds:
- npx vite build editor
- npx vite build
build:core:
desc: "Build for core mode"
deps: [prepare]
cmds:
- npx vite build editor --mode core
- npx vite build --mode core
build:proprietary:
desc: "Build for proprietary mode"
@@ -157,7 +157,7 @@ tasks:
vars:
PREVIEW: '{{.PREVIEW | default ""}}'
cmds:
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary'
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build --mode proprietary'
build:saas:
desc: "Build for SaaS mode"
@@ -165,7 +165,7 @@ tasks:
- task: prepare
vars: { MODE: saas }
cmds:
- npx vite build editor --mode saas
- npx vite build --mode saas
build:desktop:
desc: "Build for desktop mode"
@@ -173,13 +173,13 @@ tasks:
- task: prepare
vars: { MODE: desktop }
cmds:
- npx vite build editor --mode desktop
- npx vite build --mode desktop
build:prototypes:
desc: "Build for prototypes mode"
deps: [prepare]
cmds:
- npx vite build editor --mode prototypes
- npx vite build --mode prototypes
storybook:
@@ -272,23 +272,23 @@ tasks:
# Covers the whole editor tree, including the portal/processor layer and
# public/css. Vendored CSS and build output are excluded via ignoreFiles
# in stylelint.config.mjs.
- npx stylelint "editor/**/*.css"
- npx stylelint "src/**/*.css"
lint:colors:
desc: "Enforce theme tokens — no hardcoded colours or raw primitives in components"
aliases: [lint:colours]
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs
- node editor/scripts/lint/theme-lint.mjs css-colors
- node editor/scripts/lint/theme-lint.mjs code-colors
- node editor/scripts/lint/theme-lint.mjs no-primitives
- node scripts/lint/theme-lint.mjs
- node scripts/lint/theme-lint.mjs css-colors
- node scripts/lint/theme-lint.mjs code-colors
- node scripts/lint/theme-lint.mjs no-primitives
contrast:
desc: "Report low-contrast theme token pairs (warning only, never blocks)"
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs contrast
- node scripts/lint/theme-lint.mjs contrast
lint:eslint:
desc: "Run ESLint linting"
@@ -302,7 +302,7 @@ tasks:
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
# shell-agnostic. Covers the whole editor tree, including the portal layer.
- npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
- npx dpdm "src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
lint:fix:
desc: "Auto-fix lint issues"
@@ -343,14 +343,14 @@ tasks:
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/core/tsconfig.json }
vars: { PROJECT: src/editor/core/tsconfig.json }
typecheck:proprietary:
desc: "Typecheck proprietary build variant"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/proprietary/tsconfig.json }
vars: { PROJECT: src/editor/proprietary/tsconfig.json }
typecheck:saas:
desc: "Typecheck SaaS build variant"
@@ -359,7 +359,7 @@ tasks:
vars: { MODE: saas }
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/saas/tsconfig.json }
vars: { PROJECT: src/editor/saas/tsconfig.json }
typecheck:desktop:
desc: "Typecheck desktop build variant"
@@ -368,35 +368,35 @@ tasks:
vars: { MODE: desktop }
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/desktop/tsconfig.json }
vars: { PROJECT: src/editor/desktop/tsconfig.json }
typecheck:cloud:
desc: "Typecheck cloud shared layer (standalone)"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/cloud/tsconfig.json }
vars: { PROJECT: src/editor/cloud/tsconfig.json }
typecheck:scripts:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/scripts/tsconfig.json }
vars: { PROJECT: scripts/tsconfig.json }
typecheck:prototypes:
desc: "Typecheck prototypes build variant"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/prototypes/tsconfig.json }
vars: { PROJECT: src/editor/prototypes/tsconfig.json }
typecheck:portal:
desc: "Typecheck developer portal build variant"
deps: [install]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/portal/tsconfig.json }
vars: { PROJECT: src/processor/proprietary/tsconfig.json }
typecheck:storybook:
desc: "Typecheck Storybook config and stories"
@@ -433,7 +433,7 @@ tasks:
og:check:
desc: "Fail if committed OG/social-preview metadata is out of date"
cmds:
- node editor/scripts/generate-og-metadata.mjs --check
- node scripts/generate-og-metadata.mjs --check
check:all:
desc: "Full CI quality gate"
@@ -461,13 +461,13 @@ tasks:
desc: "Run editor tests"
deps: [prepare]
cmds:
- npx vitest run --root editor
- npx vitest run --root .
test:watch:
desc: "Run tests in watch mode"
deps: [prepare]
cmds:
- npx vitest --watch --root editor
- npx vitest --watch --root .
test:coverage:
desc: "Run tests with coverage (one-shot; CI-friendly)."
@@ -478,12 +478,12 @@ tasks:
# 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
# (--root .), so output lands at frontend/coverage/. The
# CI upload step reads from that path. An earlier attempt with
# `./editor/coverage` double-nested into frontend/editor/editor/coverage;
# `./editor/coverage` double-nested into frontend/coverage;
# pinning future-proofs against vitest changing the default.
- >
npx vitest run --root editor --coverage
npx vitest run --root . --coverage
--coverage.provider=v8
--coverage.reporter=text-summary
--coverage.reporter=json-summary
@@ -498,25 +498,25 @@ tasks:
desc: "Generate tool API types from the Java OpenAPI spec"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts
- npx tsx scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output src/editor/core/types/toolApiTypes.ts --io-output src/editor/core/types/toolIO.ts
sources:
- editor/scripts/generate-tool-api-types.mts
- scripts/generate-tool-api-types.mts
- ../SwaggerDoc.json
generates:
- editor/src/core/types/toolApiTypes.ts
- editor/src/core/types/toolIO.ts
- src/editor/core/types/toolApiTypes.ts
- src/editor/core/types/toolIO.ts
tool-models:check:
desc: "Fail if committed tool API types are out of date"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts --check
- npx tsx scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output src/editor/core/types/toolApiTypes.ts --io-output src/editor/core/types/toolIO.ts --check
licenses:generate:
desc: "Generate frontend license report"
deps: [install]
cmds:
- node editor/scripts/generate-licenses.js
- node scripts/generate-licenses.js
# ============================================================
# Clean
@@ -525,7 +525,7 @@ tasks:
clean:
desc: "Clean build artifacts and caches"
cmds:
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, dist, dist
platforms: [windows]
- cmd: rm -rf node_modules/.vite editor/dist dist
- cmd: rm -rf node_modules/.vite dist dist
platforms: [linux, darwin]
+2 -2
View File
@@ -21,7 +21,7 @@ vars:
':(exclude).devcontainer/*'
':(exclude)app/core/src/main/resources/*'
':(exclude)app/proprietary/src/main/resources/*'
':(exclude)frontend/editor/public/vendor/*'
':(exclude)frontend/public/vendor/*'
':(exclude)*Dockerfile*'
':(exclude)*pdfjs*'
':(exclude)*thirdParty*'
@@ -39,7 +39,7 @@ vars:
':(exclude)*.min.*'
':(exclude)*diff.js'
':(exclude).github/workflows/*'
LOCALE_TOML: 'frontend/editor/public/locales/*/translation.toml'
LOCALE_TOML: 'frontend/public/locales/*/translation.toml'
# gitleaks is pinned + checksum-verified by scripts/pre-commit/install_gitleaks.py,
# which owns the version and caches the binary here.
+6 -6
View File
@@ -10,14 +10,14 @@ When adding tools, follow this systematic approach using the established pattern
Create these files in the correct directories:
```
frontend/editor/src/hooks/tools/[toolName]/
frontend/src/hooks/tools/[toolName]/
├── use[ToolName]Parameters.ts # Parameter definitions and validation
└── use[ToolName]Operation.ts # Tool operation logic using useToolOperation
frontend/editor/src/components/tools/[toolName]/
frontend/src/components/tools/[toolName]/
└── [ToolName]Settings.tsx # Settings UI component (if needed)
frontend/editor/src/tools/
frontend/src/tools/
└── [ToolName].tsx # Main tool component
```
@@ -128,7 +128,7 @@ export default [ToolName] as ToolComponent;
## 3. Register Tool in System
Update these files to register your new tool:
**Tool Registry** (`frontend/editor/src/data/useTranslatedToolRegistry.tsx`):
**Tool Registry** (`frontend/src/data/useTranslatedToolRegistry.tsx`):
1. Add imports at the top:
```typescript
import [ToolName] from "../tools/[ToolName]";
@@ -155,7 +155,7 @@ import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Setting
## 4. Add Tooltips (Optional but Recommended)
Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:**
**Tooltip Hook** (`frontend/editor/src/components/tooltips/use[ToolName]Tips.ts`):
**Tooltip Hook** (`frontend/src/components/tooltips/use[ToolName]Tips.ts`):
```typescript
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
@@ -202,7 +202,7 @@ const [ToolName] = (props: BaseToolProps) => {
## 5. Add Translations
Update translation files. **Important: Only update `en-US` files** - other languages are handled separately.
**File to update:** `frontend/editor/public/locales/en-US/translation.toml`
**File to update:** `frontend/public/locales/en-US/translation.toml`
**Required Translation Keys**:
```toml
+17 -17
View File
@@ -139,10 +139,10 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Environment Variables
- All `VITE_*` variables must be declared in the appropriate committed env file:
- `frontend/editor/.env` — core and shared vars (base, loaded in every mode)
- `frontend/editor/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode)
- `frontend/editor/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
- `frontend/editor/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode)
- `frontend/.env` — core and shared vars (base, loaded in every mode)
- `frontend/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode)
- `frontend/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
- `frontend/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode)
- These files are committed to Git and must not contain private keys
- Local overrides (API keys, machine-specific settings) go in uncommitted sibling `.env.local` / `.env.saas.local` / `.env.desktop.local` files — Vite automatically layers them on top
- Never use `|| 'hardcoded-fallback'` inline — put defaults in the committed env files
@@ -153,9 +153,9 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Import Paths - CRITICAL
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
For a broader explanation of the frontend layering and override architecture, read @frontend/DeveloperGuide.md
Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/editor/src/core/theme/README.md — it explains the palette/`--c-*` token system and the rule that literal colours live only in `primitives.css`.
Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/src/editor/core/theme/README.md — it explains the palette/`--c-*` token system and the rule that literal colours live only in `primitives.css`.
```typescript
// ✅ CORRECT - Use @app/* for all imports
@@ -270,7 +270,7 @@ Frontend designed for **stateful document processing**:
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
#### FileContext - Central State Management
**Location**: `frontend/editor/src/core/contexts/FileContext.tsx`
**Location**: `frontend/src/editor/core/contexts/FileContext.tsx`
- **Active files**: Currently loaded PDFs and their variants
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
@@ -295,7 +295,7 @@ Without cleanup: browser crashes with memory leaks.
**Architecture**: Modular hook-based system with clear separation of concerns:
- **useToolOperation** (`frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- **useToolOperation** (`frontend/src/editor/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
@@ -383,7 +383,7 @@ return useToolOperation({
### Frontend Directory Structure
The frontend is organized with a clear separation of concerns:
- **`frontend/editor/src/core/`**: Main application code (shared, production-ready components)
- **`frontend/src/editor/core/`**: Main application code (shared, production-ready components)
- **`core/components/`**: React components organized by feature
- `core/components/tools/`: Individual PDF tool implementations
- `core/components/viewer/`: PDF viewer components
@@ -401,17 +401,17 @@ The frontend is organized with a clear separation of concerns:
- **`core/data/`**: Static data (tool taxonomy, etc.)
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
- **`frontend/editor/src/desktop/`**: Desktop-specific (Tauri) code
- **`frontend/editor/src/proprietary/`**: Proprietary/licensed features
- **`frontend/editor/src-tauri/`**: Tauri (Rust) native desktop application code
- **`frontend/editor/public/`**: Static assets served directly
- **`frontend/src/editor/desktop/`**: Desktop-specific (Tauri) code
- **`frontend/src/editor/proprietary/`**: Proprietary/licensed features
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
- **`frontend/public/`**: Static assets served directly
- `public/locales/`: Translation JSON files
### Component Architecture
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/editor/public/` (modern)
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
- **Internationalization**:
- Backend: `messages_*.properties` files
- Frontend: JSON files in `frontend/editor/public/locales/` (converted from .properties)
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
- Conversion Script: `scripts/convert_properties_to_json.py`
### Configuration Modes
@@ -436,7 +436,7 @@ The frontend is organized with a clear separation of concerns:
4. **Code Style**: Spotless enforces Google Java Format automatically (`task backend:format`)
5. **Translations**:
- Backend: Use helper scripts in `/scripts` for multi-language updates
- Frontend: Update JSON files in `frontend/editor/public/locales/` or use conversion script
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
6. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
## Frontend Architecture Status
@@ -454,7 +454,7 @@ The frontend is organized with a clear separation of concerns:
## Translation Rules
- **CRITICAL**: Always update translations in `en-US` only - all other languages (including `en-GB`) are handled separately
- Translation files are located in `frontend/editor/public/locales/`
- Translation files are located in `frontend/public/locales/`
- After changing any translation file, run `task pre-commit:fix`
## Important Notes
+1 -1
View File
@@ -21,7 +21,7 @@ This project uses [Task](https://taskfile.dev/) as a unified command runner. Aft
1. Install the `task` CLI: https://taskfile.dev/installation/
2. Run `task install` to install all dependencies
3. Run `task dev` to start backend + frontend
3. Run `task dev` to start backend + frontend or `task desktop:dev` to start the desktop application
4. Run `task check` before submitting a PR
Run `task --list` to see all available commands.
+1 -1
View File
@@ -458,7 +458,7 @@ For Stirling 2.0, new features are built as React components:
1. **Create the React Component:**
```typescript
// frontend/editor/src/tools/NewTool.tsx
// frontend/src/tools/NewTool.tsx
import { useState } from 'react';
import { Button, FileInput, Container } from '@mantine/core';
+12 -14
View File
@@ -10,20 +10,18 @@ if that directory exists, is licensed under the license defined in "app/propriet
if that directory exists, is licensed under the license defined in "app/saas/LICENSE".
* All content that resides under the "engine/" directory of this repository,
if that directory exists, is licensed under the license defined in "engine/LICENSE".
* All content that resides under the "frontend/editor/src/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/proprietary/LICENSE".
* All content that resides under the "frontend/editor/src/desktop/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
* All content that resides under the "frontend/editor/src/cloud/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
* All content that resides under the "frontend/editor/src/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE".
* All content that resides under the "frontend/editor/src/portal-saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal-saas/LICENSE".
* All content that resides under the "frontend/src/editor/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/editor/proprietary/LICENSE".
* All content that resides under the "frontend/src/editor/desktop/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/editor/desktop/LICENSE".
* All content that resides under the "frontend/src/editor/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/editor/saas/LICENSE".
* All content that resides under the "frontend/src/editor/cloud/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/editor/cloud/LICENSE".
* All content that resides under the "frontend/src/editor/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/editor/prototypes/LICENSE".
* All content that resides under the "frontend/src/processor/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/processor/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
+1 -1
View File
@@ -86,7 +86,7 @@ If you're using Tauri's built-in updater feature:
## Configuration Files
### 1. Tauri Configuration (frontend/editor/src-tauri/tauri.conf.json)
### 1. Tauri Configuration (frontend/src-tauri/tauri.conf.json)
The Windows signing configuration is already set up:
+8 -8
View File
@@ -156,6 +156,14 @@
"moduleName": ".*",
"moduleLicense": "GNU GENERAL PUBLIC LICENSE, Version 2 + Classpath Exception"
},
{
"moduleName": ".*",
"moduleLicense": "GNU Lesser Public License"
},
{
"moduleName": ".*",
"moduleLicense": "The GNU Lesser General Public License"
},
{
"moduleName": "com.martiansoftware:jsap",
"moduleLicense": "LGPL"
@@ -224,14 +232,6 @@
"moduleName": "com.google.re2j:re2j",
"moduleLicense": "Go License"
},
{
"moduleName": "com.hubspot:algebra",
"moduleLicense": null
},
{
"moduleName": "com.hubspot.immutables:immutables-exceptions",
"moduleLicense": null
},
{
"moduleName": ".*",
"moduleLicense": "UnRar License"
+13 -7
View File
@@ -16,7 +16,7 @@ dependencies {
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
api "org.apache.pdfbox:preflight:$pdfboxVersion"
api 'com.github.junrar:junrar:7.6.0' // RAR archive support for CBR files
api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
@@ -36,18 +36,24 @@ dependencies {
api "com.stirling:jpdfium:${jpdfiumVersion}"
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
// -PjpdfiumPlatforms=all|none|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
// 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform).
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
def jpdfiumPlatforms = jpdfiumPlatformsProp == 'all'
? jpdfiumAllPlatforms
: jpdfiumPlatformsProp.split(',').collect { it.trim() }.findAll { it }
def jpdfiumPlatforms
if (jpdfiumPlatformsProp == 'all') {
jpdfiumPlatforms = jpdfiumAllPlatforms
} else if (jpdfiumPlatformsProp == 'none') {
jpdfiumPlatforms = []
} else {
jpdfiumPlatforms = jpdfiumPlatformsProp.split(',').collect { it.trim() }.findAll { it }
}
def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) }
if (jpdfiumInvalid) {
throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " +
"Valid: ${jpdfiumAllPlatforms.join(', ')} or 'all'.")
"Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.")
}
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}")
jpdfiumPlatforms.each { platform ->
runtimeOnly "com.stirling:jpdfium-natives-${platform}:${jpdfiumVersion}"
}
@@ -983,7 +983,7 @@ public class PdfMarkdownConverter {
ordered.sort(Comparator.comparingDouble((Line l) -> l.y).reversed());
StringBuilder sb = new StringBuilder();
for (Line l : ordered) {
if (sb.length() > 0) {
if (!sb.isEmpty()) {
sb.append(' ');
}
sb.append(l.text);
@@ -19,6 +19,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import jakarta.annotation.PreDestroy;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
@@ -63,6 +64,21 @@ public class JobExecutorService {
"Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs);
}
/** Stop the service-owned executor when the application context is closed or restarted. */
@PreDestroy
public void shutdown() {
log.debug("Shutting down job executor");
executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
executor.shutdownNow();
}
}
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
return runJobGeneric(async, work, -1);
}
@@ -225,19 +225,21 @@ public class MobileScannerService {
Path sessionDir = getSafeSessionDirectory(sessionId);
if (Files.exists(sessionDir)) {
// Delete all files in session directory
Files.walk(sessionDir)
.sorted(
(a, b) ->
-a.compareTo(b)) // Reverse order to delete files before
// directory
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete file: {}", path, e);
}
});
try (var paths = Files.walk(sessionDir)) {
paths.sorted(
(a, b) ->
-a.compareTo(
b)) // Reverse order to delete files before
// directory
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete file: {}", path, e);
}
});
}
}
log.info("Deleted session: {}", sessionId);
} catch (IllegalArgumentException e) {
@@ -48,6 +48,10 @@ public class TaskManager {
@Value("${stirling.jobResultExpiryMinutes:30}")
private int jobResultExpiryMinutes = 30;
/** Maximum age of a task that never reached a terminal state. */
@Value("${stirling.job.pendingExpiryMinutes:1440}")
private int pendingJobExpiryMinutes = 1440;
private final FileStorage fileStorage;
private final JobStore jobStore;
private final ClusterBackplane clusterBackplane;
@@ -332,19 +336,32 @@ public class TaskManager {
}
LocalDateTime expiryThreshold =
LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
LocalDateTime pendingExpiryThreshold =
LocalDateTime.now().minus(pendingJobExpiryMinutes, ChronoUnit.MINUTES);
int removedCount = 0;
try {
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
JobResult result = entry.getValue();
// Remove completed jobs that are older than the expiry threshold
if (result.isComplete()
&& result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold)) {
boolean expiredCompletedJob =
result.isComplete()
&& result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold);
boolean abandonedPendingJob =
!result.isComplete()
&& result.getCreatedAt() != null
&& result.getCreatedAt().isBefore(pendingExpiryThreshold);
// Remove old terminal results and abandoned pending jobs. Without the second
// branch, a client that starts a task and never completes it keeps its result in
// memory forever.
if (expiredCompletedJob || abandonedPendingJob) {
// Clean up file results
cleanupJobFiles(result, entry.getKey());
if (expiredCompletedJob) {
cleanupJobFiles(result, entry.getKey());
}
// Remove the job result
jobResults.remove(entry.getKey());
@@ -941,7 +941,7 @@ public class GeneralUtils {
}
// If no MAC address found, use hostname as fallback
if (sb.length() == 0) {
if (sb.isEmpty()) {
String hostname = InetAddress.getLocalHost().getHostName();
sb.append(hostname != null ? hostname : "unknown-host");
log.warn("No MAC address found, using hostname for fingerprint generation");
@@ -1,17 +1,22 @@
package stirling.software.common.util;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.util.concurrent.UncheckedExecutionException;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public final class RegexPatternUtils {
private static final RegexPatternUtils INSTANCE = new RegexPatternUtils();
private final ConcurrentHashMap<PatternKey, Pattern> patternCache = new ConcurrentHashMap<>();
private static final long MAX_CACHED_PATTERNS = 512;
private final Cache<PatternKey, Pattern> patternCache =
CacheBuilder.newBuilder().maximumSize(MAX_CACHED_PATTERNS).build();
private static final String WHITESPACE_REGEX = "\\s++";
private static final String EXTENSION_REGEX = "\\.(?:[^.]*+)?$";
@@ -51,7 +56,7 @@ public final class RegexPatternUtils {
throw new IllegalArgumentException("Regex pattern cannot be null");
}
return patternCache.computeIfAbsent(new PatternKey(regex, 0), this::compilePattern);
return getOrCompile(new PatternKey(regex, 0));
}
/**
@@ -77,7 +82,7 @@ public final class RegexPatternUtils {
throw new IllegalArgumentException("Regex pattern cannot be null");
}
return patternCache.computeIfAbsent(new PatternKey(regex, flags), this::compilePattern);
return getOrCompile(new PatternKey(regex, flags));
}
/**
@@ -98,7 +103,7 @@ public final class RegexPatternUtils {
* @return true if pattern is cached, false otherwise
*/
public boolean isCached(String regex, int flags) {
return regex != null && patternCache.containsKey(new PatternKey(regex, flags));
return regex != null && patternCache.getIfPresent(new PatternKey(regex, flags)) != null;
}
/**
@@ -107,7 +112,7 @@ public final class RegexPatternUtils {
* @return number of patterns currently cached
*/
public int getCacheSize() {
return patternCache.size();
return (int) patternCache.size();
}
/**
@@ -115,7 +120,7 @@ public final class RegexPatternUtils {
* useful for testing or memory cleanup in long-running applications.
*/
public void clearCache() {
patternCache.clear();
patternCache.invalidateAll();
log.debug("Regex pattern cache cleared");
}
@@ -141,13 +146,32 @@ public final class RegexPatternUtils {
return false;
}
PatternKey key = new PatternKey(regex, flags);
boolean removed = patternCache.remove(key) != null;
boolean removed = patternCache.getIfPresent(key) != null;
patternCache.invalidate(key);
if (removed) {
log.debug("Removed regex pattern from cache: {} (flags: {})", regex, flags);
}
return removed;
}
private Pattern getOrCompile(PatternKey key) {
try {
return patternCache.get(key, () -> compilePattern(key));
} catch (UncheckedExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof PatternSyntaxException patternSyntaxException) {
throw patternSyntaxException;
}
throw e;
} catch (java.util.concurrent.ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof PatternSyntaxException patternSyntaxException) {
throw patternSyntaxException;
}
throw new IllegalStateException("Failed to compile regex pattern", cause);
}
}
/**
* Internal method to compile a pattern and handle errors consistently.
*
@@ -154,7 +154,7 @@ class PdfMarkdownConverterTest {
|| isTableSeparatorRow(line)) {
continue;
}
if (sb.length() > 0) {
if (!sb.isEmpty()) {
sb.append('\n');
}
sb.append(line);
+5 -5
View File
@@ -57,7 +57,7 @@ dependencies {
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
implementation 'com.sun.xml.bind:jaxb-core:4.0.9'
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation "com.google.code.gson:gson:${gsonVersion}"
@@ -156,7 +156,7 @@ if (buildWithPortal) {
}
// Workspace root holds package.json and node_modules (shared across editor /
// future portal). Editor-specific paths (src, public, dist, tauri) live one
// level deeper under frontend/editor/.
// level deeper under frontend/.
// Vite mode: -PprototypesMode > -PfrontendMode > enableSaas > disableAdditional > proprietary.
def frontendModeOverride = project.findProperty('frontendMode')?.toString()?.toLowerCase()
@@ -176,11 +176,11 @@ def frontendBuildTask = "frontend:build:${frontendMode}"
// Workspace root holds package.json and node_modules (shared across editor /
// future portal). Editor-specific paths (src, public, dist, tauri) live one
// level deeper under frontend/editor/. When the portal lands as an embedded
// level deeper under frontend/. When the portal lands as an embedded
// app, add a sibling frontendPortalDir / frontendPortalDistDir alongside.
def frontendDir = file('../../frontend')
def frontendEditorDir = file('../../frontend/editor')
def frontendEditorDistDir = file('../../frontend/editor/dist')
def frontendEditorDir = file('../../frontend')
def frontendEditorDistDir = file('../../frontend/dist')
def resourcesStaticDir = file('src/main/resources/static')
def generatedFrontendPaths = [
'assets',
@@ -321,7 +321,7 @@ public class ExternalAppDepConfig {
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
if (sb.length() > 0) sb.append('\n');
if (!sb.isEmpty()) sb.append('\n');
sb.append(line);
}
}
@@ -130,7 +130,7 @@ public class UIDataController {
objectMapper.readValue(
config, new TypeReference<Map<String, Object>>() {});
String name = (String) jsonContent.get("name");
if (name == null || name.length() < 1) {
if (name == null || name.isEmpty()) {
String filename =
jsonFiles
.get(pipelineConfigs.indexOf(config))
@@ -61,7 +61,6 @@ import stirling.software.common.model.api.PDFFile;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -270,25 +269,6 @@ public class GetInfoOnPDF {
}
}
private static void validatePdfFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("PDF file is required");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileSizeLimit",
"File size ({0} bytes) exceeds maximum allowed size ({1} bytes)",
file.getSize(),
MAX_FILE_SIZE);
}
String contentType = file.getContentType();
if (contentType != null && !"application/pdf".equals(contentType)) {
log.warn("File content type is {}, expected application/pdf", contentType);
}
}
private static ResponseEntity<byte[]> createErrorResponse(String errorMessage) {
try {
ObjectNode errorNode = objectMapper.createObjectNode();
@@ -1104,14 +1084,6 @@ public class GetInfoOnPDF {
public ResponseEntity<byte[]> getPdfInfo(@ModelAttribute PDFFile request) throws IOException {
MultipartFile inputFile = request.getFileInput();
// Validate input
try {
validatePdfFile(inputFile);
} catch (IllegalArgumentException e) {
log.error("Invalid PDF file: {}", e.getMessage());
return createErrorResponse("Invalid PDF file: " + e.getMessage());
}
List<PDFVerificationResult> verificationResults = null;
try {
verificationResults = veraPDFService.validatePDF(inputFile.getInputStream());
@@ -124,15 +124,15 @@ public class PasswordController {
StandardProtectionPolicy spp =
new StandardProtectionPolicy(ownerPassword, password, ap);
if ((ownerPassword != null && ownerPassword.length() > 0)
|| (password != null && password.length() > 0)) {
if ((ownerPassword != null && !ownerPassword.isEmpty())
|| (password != null && !password.isEmpty())) {
spp.setEncryptionKeyLength(keyLength);
}
spp.setPermissions(ap);
document.protect(spp);
if ((ownerPassword == null || ownerPassword.length() == 0)
&& (password == null || password.length() == 0))
if ((ownerPassword == null || ownerPassword.isEmpty())
&& (password == null || password.isEmpty()))
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
@@ -760,12 +760,12 @@ class RedactExecuteService {
char ch = raw.charAt(i);
if (Character.isLetterOrDigit(ch)) {
current.append(ch);
} else if (current.length() > 0) {
} else if (!current.isEmpty()) {
tokens.add(current.toString());
current.setLength(0);
}
}
if (current.length() > 0) tokens.add(current.toString());
if (!current.isEmpty()) tokens.add(current.toString());
if (tokens.size() < 2) return null;
StringBuilder out = new StringBuilder();
for (int i = 0; i < tokens.size(); i++) {
@@ -788,25 +788,25 @@ class RedactExecuteService {
StringBuilder current = new StringBuilder();
for (String token : tokens) {
if (token.isEmpty()) {
if (current.length() > 0) {
if (result.length() > 0) result.append(' ');
if (!current.isEmpty()) {
if (!result.isEmpty()) result.append(' ');
result.append(current);
current.setLength(0);
}
} else if (token.length() == 1) {
current.append(token);
} else {
if (current.length() > 0) {
if (result.length() > 0) result.append(' ');
if (!current.isEmpty()) {
if (!result.isEmpty()) result.append(' ');
result.append(current);
current.setLength(0);
}
if (result.length() > 0) result.append(' ');
if (!result.isEmpty()) result.append(' ');
result.append(token);
}
}
if (current.length() > 0) {
if (result.length() > 0) result.append(' ');
if (!current.isEmpty()) {
if (!result.isEmpty()) result.append(' ');
result.append(current);
}
return result.toString().trim();
@@ -251,7 +251,7 @@ public class MetricsController {
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
!endpointInspector.getValidGetEndpoints().isEmpty();
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
@@ -292,7 +292,7 @@ public class MetricsController {
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
!endpointInspector.getValidGetEndpoints().isEmpty();
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
@@ -332,7 +332,7 @@ public class MetricsController {
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
!endpointInspector.getValidGetEndpoints().isEmpty();
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
@@ -5,17 +5,26 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.core.Ordered;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.servlet.function.support.RouterFunctionMapping;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.util.JavaScriptUtils;
@@ -32,6 +41,33 @@ public class ReactRoutingController {
private static final Pattern BASE_HREF_PATTERN =
Pattern.compile("<base href=\\\"[^\\\"]*\\\"\\s*/?>");
// First path segments owned by the backend or static assets, never SPA routes.
// Mirrors the exclusion regexes on forwardRootPaths/forwardNestedPaths below.
private static final Set<String> NON_SPA_FIRST_SEGMENTS =
Set.of(
"api",
"static",
"pipeline",
"pdfjs",
"pdfjs-legacy",
"pdfium",
"vendor",
"fonts",
"images",
"css",
"js",
"assets",
"locales",
"modern-logo",
"classic-logo",
"Login",
"og_images",
"samples");
// After the annotated controllers (order 0), before the resource chain
// (LOWEST_PRECEDENCE - 1).
private static final int SPA_FALLBACK_ORDER = Ordered.LOWEST_PRECEDENCE - 2;
@Value("${server.servlet.context-path:/}")
private String contextPath;
@@ -256,6 +292,59 @@ public class ReactRoutingController {
return serveIndexHtml(request);
}
// The regex mappings above only cover 1- and 2-segment paths (Spring path variables cannot
// span '/'), so deep SPA links like /processor/pipelines/new 404d on direct navigation.
//
// Registered as its own mapping rather than exposed as a bare RouterFunction @Bean:
// Spring's own RouterFunctionMapping is ordered -1, ahead of the annotated controllers at
// order 0, so a plain bean would shadow every dot-free backend route the denylist below
// does not name (/v1/api-docs, /error, /actuator, ...). LOWEST_PRECEDENCE - 2 puts it after
// the controllers and before the resource chain (LOWEST_PRECEDENCE - 1), which is the only
// position where a catch-all fallback is safe.
@Bean
public RouterFunctionMapping spaDeepLinkFallbackMapping() {
RouterFunction<ServerResponse> fallback =
RouterFunctions.route(
request -> {
HttpServletRequest servletRequest = request.servletRequest();
return "GET".equals(servletRequest.getMethod())
&& isSpaFallbackRoute(
stripContextPath(
servletRequest.getContextPath(),
servletRequest.getRequestURI()));
},
request ->
ServerResponse.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(serveIndexHtml(request.servletRequest()).getBody()));
RouterFunctionMapping mapping = new RouterFunctionMapping(fallback);
mapping.setOrder(SPA_FALLBACK_ORDER);
mapping.setMessageConverters(
List.of(new StringHttpMessageConverter(StandardCharsets.UTF_8)));
return mapping;
}
// Dot-free paths only, so requests for real files still fall through to the resource
// handlers. This is a denylist, so it is only safe because the mapping above runs after
// the annotated controllers - see spaDeepLinkFallbackMapping.
static boolean isSpaFallbackRoute(String path) {
if (path == null || path.isEmpty() || "/".equals(path) || path.indexOf('.') >= 0) {
return false;
}
String[] segments = (path.startsWith("/") ? path.substring(1) : path).split("/");
return segments.length > 0
&& !segments[0].isEmpty()
&& !NON_SPA_FIRST_SEGMENTS.contains(segments[0]);
}
private static String stripContextPath(String contextPath, String uri) {
if (contextPath != null && !contextPath.isBlank() && uri.startsWith(contextPath)) {
return uri.substring(contextPath.length());
}
return uri;
}
private String buildFallbackHtml() {
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
@@ -237,12 +237,12 @@ public class HardwareKeyStoreService {
combined.append(env);
}
if (prop != null && !prop.isBlank()) {
if (combined.length() > 0) {
if (!combined.isEmpty()) {
combined.append(java.io.File.pathSeparator);
}
combined.append(prop);
}
if (combined.length() == 0) {
if (combined.isEmpty()) {
return List.of();
}
return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]"))
@@ -264,21 +264,6 @@ class GetInfoOnPDFMoreTest {
@DisplayName("error handling")
class Errors {
@Test
@DisplayName("empty file input yields an error response")
void emptyFile() throws Exception {
MockMultipartFile mf =
new MockMultipartFile("fileInput", "x.pdf", "application/pdf", new byte[0]);
PDFFile request = new PDFFile();
request.setFileInput(mf);
ResponseEntity<byte[]> resp = getInfoOnPDF.getPdfInfo(request);
// createErrorResponse returns HTTP 200 with a JSON body carrying an "error" field.
assertThat(resp.getBody()).isNotNull();
JsonNode body = om.readTree(resp.getBody());
assertThat(body.has("error")).isTrue();
assertThat(body.get("error").asText("")).contains("Invalid");
}
@Test
@DisplayName("veraPDF failure is swallowed and a report is still produced")
void veraPdfFailureSwallowed() throws Exception {
@@ -556,24 +556,6 @@ class GetInfoOnPDFTest {
@DisplayName("Validation and Error Handling Tests")
class ValidationErrorTests {
@Test
@DisplayName("Should reject null file")
void testValidation_NullFile() throws IOException {
PDFFile request = new PDFFile();
request.setFileInput(null);
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
Assertions.assertEquals(
HttpStatus.OK, response.getStatusCode()); // Returns error JSON with 200
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
Assertions.assertTrue(jsonNode.has("error"));
Assertions.assertTrue(
jsonNode.get("error").asText("").contains("PDF file is required"));
}
@Test
@DisplayName("Should reject empty file")
void testValidation_EmptyFile() throws IOException {
@@ -591,64 +573,6 @@ class GetInfoOnPDFTest {
Assertions.assertTrue(jsonNode.has("error"));
}
@Test
@DisplayName("Should reject file that exceeds max size")
void testValidation_TooLargeFile() throws IOException {
MultipartFile largeFile =
new MultipartFile() {
@Override
public String getName() {
return "file";
}
@Override
public String getOriginalFilename() {
return "large.pdf";
}
@Override
public String getContentType() {
return MediaType.APPLICATION_PDF_VALUE;
}
@Override
public boolean isEmpty() {
return false;
}
@Override
public long getSize() {
// Report 101 MB without allocating memory
return 101L * 1024L * 1024L;
}
@Override
public byte[] getBytes() {
return new byte[0];
}
@Override
public java.io.InputStream getInputStream() {
return java.io.InputStream.nullInputStream();
}
@Override
public void transferTo(java.io.File dest) throws IllegalStateException {}
};
PDFFile request = new PDFFile();
request.setFileInput(largeFile);
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
Assertions.assertTrue(jsonNode.has("error"));
Assertions.assertTrue(
jsonNode.get("error").asText("").contains("exceeds maximum allowed size"));
}
}
@Nested
@@ -4,12 +4,24 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.servlet.function.EntityResponse;
import org.springframework.web.servlet.function.HandlerFunction;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.servlet.function.support.RouterFunctionMapping;
import org.springframework.web.util.ServletRequestPathUtils;
import jakarta.servlet.http.HttpServletRequest;
@@ -175,6 +187,83 @@ class ReactRoutingControllerTest {
assertNotNull(response.getBody());
}
// --- deep-link SPA fallback (router function) ---
@Test
void isSpaFallbackRoute_acceptsDeepSpaPaths() {
assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/new"));
assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/123/runs/456"));
assertTrue(ReactRoutingController.isSpaFallbackRoute("/workflow/sign/some-token"));
assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/new/"));
// "pipelines" must not be swallowed by the "pipeline" exclusion
assertTrue(ReactRoutingController.isSpaFallbackRoute("/pipelines"));
}
@Test
void isSpaFallbackRoute_rejectsBackendStaticAndFilePaths() {
assertFalse(ReactRoutingController.isSpaFallbackRoute("/api/v1/some/endpoint"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/pipeline"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/pipeline/anything"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/assets/deep/path"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/file.js"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/branding/sub/logo.png"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/"));
assertFalse(ReactRoutingController.isSpaFallbackRoute(""));
assertFalse(ReactRoutingController.isSpaFallbackRoute(null));
}
@Test
void spaDeepLinkFallback_servesIndexForDeepRoute() throws Exception {
controller.init();
RouterFunction<ServerResponse> router = routerOf(controller.spaDeepLinkFallbackMapping());
ServerRequest deepRequest = serverRequest("GET", "/processor/pipelines/new");
Optional<HandlerFunction<ServerResponse>> handler = router.route(deepRequest);
assertTrue(handler.isPresent());
ServerResponse response = handler.get().handle(deepRequest);
assertEquals(HttpStatus.OK, response.statusCode());
assertInstanceOf(EntityResponse.class, response);
Object body = ((EntityResponse<?>) response).entity();
assertTrue(body.toString().contains("Stirling PDF"));
}
@Test
void spaDeepLinkFallback_ignoresApiFilesAndNonGet() {
controller.init();
RouterFunction<ServerResponse> router = routerOf(controller.spaDeepLinkFallbackMapping());
assertTrue(router.route(serverRequest("GET", "/api/v1/policies/run")).isEmpty());
assertTrue(router.route(serverRequest("GET", "/branding/sub/logo.png")).isEmpty());
assertTrue(router.route(serverRequest("POST", "/processor/pipelines/new")).isEmpty());
}
@Test
void spaDeepLinkFallback_runsAfterControllersAndBeforeResources() {
controller.init();
int order = controller.spaDeepLinkFallbackMapping().getOrder();
// A catch-all denylist is only safe below every annotated controller; Spring's own
// RouterFunctionMapping sits at -1, which would shadow /v1/api-docs, /error and friends.
assertTrue(order > 0, "SPA fallback must run after annotated controllers");
assertTrue(
order < Ordered.LOWEST_PRECEDENCE - 1,
"SPA fallback must run before the static-resource chain");
}
private static RouterFunction<ServerResponse> routerOf(RouterFunctionMapping mapping) {
@SuppressWarnings("unchecked")
RouterFunction<ServerResponse> router =
(RouterFunction<ServerResponse>) mapping.getRouterFunction();
return router;
}
private static ServerRequest serverRequest(String method, String uri) {
MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, uri);
ServletRequestPathUtils.parseAndCache(servletRequest);
return ServerRequest.create(servletRequest, List.of(new StringHttpMessageConverter()));
}
// --- context path handling ---
@Test
+12
View File
@@ -0,0 +1,12 @@
{
"com.hubspot.immutables:immutables-exceptions:1.9": {
"name": "The Apache License, Version 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.txt",
"projectUrl": "https://github.com/HubSpot/hubspot-immutables/tree/58628096ac99b286fe4f8bfe12aa3cff0f0589d3"
},
"com.hubspot:algebra:1.5": {
"name": "The Apache License, Version 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.txt",
"projectUrl": "https://github.com/HubSpot/algebra/tree/5d42983fd3a26539df9ba2cbeac32a1bddce0494"
}
}
@@ -2,7 +2,7 @@ package stirling.software.proprietary.access.model;
/** Types of resources whose access can be gated by {@link ResourceGrant}. */
public enum ResourceType {
// The admin portal / processor (frontend/editor/src/portal). Singleton resource (empty
// The admin portal / processor (frontend/src/processor/proprietary). Singleton resource (empty
// resourceId).
PORTAL,
// A stored S3/MCP/API integration configuration.
@@ -2,6 +2,7 @@ package stirling.software.proprietary.config;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.slf4j.MDC;
@@ -12,10 +13,15 @@ import org.springframework.core.task.support.TaskExecutorAdapter;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
import jakarta.annotation.PreDestroy;
@Configuration
@EnableAsync
public class AsyncConfig {
private ExecutorService auditExecutorService;
private ExecutorService aiStreamExecutorService;
/**
* MDC context-propagating task decorator. Copies MDC context from the caller thread to the
* virtual thread executing the task.
@@ -44,8 +50,8 @@ public class AsyncConfig {
@Bean(name = "auditExecutor")
public Executor auditExecutor() {
TaskExecutorAdapter adapter =
new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
auditExecutorService = Executors.newVirtualThreadPerTaskExecutor();
TaskExecutorAdapter adapter = new TaskExecutorAdapter(auditExecutorService);
adapter.setTaskDecorator(new MDCContextTaskDecorator());
return adapter;
}
@@ -53,9 +59,25 @@ public class AsyncConfig {
/** Propagates the request's SecurityContext onto background AI-orchestration threads. */
@Bean(name = "aiStreamExecutor")
public Executor aiStreamExecutor() {
TaskExecutorAdapter adapter =
new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
aiStreamExecutorService = Executors.newVirtualThreadPerTaskExecutor();
TaskExecutorAdapter adapter = new TaskExecutorAdapter(aiStreamExecutorService);
adapter.setTaskDecorator(new MDCContextTaskDecorator());
return new DelegatingSecurityContextExecutor(adapter);
}
/**
* Close the underlying executors because the exposed Spring adapters do not own their
* lifecycle.
*/
@PreDestroy
void shutdown() {
shutdownExecutor(auditExecutorService);
shutdownExecutor(aiStreamExecutorService);
}
private void shutdownExecutor(ExecutorService executor) {
if (executor != null) {
executor.shutdownNow();
}
}
}
@@ -16,6 +16,8 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientResponseException;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -79,6 +81,21 @@ public class PolicyEngine {
private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor();
/** Stop the service-owned executor when the application context is closed or restarted. */
@PreDestroy
void shutdown() {
log.debug("Shutting down policy engine executor");
asyncExecutor.shutdown();
try {
if (!asyncExecutor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) {
asyncExecutor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
asyncExecutor.shutdownNow();
}
}
/**
* Submit a pipeline to run asynchronously. The handle's run id scopes a {@link TaskManager} job
* (status/notes/results observable via the job endpoints); its future resolves when the run
@@ -199,7 +199,7 @@ public class UserController {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "usernameExists", "message", "Username already exists"));
}
if (newUsername != null && newUsername.length() > 0) {
if (newUsername != null && !newUsername.isEmpty()) {
try {
userService.changeUsername(user, newUsername);
} catch (IllegalArgumentException e) {
@@ -205,7 +205,7 @@ public class UserService implements UserServiceInterface {
User user =
findByUsernameIgnoreCase(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
if (user.getApiKey() == null || user.getApiKey().length() == 0) {
if (user.getApiKey() == null || user.getApiKey().isEmpty()) {
user = addApiKeyToUser(username);
}
return user.getApiKey();
@@ -224,7 +224,7 @@ public class PortalInfraAuditService {
if (word.isEmpty()) {
continue;
}
if (sb.length() > 0) {
if (!sb.isEmpty()) {
sb.append(' ');
}
String lower = word.toLowerCase(Locale.ROOT);
@@ -515,7 +515,7 @@ public class UserLicenseSettingsService {
appendIfPresent(builder, applicationProperties.getAutomaticallyGenerated().getUUID());
appendIfPresent(builder, applicationProperties.getPremium().getKey());
if (builder.length() == 0) {
if (builder.isEmpty()) {
builder.append(DEFAULT_INTEGRITY_SECRET);
}
@@ -524,7 +524,7 @@ public class UserLicenseSettingsService {
private void appendIfPresent(StringBuilder builder, String value) {
if (value != null && !value.isBlank()) {
if (builder.length() > 0) {
if (!builder.isEmpty()) {
builder.append(SIGNATURE_SEPARATOR);
}
builder.append(value);
@@ -7,12 +7,14 @@ import java.util.concurrent.TimeUnit;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.storage.model.StorageCleanupEntry;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
@@ -25,6 +27,7 @@ public class StorageCleanupService {
private final StorageProvider storageProvider;
private final StorageCleanupEntryRepository cleanupEntryRepository;
private final FileShareAccessRepository fileShareAccessRepository;
private final FileShareRepository fileShareRepository;
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
@@ -62,12 +65,14 @@ public class StorageCleanupService {
}
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
@Transactional
public void cleanupExpiredShareLinks() {
List<stirling.software.proprietary.storage.model.FileShare> expired =
fileShareRepository.findByExpiresAtBeforeAndShareTokenNotNull(LocalDateTime.now());
if (expired.isEmpty()) {
return;
}
expired.forEach(fileShareAccessRepository::deleteByFileShare);
fileShareRepository.deleteAll(expired);
}
}
+5
View File
@@ -6,6 +6,11 @@ dependencies {
implementation project(':common')
implementation project(':proprietary')
// Markdown -> HTML for rendering versioned legal documents (agreement) to PDF via the
// shared FileToPdf/WeasyPrint path in :common. Same library the core Markdown-to-PDF tool uses.
implementation "org.commonmark:commonmark:$commonmarkVersion"
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
api 'org.springframework.boot:spring-boot-starter-security'
api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
@@ -20,7 +20,8 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
"stirling.software.saas.ai.repository",
"stirling.software.saas.payg.repository",
"stirling.software.saas.payg.bundle",
"stirling.software.saas.procurement.repository"
"stirling.software.saas.procurement.repository",
"stirling.software.saas.legal"
})
@EntityScan({
"stirling.software.saas.accountlink",
@@ -28,6 +29,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
"stirling.software.saas.billing.model",
"stirling.software.saas.ai.model",
"stirling.software.saas.payg",
"stirling.software.saas.procurement.model"
"stirling.software.saas.procurement.model",
"stirling.software.saas.legal"
})
public class SaasJpaConfig {}
@@ -0,0 +1,62 @@
package stirling.software.saas.legal;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* An append-only record that a user accepted a versioned legal document at a particular moment in
* the product. Distinct from a signed agreement (which is a negotiated, signature-bearing artifact,
* see {@code ProcurementAgreementSignature}); this captures the lighter clickwrap consents — the
* EULA accepted at trial start and at quote generation — with the exact document version, so what
* was agreed is auditable even after the document versions up.
*/
@Entity
@Table(name = "legal_consent")
@NoArgsConstructor
@Getter
@Setter
public class LegalConsent implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "consent_id")
private Long consentId;
@Column(name = "team_id")
private Long teamId;
@Column(name = "user_id")
private Long userId;
@Column(name = "document_id", nullable = false, length = 64)
private String documentId;
@Column(name = "document_version", nullable = false, length = 32)
private String documentVersion;
// Where in the product the consent was given: "trial", "quote", etc.
@Column(name = "context", nullable = false, length = 32)
private String context;
@Column(name = "signer_ip", length = 64)
private String signerIp;
@CreationTimestamp
@Column(name = "consented_at", nullable = false, updatable = false)
private LocalDateTime consentedAt;
}
@@ -0,0 +1,5 @@
package stirling.software.saas.legal;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LegalConsentRepository extends JpaRepository<LegalConsent, Long> {}
@@ -0,0 +1,47 @@
package stirling.software.saas.legal;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/** Records clickwrap consents to versioned legal documents (see {@link LegalConsent}). */
@Slf4j
@Service
@Profile("saas")
@RequiredArgsConstructor
public class LegalConsentService {
private final LegalDocumentRegistry registry;
private final LegalConsentRepository consents;
/**
* Record that the given user accepted the current version of {@code documentId} in {@code
* context} (e.g. "trial", "quote"). No-op for an unknown document. Best-effort: callers treat a
* failure as non-fatal so it never blocks the flow the consent accompanies.
*/
@Transactional
public void record(Long teamId, Long userId, String documentId, String context, String ip) {
LegalDocumentMeta meta = registry.meta(documentId).orElse(null);
if (meta == null) {
log.warn("[legal] consent for unknown document '{}' ignored", documentId);
return;
}
LegalConsent consent = new LegalConsent();
consent.setTeamId(teamId);
consent.setUserId(userId);
consent.setDocumentId(meta.id());
consent.setDocumentVersion(meta.version());
consent.setContext(context);
consent.setSignerIp(ip);
consents.save(consent);
log.info(
"[legal] consent recorded team={} doc={} v{} context={}",
teamId,
meta.id(),
meta.version(),
context);
}
}
@@ -0,0 +1,133 @@
package stirling.software.saas.legal;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
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.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Serves the versioned legal documents (EULA, SLA exhibit, subprocessors) for in-product viewing,
* and records the lighter clickwrap consents. The enterprise agreement itself is served + signed
* through the procurement controller, since it needs a quote to fill its Order Form.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/legal")
@Profile("saas")
@RequiredArgsConstructor
public class LegalController {
private final LegalDocumentRegistry registry;
private final LegalConsentService consents;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
/** A legal document rendered for viewing: registry metadata + the static markdown body. */
public record LegalDocumentResponse(
String docId,
String version,
String versionLabel,
String displayName,
String effectiveDate,
String status,
String markdown) {}
public record ConsentRequest(String documentId, String context) {}
/** Fetch a legal document's current version as markdown. 404 for an unknown document. */
@GetMapping("/{docId}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<LegalDocumentResponse> document(@PathVariable String docId) {
return registry.meta(docId)
.<ResponseEntity<LegalDocumentResponse>>map(
meta ->
ResponseEntity.ok(
new LegalDocumentResponse(
meta.id(),
meta.version(),
meta.versionLabel(),
meta.displayName(),
meta.effectiveDate(),
meta.status(),
registry.staticMarkdown(docId))))
.orElseGet(() -> ResponseEntity.notFound().build());
}
/**
* Record a clickwrap consent (e.g. the EULA accepted at trial start or quote generation).
* Best-effort — a teamless caller still returns 200 so the accompanying flow is never blocked.
*/
@PostMapping("/consent")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> consent(
@RequestBody ConsentRequest request, Authentication auth, HttpServletRequest http) {
if (request == null || request.documentId() == null || request.context() == null) {
return ResponseEntity.badRequest().build();
}
Optional<TeamMembership> membership = primaryMembership(auth);
Long teamId = membership.map(m -> m.getTeam().getId()).orElse(null);
Long userId = membership.map(m -> m.getUser().getId()).orElse(null);
// Best-effort for real: consent is audit metadata, not an authorisation gate, so a failed
// write must not fail the trial start or quote generation this call accompanies. Previously
// that only held because the caller happened to swallow the 500.
try {
consents.record(
teamId, userId, request.documentId(), request.context(), clientIp(http));
} catch (RuntimeException e) {
log.warn(
"[legal] consent not recorded doc={} context={}: {}",
request.documentId(),
request.context(),
e.getMessage());
}
return ResponseEntity.ok().build();
}
private Optional<TeamMembership> primaryMembership(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return Optional.empty();
}
return memberRepo.findPrimaryMembership(user.getId()).stream().findFirst();
}
/**
* Best guess at the caller's address, for the audit record.
*
* <p>Informational only, and must stay that way: the first {@code X-Forwarded-For} hop is set
* by the client, so a stored address is trivially spoofable and is not evidence of where a
* consent or signature came from. Treat it as a hint when reconstructing events, never as
* proof.
*/
private static String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
@@ -0,0 +1,27 @@
package stirling.software.saas.legal;
import java.util.List;
/**
* One legal document's registry entry, as declared in {@code legal/manifest.json}. Immutable
* snapshot loaded at startup by {@link LegalDocumentRegistry}.
*
* <p>{@code parts} lists the pieces, in render order, that make up the document. A plain entry
* (e.g. {@code "msa.md"}) is a static markdown file under {@code legal/<id>/<version>/}; an entry
* prefixed with {@code "@"} (e.g. {@code "@order-form"}) is a dynamic section that a document
* assembler generates at render time.
*/
public record LegalDocumentMeta(
String id,
String label,
String displayName,
String version,
String effectiveDate,
String status,
List<String> parts) {
/** Fully-qualified version label shown to users and stored on signatures, e.g. "SEA v0.9.1". */
public String versionLabel() {
return label + " v" + version;
}
}
@@ -0,0 +1,151 @@
package stirling.software.saas.legal;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
/**
* Loads the versioned legal-document registry from {@code legal/manifest.json} on startup and
* serves document metadata + rendered markdown from the classpath.
*
* <p>Publishing a new version of any document is a content-only change: drop the markdown under
* {@code legal/<id>/<newVersion>/} and bump that document's {@code version} in the manifest — no
* code change. Signatures pin the exact {@code {id, version, contentHash}} they were signed against
* (see the procurement agreement flow), so historical documents stay reproducible.
*
* <p>Token slots of the form <code>{{name}}</code> in the markdown are filled at render time. This
* registry fills the document-level common tokens ({@code version}, {@code version_date}, {@code
* subprocessor_url}, {@code eula_url}); callers that need per-quote tokens (the enterprise
* agreement's Order Form) fill the rest.
*/
@Slf4j
@Service
public class LegalDocumentRegistry {
private static final String MANIFEST = "legal/manifest.json";
private static final Pattern TOKEN = Pattern.compile("\\{\\{\\s*([a-zA-Z0-9_]+)\\s*}}");
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, LegalDocumentMeta> documents = new LinkedHashMap<>();
private String subprocessorUrl = "";
private String eulaUrl = "";
@PostConstruct
void load() throws IOException {
JsonNode root;
try (InputStream in = new ClassPathResource(MANIFEST).getInputStream()) {
root = objectMapper.readTree(in);
}
subprocessorUrl = root.path("subprocessorUrl").asText("");
eulaUrl = root.path("eulaUrl").asText("");
JsonNode docs = root.path("documents");
docs.fieldNames()
.forEachRemaining(
id -> {
JsonNode d = docs.get(id);
List<String> parts =
objectMapper.convertValue(
d.path("parts"),
objectMapper
.getTypeFactory()
.constructCollectionType(
List.class, String.class));
documents.put(
id,
new LegalDocumentMeta(
id,
d.path("label").asText(id),
d.path("displayName").asText(id),
d.path("version").asText("0"),
d.path("effectiveDate").asText(""),
d.path("status").asText("draft"),
parts == null ? List.of() : parts));
});
log.info("[legal] loaded {} document(s) from {}", documents.size(), MANIFEST);
}
public Optional<LegalDocumentMeta> meta(String docId) {
return Optional.ofNullable(documents.get(docId));
}
public String subprocessorUrl() {
return subprocessorUrl;
}
public String eulaUrl() {
return eulaUrl;
}
/** Document-level tokens available to every document (before any per-quote tokens). */
public Map<String, String> commonTokens(LegalDocumentMeta meta) {
Map<String, String> t = new LinkedHashMap<>();
t.put("version", meta.version());
t.put("version_date", meta.effectiveDate());
t.put("subprocessor_url", subprocessorUrl);
t.put("eula_url", eulaUrl);
return t;
}
/** Read one static markdown part of a document from the classpath. */
public String readPart(LegalDocumentMeta meta, String partFile) {
String path = "legal/" + meta.id() + "/" + meta.version() + "/" + partFile;
try (InputStream in = new ClassPathResource(path).getInputStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("Missing legal document part: " + path, e);
}
}
/**
* The concatenated static parts of a document (dynamic {@code @}-parts skipped), with only the
* common tokens filled. Use for fully-static documents (EULA, SLA, subprocessors).
*/
public String staticMarkdown(String docId) {
LegalDocumentMeta meta =
meta(docId)
.orElseThrow(
() -> new IllegalArgumentException("Unknown document: " + docId));
Map<String, String> tokens = commonTokens(meta);
StringBuilder sb = new StringBuilder();
for (String part : meta.parts()) {
if (part.startsWith("@")) continue; // dynamic section — not part of the static body
if (sb.length() > 0) sb.append("\n\n");
sb.append(fill(readPart(meta, part), tokens));
}
return sb.toString();
}
/** Replace {@code {{token}}} slots; unknown tokens are left intact so gaps are visible. */
public static String fill(String markdown, Map<String, String> tokens) {
Matcher m = TOKEN.matcher(markdown);
StringBuilder out = new StringBuilder();
while (m.find()) {
String key = m.group(1);
String value = tokens.get(key);
m.appendReplacement(
out,
value == null
? Matcher.quoteReplacement(m.group(0))
: Matcher.quoteReplacement(value));
}
m.appendTail(out);
return out.toString();
}
}
@@ -34,17 +34,22 @@ import lombok.Setter;
@Entity
@Table(
name = "payg_prepaid_bundle",
// Declared here for ddl-auto (fresh schemas) and to document intent. The authoritative creator
// in production is the Supabase CLI migration 20260720000000_payg_prepaid_bundle, which builds
// Declared here for ddl-auto (fresh schemas) and to document intent. The authoritative
// creator
// in production is the Supabase CLI migration 20260720000000_payg_prepaid_bundle, which
// builds
// the partial forms (WHERE units_remaining > 0 / WHERE stripe_ref IS NOT NULL). Flyway was
// retired for SaaS (#7100), so there is no migration twin — names match the CLI migration.
indexes = {
// Hot-path FIFO draw lookup — findDrawableForUpdate runs a locked read on every billable
// charge past the free grant; without it that degrades to a locked scan as the table grows.
// Hot-path FIFO draw lookup — findDrawableForUpdate runs a locked read on every
// billable
// charge past the free grant; without it that degrades to a locked scan as the table
// grows.
@Index(
name = "idx_payg_prepaid_bundle_team_expiry",
columnList = "team_id, expires_at"),
// One pool per Stripe payment — the idempotency guard so a redelivered invoice.paid can't
// One pool per Stripe payment — the idempotency guard so a redelivered invoice.paid
// can't
// credit the same purchase twice.
@Index(
name = "uq_payg_prepaid_bundle_stripe_ref",
@@ -22,6 +22,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
@@ -30,6 +32,8 @@ import stirling.software.proprietary.security.database.repository.UserRepository
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
import stirling.software.saas.procurement.legal.AgreementSigning;
import stirling.software.saas.procurement.model.ProcurementAgreementSignature;
import stirling.software.saas.procurement.model.ProcurementDeal;
import stirling.software.saas.procurement.model.ProcurementQuote;
import stirling.software.saas.procurement.model.QuoteDetails;
@@ -178,7 +182,17 @@ public class ProcurementController {
String taxId) {}
/** Trial setup captured before the trial starts: deployment target + seat count. */
public record StartTrialRequest(String deployment, int users) {}
/**
* Setup step 2 collects the buying entity; all of it is optional so an older client still
* starts.
*/
public record StartTrialRequest(
String deployment,
int users,
String businessName,
String contactName,
String contactEmail,
String inviteEmails) {}
public record SnapshotResponse(
Long dealId,
@@ -190,8 +204,33 @@ public class ProcurementController {
int trialExtensionsUsed,
boolean licensed,
String licenseKey,
// Version label of the signed agreement PDF available for download, else null.
String agreementSignedVersion,
// Buying entity captured at trial setup; null on deals started before that step.
String businessName,
String contactName,
String contactEmail,
QuoteResponse latestQuote) {}
/** The filled agreement for review: registry metadata + the rendered markdown body. */
public record AgreementDocumentResponse(
String docId,
String version,
String versionLabel,
String displayName,
String effectiveDate,
String status,
String markdown) {}
/** Buyer-supplied signing inputs from the agreement stage. */
public record SignAgreementRequest(
String customerLegalName,
String signatoryName,
String signatoryTitle,
boolean authorityConfirmed) {}
public record SignAgreementResponse(Long signatureId, String versionLabel, boolean pdfStored) {}
// ---- endpoints ----------------------------------------------------------
/**
@@ -213,7 +252,8 @@ public class ProcurementController {
}
private static final SnapshotResponse EMPTY_SNAPSHOT =
new SnapshotResponse(null, null, null, 0, null, null, 0, false, null, null);
new SnapshotResponse(
null, null, null, 0, null, null, 0, false, null, null, null, null, null, null);
/**
* Download the offline / air-gapped licence file (.lic) for the team — available for an
@@ -239,6 +279,15 @@ public class ProcurementController {
.orElseGet(() -> ResponseEntity.notFound().build());
}
/** Mark the account as looking at enterprise. Idempotent; never disturbs an existing deal. */
@PostMapping("/interest")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> recordInterest(Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return ResponseEntity.ok(toSnapshot(procurement.recordInterest(teamId), true));
}
@PostMapping("/trial/start")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> startTrial(
@@ -248,8 +297,30 @@ public class ProcurementController {
// Body is optional so an older client (no setup step) still starts a cloud trial.
String deployment = request != null ? request.deployment() : null;
int seats = request != null ? request.users() : 0;
return ResponseEntity.ok(
toSnapshot(procurement.startTrial(teamId, deployment, seats), true));
ProcurementDeal deal;
try {
deal =
procurement.startTrial(
teamId,
deployment,
seats,
request != null ? request.businessName() : null,
request != null ? request.contactName() : null,
request != null ? request.contactEmail() : null,
request != null ? request.inviteEmails() : null);
} catch (IllegalStateException e) {
// Past the trial the deal holds a committed licence; restarting would replace it.
log.warn("[procurement] trial start rejected team={}: {}", teamId, e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
// After the trial exists, so a rejected invite can never stop it starting.
if (request != null) {
procurement.sendTrialInvites(
teamId,
primaryMembership(auth).map(TeamMembership::getUser).orElse(null),
request.inviteEmails());
}
return ResponseEntity.ok(toSnapshot(deal, true));
}
@PostMapping("/trial/extend")
@@ -270,8 +341,17 @@ public class ProcurementController {
@RequestBody QuoteRequest request, Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return ResponseEntity.ok(
toQuote(procurement.buildQuote(teamId, request.toConfig(), request.toDetails())));
try {
return ResponseEntity.ok(
toQuote(
procurement.buildQuote(
teamId, request.toConfig(), request.toDetails())));
} catch (IllegalStateException e) {
// Below the minimum deal size, or the deal is already live. A client error, not a
// fault.
log.warn("[procurement] quote rejected team={}: {}", teamId, e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
// Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a
@@ -293,6 +373,111 @@ public class ProcurementController {
}
}
/**
* The filled Stirling Enterprise Agreement (MSA + Order Form + DPA) for the team's current
* quote, as markdown, for the buyer to review before signing. 404 when there's no quote yet.
*/
@GetMapping("/agreement/document")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<AgreementDocumentResponse> agreementDocument(Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return procurement
.agreementDocument(teamId)
.<ResponseEntity<AgreementDocumentResponse>>map(
a ->
ResponseEntity.ok(
new AgreementDocumentResponse(
a.docId(),
a.version(),
a.versionLabel(),
a.displayName(),
a.effectiveDate(),
a.status(),
a.markdown())))
.orElseGet(() -> ResponseEntity.notFound().build());
}
/**
* Record a signed agreement: capture the typed legal name / signatory / title / authority, pin
* the exact document version + content hash + variable snapshot, and store the rendered PDF
* (best-effort). The caller then proceeds to accept the quote as before.
*/
@PostMapping("/agreement/sign")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SignAgreementResponse> signAgreement(
@RequestBody SignAgreementRequest request,
Authentication auth,
HttpServletRequest http) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
if (request == null
|| request.signatoryName() == null
|| request.signatoryName().isBlank()
|| !request.authorityConfirmed()) {
return ResponseEntity.badRequest().build();
}
try {
ProcurementAgreementSignature sig =
procurement.signAgreement(
teamId,
new AgreementSigning(
request.customerLegalName(),
request.signatoryName(),
request.signatoryTitle(),
request.authorityConfirmed()),
clientIp(http));
return ResponseEntity.ok(
new SignAgreementResponse(
sig.getSignatureId(), sig.getDocumentLabel(), sig.getPdf() != null));
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
/** Download the stored signed-agreement PDF for the team. 404 if none was rendered/stored. */
@GetMapping("/agreement/signature/pdf")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<byte[]> signaturePdf(Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return procurement
.signedAgreementPdf(teamId)
.<ResponseEntity<byte[]>>map(
pdf ->
ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment;"
+ " filename=\"stirling-enterprise-agreement.pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf))
.orElseGet(() -> ResponseEntity.notFound().build());
}
/**
* Download the current (unsigned) agreement as a PDF — the document shown at the sign step. 404
* when there's no quote yet or the render runtime is unavailable.
*/
@GetMapping("/agreement/document/pdf")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<byte[]> agreementDocumentPdf(Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return procurement
.agreementDocumentPdf(teamId)
.<ResponseEntity<byte[]>>map(
pdf ->
ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment;"
+ " filename=\"stirling-enterprise-agreement.pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf))
.orElseGet(() -> ResponseEntity.notFound().build());
}
/**
* Provision on accept: upgrade the team's licence to the committed annual term, valid
* immediately. Called server-side by the accept edge function (ROLE_ADMIN via X-API-Key) once
@@ -311,9 +496,38 @@ public class ProcurementController {
}
}
/**
* Go live once payment settles: advance the deal to active and re-affirm the annual licence.
* Called server-side by the {@code invoice.paid} webhook (ROLE_ADMIN via X-API-Key), alongside
* {@code /provision}, which runs earlier at accept and deliberately leaves the stage alone.
*
* <p>Answers 200 when the team has no deal at all, rather than erroring: a committed
* subscription can be closed directly in Stripe by sales with no portal deal behind it, and a
* non-2xx would have Stripe retry a webhook that can never succeed.
*
* <p>{@code invoiceId} is what makes this idempotent without swallowing renewals: the same
* invoice twice is a redelivery, a different one is next year's payment and has to re-issue the
* licence. Optional so an older caller still works, at the cost of that distinction.
*/
@PostMapping("/activate")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> activate(
@RequestParam("teamId") long teamId,
@RequestParam(value = "invoiceId", required = false) String invoiceId) {
try {
procurement.markLive(teamId, invoiceId);
} catch (IllegalStateException e) {
log.info(
"[procurement] activate skipped, no deal for team={}: {}",
teamId,
e.getMessage());
}
return ResponseEntity.ok().build();
}
/**
* Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the
* annual licence, advance to active). The real go-live is webhook-driven once payment settles.
* annual licence, advance to active). Production go-live runs through {@code /activate}.
*/
@PostMapping("/go-live")
@PreAuthorize("isAuthenticated()")
@@ -360,6 +574,21 @@ public class ProcurementController {
.orElse(null);
}
/**
* Best-effort client IP for the signature record: first X-Forwarded-For hop, else the peer.
*
* <p>Informational only. That header is client-set, so {@code signer_ip} is spoofable and is
* not evidence of where a signature came from — the document hash and version are what make the
* record trustworthy. Treat the address as a hint, never as proof.
*/
private static String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
/**
* Build the snapshot for a deal. {@code includeLicenseKey} is true only for the team leader; a
* member sees {@code licensed} but not the key itself (see {@link #snapshot}). Mutation
@@ -381,6 +610,10 @@ public class ProcurementController {
deal.getTrialExtensionsUsed(),
deal.getLicenseRef() != null,
includeLicenseKey ? deal.getLicenseRef() : null,
procurement.signedAgreementLabel(deal.getDealId()).orElse(null),
deal.getBusinessName(),
deal.getContactName(),
deal.getContactEmail(),
latest);
}
@@ -30,4 +30,17 @@ public class ProcurementConfigurationProperties {
* /go-live is a stand-in for the invoice.paid webhook and would let a leader activate unpaid.
*/
private boolean demoControlsEnabled = false;
/**
* Smallest annual fee, in minor units, that may be quoted. The pricing curve has no natural
* floor — a small enough committed volume rounds the meter to zero — and every registered user
* is the leader of their own team, so without this any signup could price a $0 enterprise
* quote, accept it, and be provisioned a committed licence. 12_000_00 is USD 12,000/yr, the
* self-hosted deploy fee, chosen so the floor cannot sit below a line item the quote itself can
* contain.
*
* <p>This is a commercial number, not a technical one: set it to whatever the smallest
* enterprise deal you will actually sign is. Zero disables the check.
*/
private long minAnnualNetMinor = 12_000_00L;
}
@@ -0,0 +1,278 @@
package stirling.software.saas.procurement.legal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.legal.LegalDocumentMeta;
import stirling.software.saas.legal.LegalDocumentRegistry;
import stirling.software.saas.procurement.model.ProcurementQuote;
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
import stirling.software.saas.procurement.pricing.QuoteConfig;
import stirling.software.saas.procurement.pricing.QuoteLineItem;
/**
* Builds the full Stirling Enterprise Agreement for a specific quote: the static MSA (Part A) and
* DPA (Part C) from the {@link LegalDocumentRegistry}, with the dynamic Order Form (Part B)
* generated from the quote and slotted where the manifest's {@code @order-form} part sits.
*
* <p>Only the Order Form varies per deal; the MSA and DPA bodies are rendered verbatim with token
* substitution. The set of values used is returned as {@code variablesJson} so a signature can pin
* exactly what was rendered.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AgreementAssembler {
public static final String DOC_ID = "enterprise-agreement";
private static final DateTimeFormatter DATE =
DateTimeFormatter.ofPattern("MMMM d, yyyy", Locale.US);
private static final String BLANK = "\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_";
private final LegalDocumentRegistry registry;
private final ProcurementPricingService pricing;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Render the agreement for a quote. {@code signing} is null for a preview (before signing) —
* the effective date and signature block then read as blanks / "On signature".
*/
public AssembledAgreement assemble(ProcurementQuote quote, AgreementSigning signing) {
LegalDocumentMeta meta =
registry.meta(DOC_ID)
.orElseThrow(
() ->
new IllegalStateException(
"Enterprise agreement not registered"));
Map<String, String> tokens = tokens(quote, signing, meta);
StringBuilder md = new StringBuilder();
for (String part : meta.parts()) {
if (md.length() > 0) md.append("\n\n");
if ("@order-form".equals(part)) {
md.append(LegalDocumentRegistry.fill(orderForm(quote, tokens), tokens));
} else {
md.append(LegalDocumentRegistry.fill(registry.readPart(meta, part), tokens));
}
}
String variablesJson;
try {
variablesJson = objectMapper.writeValueAsString(tokens);
} catch (Exception e) {
variablesJson = "{}";
}
return new AssembledAgreement(
meta.id(),
meta.version(),
meta.versionLabel(),
meta.displayName(),
meta.effectiveDate(),
meta.status(),
md.toString(),
variablesJson);
}
private Map<String, String> tokens(
ProcurementQuote quote, AgreementSigning signing, LegalDocumentMeta meta) {
QuoteConfig cfg = toConfig(quote);
boolean signed = signing != null;
String legalName =
signed && notBlank(signing.customerLegalName())
? signing.customerLegalName().trim()
: (notBlank(quote.getBusinessName())
? quote.getBusinessName().trim()
: "Customer");
Map<String, String> t = new LinkedHashMap<>(registry.commonTokens(meta));
t.put("effective_date", signed ? LocalDate.now().format(DATE) : "On signature");
t.put("customer_legal_name", cell(legalName));
t.put("quote_ref", nz(quote.getQuoteNumber()));
t.put("deployment", ProcurementPricingService.deploymentName(quote.getDeployment()));
t.put("committed_pdfs_yr", String.format(Locale.US, "%,d", Math.max(0, quote.getVolume())));
t.put("posture", ProcurementPricingService.postureName(quote.getIntensity()));
t.put("processes_per_pdf", String.valueOf(Math.max(1, quote.getIntensity())));
t.put("rate_per_pdf", String.format(Locale.US, "$%.4f", pricing.effectiveRatePerPdf(cfg)));
t.put("term_years", String.valueOf(quote.getTermYears()));
t.put("term_discount_pct", pricing.termDiscountPct(quote.getTermYears()) + "%");
t.put("sla_tier", slaTier(quote.getServiceLevel()));
t.put("annual_fee_y1", money(quote.getAnnualNetMinor()));
t.put("contract_total", money(quote.getTcvMinor()));
t.put("elected_or_not", quote.isIndemnification() ? "Elected" : "Not elected");
t.put("po_number", notBlank(quote.getPoNumber()) ? cell(quote.getPoNumber()) : "");
t.put(
"customer_signatory",
signed && notBlank(signing.signatoryName())
? cell(signing.signatoryName())
: BLANK);
t.put(
"customer_signatory_title",
signed && notBlank(signing.signatoryTitle())
? cell(signing.signatoryTitle())
: BLANK);
return t;
}
/**
* Make a buyer-supplied value safe to slot into a markdown table cell.
*
* <p>An unescaped {@code |} or newline splits the cell and breaks the Order Form's table. That
* matters beyond appearance: this markdown is what gets hashed into the signature record, so a
* value that restructures the table means the SHA-256 we keep as proof covers a document
* reading differently from the one the signatory saw.
*/
private static String cell(String raw) {
return raw.trim().replace("|", "\\|").replaceAll("\\s*\\R+\\s*", " ");
}
/** Part B — the Order Form. Generated from the quote; the only per-deal section. */
private String orderForm(ProcurementQuote quote, Map<String, String> t) {
String date = t.get("effective_date");
String signatory = t.get("customer_signatory");
String signatoryTitle = t.get("customer_signatory_title");
StringBuilder sb = new StringBuilder();
sb.append("## Part B — Order Form · {{quote_ref}}\n\n");
sb.append("| Term | Value |\n| --- | --- |\n");
row(sb, "Customer", "{{customer_legal_name}}");
row(sb, "Subscription", "Enterprise · {{deployment}}");
row(sb, "Purchase order", "{{po_number}}");
row(sb, "Committed Volume", "{{committed_pdfs_yr}} PDFs / year at the {{posture}} posture");
row(sb, "Committed rate", "{{rate_per_pdf}} per PDF");
row(sb, "Service level", "{{sla_tier}} (per SLA Exhibit)");
row(
sb,
"Term",
"{{term_years}} year(s) · term discount {{term_discount_pct}} on committed processing");
row(sb, "Itemized services", itemizedServices(quote));
row(sb, "Annual Fee (year 1)", "{{annual_fee_y1}}");
row(sb, "Total (paid in advance)", "{{contract_total}}");
row(sb, "Escalator", "+3% at each anniversary during the Term");
row(
sb,
"Payment",
"Full {{term_years}}-year term invoiced in advance on acceptance · net 30 · ACH,"
+ " wire, or check");
row(sb, "Overage", "Committed rate, billed quarterly in arrears");
row(
sb,
"Data schedule",
"First 25 MB per file included; each additional 25 MB or part thereof (decimal MB,"
+ " rounded up per file, measured once at ingestion) draws down 1 PDF Process."
+ " Frozen for the Term (MSA §3.5).");
row(
sb,
"Drawdown schedule",
"{{posture}}: {{processes_per_pdf}} PDF Processes per PDF (MSA §3.3, frozen for the Term)");
row(
sb,
"Enhanced IP Protection",
"{{elected_or_not}} — extends §7.3 to patent claims at the §8.2 super-cap");
row(sb, "Standard terms", "SSO, SCIM, RBAC, and audit logs included.");
sb.append(
"\n**Itemized services menu (include as elected):** Self-hosted deployment $12,000/yr"
+ " · Air-gapped deployment $36,000/yr · Dedicated SE/CSM $30,000/yr · Enhanced IP"
+ " Protection (patent coverage, Section 7.3) 5% of committed processing fees ·"
+ " Onboarding & training $7,500 one-time · Quarterly business reviews $8,000/yr."
+ " Baseline IP indemnification (copyright, trademark, trade secret) is included at"
+ " no charge.\n\n");
sb.append(
"**Signatures.** By signing, each signatory represents they have authority to bind"
+ " their Party. Signatures delivered electronically or in counterparts are"
+ " effective as originals.\n\n");
sb.append("| Provider | Customer |\n| --- | --- |\n");
sb.append("| Stirling PDF, Inc. | {{customer_legal_name}} |\n");
sb.append("| Name: Matt Joseph | Name: ").append(signatory).append(" |\n");
sb.append("| Title: CEO | Title: ").append(signatoryTitle).append(" |\n");
sb.append("| Date: ").append(date).append(" | Date: ").append(date).append(" |\n");
return sb.toString();
}
/**
* The elected add-on lines, taken from the quote's stored breakdown (excludes the base meter).
*/
private String itemizedServices(ProcurementQuote quote) {
List<QuoteLineItem> lines = parseLineItems(quote.getLineItemsJson());
List<String> elected = new ArrayList<>();
for (QuoteLineItem li : lines) {
if (li.key().equals("usage")
|| li.key().equals("seats")
|| li.key().equals("multi-year")) {
continue;
}
String suffix = li.kind() == QuoteLineItem.Kind.ONE_TIME ? " (one-time)" : "/yr";
elected.add(li.label() + " " + money(li.amountMinor()) + suffix);
}
return elected.isEmpty() ? "None elected" : String.join(" · ", elected);
}
private List<QuoteLineItem> parseLineItems(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(
json,
objectMapper
.getTypeFactory()
.constructCollectionType(List.class, QuoteLineItem.class));
} catch (Exception e) {
log.warn("[legal] could not parse quote line items for the order form", e);
return List.of();
}
}
private static QuoteConfig toConfig(ProcurementQuote q) {
int users = q.getSeats() == null ? 0 : q.getSeats();
return new QuoteConfig(
q.getVolume(),
users,
q.getIntensity(),
q.getSizeMult(),
q.getDeployment(),
q.getTermYears(),
q.getServiceLevel(),
q.isIndemnification(),
q.isTraining(),
q.isQbr(),
q.getCurrency());
}
private static void row(StringBuilder sb, String term, String value) {
sb.append("| ").append(term).append(" | ").append(value).append(" |\n");
}
private static String slaTier(String serviceLevel) {
if ("dedicated".equalsIgnoreCase(serviceLevel)) return "Dedicated";
if ("priority".equalsIgnoreCase(serviceLevel)) return "Priority";
return "Standard";
}
/** Minor units (cents) → whole-dollar display; the quote figures are whole dollars. */
private static String money(long minor) {
return String.format(Locale.US, "$%,d", minor / 100L);
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
private static String nz(String s) {
return s == null ? "" : s;
}
}
@@ -0,0 +1,71 @@
package stirling.software.saas.procurement.legal;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.commonmark.Extension;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.TempFileManager;
/**
* Renders an assembled agreement's markdown to a PDF, dogfooding Stirling's own conversion path:
* commonmark (markdown → HTML) then {@link FileToPdf#convertHtmlToPdf} (HTML → PDF via WeasyPrint),
* the same pipeline as the product's Markdown-to-PDF tool.
*
* <p>The signed PDF is a stored artifact, but it must never block signing: {@link #tryRender}
* returns {@code null} if the conversion runtime (WeasyPrint) is unavailable, so the signature is
* still recorded and the buyer keeps the on-the-fly download.
*/
@Service
@RequiredArgsConstructor
public class AgreementPdfRenderer {
private final RuntimePathConfig runtimePathConfig;
private final TempFileManager tempFileManager;
private final CustomHtmlSanitizer customHtmlSanitizer;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private static final List<Extension> EXTENSIONS = List.of(TablesExtension.create());
/** Render to PDF, or return null if the conversion runtime isn't available. */
public byte[] tryRender(String markdown) {
try {
return render(markdown);
} catch (Exception e) {
org.slf4j.LoggerFactory.getLogger(AgreementPdfRenderer.class)
.warn(
"[legal] agreement PDF render unavailable; recording signature without a"
+ " stored PDF: {}",
e.getMessage());
return null;
}
}
private byte[] render(String markdown) throws Exception {
Parser parser = Parser.builder().extensions(EXTENSIONS).build();
Node document = parser.parse(markdown);
HtmlRenderer renderer = HtmlRenderer.builder().extensions(EXTENSIONS).build();
String html = renderer.render(document);
byte[] pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
null,
html.getBytes(StandardCharsets.UTF_8),
"agreement.html",
tempFileManager,
customHtmlSanitizer);
return pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
}
}
@@ -0,0 +1,12 @@
package stirling.software.saas.procurement.legal;
/**
* The buyer-supplied inputs captured at the moment of signing the enterprise agreement: the legal
* entity name, the signatory's typed name and title, and their representation of authority to bind.
* Null when the agreement is rendered for preview (before signing).
*/
public record AgreementSigning(
String customerLegalName,
String signatoryName,
String signatoryTitle,
boolean authorityConfirmed) {}
@@ -0,0 +1,16 @@
package stirling.software.saas.procurement.legal;
/**
* A rendered enterprise agreement: the full markdown the buyer sees (MSA + Order Form + DPA, tokens
* filled), plus the registry metadata that pins it. {@code variablesJson} is the exact set of
* Order-Form values as rendered, stored alongside a signature so the document is reproducible.
*/
public record AssembledAgreement(
String docId,
String version,
String versionLabel,
String displayName,
String effectiveDate,
String status,
String markdown,
String variablesJson) {}
@@ -0,0 +1,86 @@
package stirling.software.saas.procurement.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* An immutable record of a signed enterprise agreement. Each signature pins the exact legal
* document it was signed against — {@code documentId} + {@code documentVersion} + a SHA-256 {@code
* contentHash} of the rendered markdown — plus the Order-Form variable snapshot and the typed
* signatory details, so the agreement stays reproducible even after the templates version up. The
* rendered PDF is stored when the conversion runtime is available.
*/
@Entity
@Table(name = "procurement_agreement_signature")
@NoArgsConstructor
@Getter
@Setter
public class ProcurementAgreementSignature implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "signature_id")
private Long signatureId;
@Column(name = "deal_id", nullable = false)
private Long dealId;
@Column(name = "quote_id", nullable = false)
private Long quoteId;
// Which legal document, and which version of it, was signed.
@Column(name = "document_id", nullable = false, length = 64)
private String documentId;
@Column(name = "document_version", nullable = false, length = 32)
private String documentVersion;
@Column(name = "document_label", length = 64)
private String documentLabel;
// SHA-256 (hex) of the exact rendered agreement markdown the buyer accepted.
@Column(name = "content_hash", nullable = false, length = 64)
private String contentHash;
// The Order-Form variable values as rendered, so the document can be reproduced.
@Column(name = "variables_json", columnDefinition = "text")
private String variablesJson;
@Column(name = "customer_legal_name", length = 255)
private String customerLegalName;
@Column(name = "signatory_name", nullable = false, length = 255)
private String signatoryName;
@Column(name = "signatory_title", length = 255)
private String signatoryTitle;
@Column(name = "authority_confirmed", nullable = false)
private boolean authorityConfirmed;
@Column(name = "signer_ip", length = 64)
private String signerIp;
// The rendered PDF artifact; null when the conversion runtime was unavailable at signing.
@Column(name = "pdf")
private byte[] pdf;
@CreationTimestamp
@Column(name = "signed_at", nullable = false, updatable = false)
private LocalDateTime signedAt;
}
@@ -34,6 +34,13 @@ public class ProcurementDeal implements Serializable {
private static final long serialVersionUID = 1L;
/**
* Interest, before any commitment: the account asked about enterprise but has not started a
* trial. Kept as a real stage so intent survives a refresh, so the enterprise surface is only
* shown to accounts that asked for it, and so drop-off at the cheapest step is measurable.
*/
public static final String STAGE_EXPLORING = "exploring";
public static final String STAGE_TRIAL = "trial";
public static final String STAGE_QUOTE = "quote";
public static final String STAGE_AGREEMENT = "security";
@@ -69,12 +76,38 @@ public class ProcurementDeal implements Serializable {
@Column(name = "trial_extensions_used", nullable = false)
private int trialExtensionsUsed;
// Captured at trial setup, so the buying entity is known before any quote exists — the quote's
// own copies seed from these and may then diverge (a deal can change hands mid-cycle).
// Nullable: trials started before this step, and older clients, supply none.
@Column(name = "business_name", length = 255)
private String businessName;
@Column(name = "contact_name", length = 255)
private String contactName;
@Column(name = "contact_email", length = 320)
private String contactEmail;
// Addresses the buyer named at setup. Kept as the record of what was asked for; the invitations
// themselves go out through the team-invite path when the trial starts.
@Column(name = "invite_emails", length = 2000)
private String inviteEmails;
@Column(name = "license_ref", length = 128)
private String licenseRef;
@Column(name = "subscription_id", length = 255)
private String subscriptionId;
/**
* The last Stripe invoice whose payment was applied to this deal. Distinguishes a redelivered
* {@code invoice.paid} for a payment already handled from a genuine renewal, which has to
* re-issue: the committed licence expires term years from issue, so a renewal that doesn't
* re-issue leaves the licence lapsing after the customer has paid.
*/
@Column(name = "last_paid_invoice_id", length = 255)
private String lastPaidInvoiceId;
@Column(name = "accepted_quote_id")
private Long acceptedQuoteId;
@@ -47,7 +47,11 @@ public class ProcurementQuote implements Serializable {
@Column(name = "deal_id", nullable = false)
private Long dealId;
@Column(name = "quote_number", nullable = false, length = 64)
/**
* Stripe's quote number, the deal's one buyer-facing reference. Null until the quote is issued:
* Stripe assigns it at finalisation, and the issue edge function writes it back then.
*/
@Column(name = "quote_number", length = 64)
private String quoteNumber;
@Column(name = "status", nullable = false, length = 24)
@@ -60,12 +60,7 @@ public class ProcurementPricingService {
rates.discountPerDoubling()
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
: 0.0;
double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc));
// File-size multiplier (D93): larger, image-heavy PDFs cost more OCR/compute/storage. Folds
// into the per-run rate after the floor, so it flows through the meter, TCV and renewal.
// QuoteConfig has already snapped it to a known tier, so a tampered request can't sneak a
// cheaper factor in.
rate *= cfg.sizeMult();
double rate = perRunRate(cfg, rates);
double termDisc = rates.termDiscount(cfg.termYears());
// The meter is a whole-dollar figure (the quote reads in dollars), then minor units.
@@ -160,6 +155,48 @@ public class ProcurementPricingService {
return new QuoteBreakdown(lines, annualNet, tcv, renewalAnnual, cfg.currency());
}
/**
* The per-run rate after the committed-volume curve, the half-cent floor, and the file-size
* multiplier — the same value {@link #price} meters against. Extracted so read-only callers
* (the Order Form) can quote it without re-deriving the curve.
*/
private static double perRunRate(QuoteConfig cfg, PricingRates rates) {
long runVol = Math.max(0, cfg.volume()) * (long) Math.max(1, cfg.intensity());
double volDisc =
runVol > RUN_CURVE_KNEE
? Math.min(
0.5,
rates.discountPerDoubling()
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
: 0.0;
return Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc))
* cfg.sizeMult();
}
/**
* The effective per-PDF rate at the chosen posture, in dollars (4-decimal quote figure). This
* is what the Order Form and quote copy speak in — never the per-run rate. Read-only; does not
* affect billing.
*/
public double effectiveRatePerPdf(QuoteConfig cfg) {
return perRunRate(cfg, PricingRates.defaults()) * Math.max(1, cfg.intensity());
}
/** The multi-year term discount as a whole-percent figure for the Order Form (0.05 → 5). */
public int termDiscountPct(int termYears) {
return (int) Math.round(PricingRates.defaults().termDiscount(termYears) * 100.0);
}
/** Buyer-facing posture name (Essentials / Governed / Regulated) for the given intensity. */
public static String postureName(int intensity) {
return postureLabel(intensity);
}
/** Buyer-facing deployment name (Stirling Cloud / Self-hosted / Air-gapped). */
public static String deploymentName(String deployment) {
return deploymentLabel(deployment);
}
/** The default CPI escalator (fraction) applied to the annual fee on each post-term renewal. */
public double cpiEscalator() {
return PricingRates.defaults().cpiEscalator();
@@ -0,0 +1,29 @@
package stirling.software.saas.procurement.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import stirling.software.saas.procurement.model.ProcurementAgreementSignature;
public interface ProcurementAgreementSignatureRepository
extends JpaRepository<ProcurementAgreementSignature, Long> {
Optional<ProcurementAgreementSignature> findFirstByDealIdOrderBySignedAtDesc(Long dealId);
Optional<ProcurementAgreementSignature> findFirstByQuoteIdOrderBySignedAtDesc(Long quoteId);
/**
* Version labels of a deal's signatures, newest first. Projects just the label column so the
* frequently-polled snapshot never loads the PDF bytes. A signature means the agreement is
* signed; the PDF is resolved (stored or re-rendered) at download time.
*/
@Query(
"SELECT s.documentLabel FROM ProcurementAgreementSignature s"
+ " WHERE s.dealId = :dealId"
+ " ORDER BY s.signedAt DESC")
List<String> findSignedLabels(@Param("dealId") Long dealId);
}
@@ -5,7 +5,6 @@ import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
@@ -20,16 +19,24 @@ import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
import stirling.software.saas.procurement.legal.AgreementAssembler;
import stirling.software.saas.procurement.legal.AgreementPdfRenderer;
import stirling.software.saas.procurement.legal.AgreementSigning;
import stirling.software.saas.procurement.legal.AssembledAgreement;
import stirling.software.saas.procurement.license.EnterpriseLicenseService;
import stirling.software.saas.procurement.license.LicenseEntitlements;
import stirling.software.saas.procurement.model.ProcurementAgreementSignature;
import stirling.software.saas.procurement.model.ProcurementDeal;
import stirling.software.saas.procurement.model.ProcurementQuote;
import stirling.software.saas.procurement.model.QuoteDetails;
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
import stirling.software.saas.procurement.pricing.QuoteBreakdown;
import stirling.software.saas.procurement.pricing.QuoteConfig;
import stirling.software.saas.procurement.repository.ProcurementAgreementSignatureRepository;
import stirling.software.saas.procurement.repository.ProcurementDealRepository;
import stirling.software.saas.procurement.repository.ProcurementQuoteRepository;
import stirling.software.saas.service.SaasTeamService;
import stirling.software.saas.util.LogRedactionUtils;
/**
* Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a
@@ -52,6 +59,12 @@ public class ProcurementService {
private final EnterpriseLicenseService licenses;
private final ProcurementConfigurationProperties config;
private final TeamMembershipRepository memberRepo;
private final AgreementAssembler agreementAssembler;
private final AgreementPdfRenderer agreementPdfRenderer;
private final ProcurementAgreementSignatureRepository signatureRepo;
// Trial-setup invitations run through the team-invite path, with its seat, role and
// rate-limit rules rather than a second implementation here.
private final SaasTeamService teams;
public ProcurementService(
ProcurementDealRepository dealRepo,
@@ -59,13 +72,21 @@ public class ProcurementService {
ProcurementPricingService pricing,
EnterpriseLicenseService licenses,
ProcurementConfigurationProperties config,
TeamMembershipRepository memberRepo) {
TeamMembershipRepository memberRepo,
AgreementAssembler agreementAssembler,
AgreementPdfRenderer agreementPdfRenderer,
ProcurementAgreementSignatureRepository signatureRepo,
SaasTeamService teams) {
this.dealRepo = dealRepo;
this.quoteRepo = quoteRepo;
this.pricing = pricing;
this.licenses = licenses;
this.config = config;
this.memberRepo = memberRepo;
this.agreementAssembler = agreementAssembler;
this.agreementPdfRenderer = agreementPdfRenderer;
this.signatureRepo = signatureRepo;
this.teams = teams;
}
/**
@@ -89,11 +110,45 @@ public class ProcurementService {
return dealRepo.findByTeamId(teamId);
}
/**
* Starting a trial is only legitimate before one exists, while the buyer is still exploring, or
* to restart within the trial itself. A null stage is a deal that has just been constructed.
*
* <p>Package-private so the policy can be tested without the service's ten dependencies. Adding
* a later stage here would let a leader replace a paying customer's committed licence.
*/
static boolean canStartTrial(String stage) {
return stage == null
|| ProcurementDeal.STAGE_EXPLORING.equals(stage)
|| ProcurementDeal.STAGE_TRIAL.equals(stage);
}
@Transactional(readOnly = true)
public List<ProcurementQuote> quotesForDeal(Long dealId) {
return quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId);
}
/**
* Record that the account is looking at enterprise. Creates the deal at {@code exploring} when
* there is none; an existing deal is returned untouched, so this can never walk a live deal
* backwards or restart a trial.
*/
@Transactional
public ProcurementDeal recordInterest(Long teamId) {
return dealRepo.findByTeamId(teamId)
.orElseGet(
() -> {
ProcurementDeal deal = new ProcurementDeal(teamId);
deal.setStage(ProcurementDeal.STAGE_EXPLORING);
ProcurementDeal saved = dealRepo.save(deal);
log.info(
"[procurement] interest recorded team={} deal={}",
teamId,
saved.getDealId());
return saved;
});
}
/**
* Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial
* window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the
@@ -101,10 +156,40 @@ public class ProcurementService {
* ({@code cloud}/{@code selfhost}/{@code airgap}) and seat count are captured here so the quote
* builder opens seeded to their environment; both are still editable when the quote is built.
*/
@Transactional
public ProcurementDeal startTrial(Long teamId, String deployment, int seats) {
return startTrial(teamId, deployment, seats, null, null, null, null);
}
/**
* Start (or restart) the trial, capturing the buying entity if the setup step collected it.
* Blank details are ignored rather than written, so a re-run without them keeps what is there.
*
* <p>Only from before the trial or during it. Past that, {@code licenseRef} points at the
* committed annual licence, and this method would replace it with a fresh 14-day trial key
* while Stripe kept billing — the same hazard {@link #extendTrial} guards against, one step
* worse because it re-issues rather than re-dates. It would also rewind the stage and reset the
* extension counter.
*
* <p>The transaction is declared here rather than on the 3-arg overload: that one only
* delegates, and Spring's proxy cannot intercept a self-invocation, so an annotation there does
* nothing for either path. This is the method the controller calls, and it reaches out to
* Keygen between the read and the write.
*/
@Transactional
public ProcurementDeal startTrial(
Long teamId,
String deployment,
int seats,
String businessName,
String contactName,
String contactEmail,
String inviteEmails) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
if (!canStartTrial(deal.getStage())) {
throw new IllegalStateException(
"Trial cannot be started from stage " + deal.getStage());
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime ends = now.plusDays(config.getTrialDurationDays());
deal.setStage(ProcurementDeal.STAGE_TRIAL);
@@ -113,6 +198,10 @@ public class ProcurementService {
deal.setTrialStartedAt(now);
deal.setTrialEndsAt(ends);
deal.setTrialExtensionsUsed(0);
if (isNotBlank(businessName)) deal.setBusinessName(businessName.trim());
if (isNotBlank(contactName)) deal.setContactName(contactName.trim());
if (isNotBlank(contactEmail)) deal.setContactEmail(contactEmail.trim());
if (isNotBlank(inviteEmails)) deal.setInviteEmails(inviteEmails.trim());
deal.setLicenseRef(licenses.issueTrialLicense(teamId, leaderEmail(teamId), ends));
deal = dealRepo.save(deal);
log.info(
@@ -128,6 +217,45 @@ public class ProcurementService {
/**
* Constrain a caller-supplied deployment to the known set; anything else falls back to cloud.
*/
/**
* Send the invitations named at trial setup. Best-effort per address: a rejection (already a
* member, an invitee with their own paid plan, the hourly rate limit) must not fail the trial,
* so each is logged and skipped rather than propagated.
*
* <p>Note the first accepted invitation converts a personal team into a shared one with
* unlimited seats — that is {@code inviteUserToTeam}'s own rule, and naming teammates here is
* the buyer asking for exactly that.
*/
public void sendTrialInvites(
Long teamId, stirling.software.proprietary.security.model.User inviter, String emails) {
if (inviter == null || !isNotBlank(emails)) return;
for (String raw : emails.split("[,;\s]+")) {
String email = raw.trim();
if (email.isEmpty()) continue;
try {
teams.inviteUserToTeam(teamId, email, inviter);
// Redacted: an invitee list is third-party PII, and these logs are the one place it
// would otherwise be written in full. LogRedactionUtils is what the rest of the
// SaaS
// module uses for the same reason.
log.info(
"[procurement] trial invite sent team={} to={}",
teamId,
LogRedactionUtils.redactEmail(email));
} catch (Exception e) {
log.warn(
"[procurement] trial invite skipped team={} to={}: {}",
teamId,
LogRedactionUtils.redactEmail(email),
e.getMessage());
}
}
}
private static boolean isNotBlank(String value) {
return value != null && !value.isBlank();
}
private static String normalizeDeployment(String deployment) {
if (deployment == null) return "cloud";
String d = deployment.trim().toLowerCase(Locale.ROOT);
@@ -172,16 +300,32 @@ public class ProcurementService {
}
// (Re)building a quote returns the deal to the quote stage and drops any prior acceptance,
// so a rebuild from security/payment can't leave a stale stage or accepted-quote pointer.
QuoteBreakdown breakdown = pricing.price(cfg);
// Enforced before anything is persisted, and server-side rather than in the builder: the
// pricing curve has no natural floor (a small enough committed volume rounds the meter to
// zero) and every registered user leads their own team, so without this any signup could
// price a $0 enterprise quote, accept it, and be provisioned a committed licence.
long floor = config.getMinAnnualNetMinor();
if (floor > 0 && breakdown.annualNetMinor() < floor) {
throw new IllegalStateException(
"Quoted annual fee "
+ breakdown.annualNetMinor()
+ " is below the minimum enterprise deal size "
+ floor);
}
deal.setStage(ProcurementDeal.STAGE_QUOTE);
deal.setAcceptedQuoteId(null);
deal = dealRepo.save(deal);
QuoteBreakdown breakdown = pricing.price(cfg);
ProcurementQuote quote = new ProcurementQuote();
quote.setDealId(deal.getDealId());
quote.setQuoteNumber(nextQuoteNumber(deal.getDealId()));
// Priced but not yet issued: the edge fn creates the Stripe Quote and flips this to SENT.
// No quote number here: the deal's one reference is Stripe's, and Stripe does not assign it
// until the quote is finalised. The edge fn creates the Stripe Quote, flips this to SENT,
// and
// writes the number back. Nothing displays a reference in between — the builder only shows
// one
// for an issued quote, and the agreement is not assembled until after issue.
quote.setStatus(ProcurementQuote.STATUS_DRAFT);
quote.setCurrency(cfg.currency());
quote.setVolume(cfg.volume());
@@ -210,10 +354,11 @@ public class ProcurementService {
quote.setLineItemsJson(writeLineItems(breakdown));
quote.setValidUntil(LocalDate.now().plusDays(30));
quote = quoteRepo.save(quote);
// Logged by id, not reference: a draft has no reference until Stripe issues it.
log.info(
"[procurement] quote built team={} quote={} annualNet={} tcv={}",
teamId,
quote.getQuoteNumber(),
quote.getQuoteId(),
quote.getAnnualNetMinor(),
quote.getTcvMinor());
return quote;
@@ -241,6 +386,164 @@ public class ProcurementService {
return deal;
}
/** The quote a team is currently transacting on: its accepted quote, else the most recent. */
@Transactional(readOnly = true)
public Optional<ProcurementQuote> currentQuote(Long teamId) {
return dealRepo.findByTeamId(teamId)
.flatMap(
deal -> {
if (deal.getAcceptedQuoteId() != null) {
Optional<ProcurementQuote> accepted =
quoteRepo.findById(deal.getAcceptedQuoteId());
if (accepted.isPresent()) return accepted;
}
return quoteRepo
.findByDealIdOrderByCreatedAtDesc(deal.getDealId())
.stream()
.findFirst();
});
}
/**
* The filled enterprise agreement for a team's current quote, rendered for review (unsigned).
*/
@Transactional(readOnly = true)
public Optional<AssembledAgreement> agreementDocument(Long teamId) {
return currentQuote(teamId).map(q -> agreementAssembler.assemble(q, null));
}
/**
* The current (unsigned) agreement rendered to PDF, for download at the sign step. Empty when
* there's no quote yet or the render runtime is unavailable. The signed PDF (with the signature
* block filled) is a separate artifact recorded at signing (see {@link #latestSignature}).
*/
@Transactional(readOnly = true)
public Optional<byte[]> agreementDocumentPdf(Long teamId) {
return currentQuote(teamId)
.map(q -> agreementAssembler.assemble(q, null))
.map(a -> agreementPdfRenderer.tryRender(a.markdown()));
}
/**
* Record a signed enterprise agreement: assemble the final document, hash it, render + store
* the PDF (best-effort), and persist an immutable signature pinned to the exact document
* version. Does not itself accept the quote into a subscription — the caller proceeds to accept
* as before.
*
* <p>Deliberately not {@code @Transactional}: rendering the PDF shells out to WeasyPrint, and
* holding the deal's row lock across an external process buys nothing here. The only write is a
* single insert, which {@code save} makes atomic on its own.
*/
public ProcurementAgreementSignature signAgreement(
Long teamId, AgreementSigning signing, String signerIp) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId)
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
ProcurementQuote quote =
currentQuote(teamId)
.orElseThrow(
() ->
new IllegalStateException(
"No quote to sign for team " + teamId));
// Signing is only meaningful against an issued quote at the agreement stage. Without this a
// direct API call could record a signature over a draft (whose quote_ref is still empty),
// or
// re-sign a deal that has already moved on.
if (!ProcurementDeal.STAGE_AGREEMENT.equals(deal.getStage())) {
throw new IllegalStateException(
"Deal is not at the agreement stage for team " + teamId);
}
if (!ProcurementQuote.STATUS_SENT.equals(quote.getStatus())) {
throw new IllegalStateException("Quote is not issued for team " + teamId);
}
AssembledAgreement assembled = agreementAssembler.assemble(quote, signing);
ProcurementAgreementSignature sig = new ProcurementAgreementSignature();
sig.setDealId(deal.getDealId());
sig.setQuoteId(quote.getQuoteId());
sig.setDocumentId(assembled.docId());
sig.setDocumentVersion(assembled.version());
sig.setDocumentLabel(assembled.versionLabel());
sig.setContentHash(sha256(assembled.markdown()));
sig.setVariablesJson(assembled.variablesJson());
sig.setCustomerLegalName(signing.customerLegalName());
sig.setSignatoryName(signing.signatoryName());
sig.setSignatoryTitle(signing.signatoryTitle());
sig.setAuthorityConfirmed(signing.authorityConfirmed());
sig.setSignerIp(signerIp);
sig.setPdf(agreementPdfRenderer.tryRender(assembled.markdown()));
sig = signatureRepo.save(sig);
log.info(
"[procurement] agreement signed team={} quote={} doc={} pdf={}",
teamId,
quote.getQuoteId(),
assembled.versionLabel(),
sig.getPdf() != null);
return sig;
}
/** The latest recorded signature for a team's deal, if any (for the signed-PDF download). */
@Transactional(readOnly = true)
public Optional<ProcurementAgreementSignature> latestSignature(Long teamId) {
return dealRepo.findByTeamId(teamId)
.flatMap(
deal ->
signatureRepo.findFirstByDealIdOrderBySignedAtDesc(
deal.getDealId()));
}
/**
* The version label of the deal's latest signed agreement, if any. Used to surface the
* "download signed agreement" action once a signature exists; the snapshot polls this, so it
* deliberately avoids loading the PDF bytes.
*
* <p>Deliberately not conditional on a stored PDF: download re-renders from the pinned document
* version on demand, so the action works whether or not the render succeeded at signing time.
*/
@Transactional(readOnly = true)
public Optional<String> signedAgreementLabel(Long dealId) {
return signatureRepo.findSignedLabels(dealId).stream().findFirst();
}
/**
* The signed agreement as a PDF for download: the artifact stored at signing, or — if the
* render runtime was unavailable then — re-rendered now from the signature's details. Empty
* when the team has no signature or the render runtime is still unavailable.
*/
@Transactional(readOnly = true)
public Optional<byte[]> signedAgreementPdf(Long teamId) {
return latestSignature(teamId)
.flatMap(
sig -> {
if (sig.getPdf() != null) return Optional.of(sig.getPdf());
return quoteRepo
.findById(sig.getQuoteId())
.map(
q ->
agreementAssembler.assemble(
q,
new AgreementSigning(
sig.getCustomerLegalName(),
sig.getSignatoryName(),
sig.getSignatoryTitle(),
sig.isAuthorityConfirmed())))
.map(a -> agreementPdfRenderer.tryRender(a.markdown()));
});
}
private static String sha256(String s) {
try {
byte[] digest =
java.security.MessageDigest.getInstance("SHA-256")
.digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(digest);
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
/**
* Provision on accept: upgrade the team's licence to the committed annual term (valid
* immediately), so the buyer can get going the moment they accept — before the invoice is paid.
@@ -260,22 +563,58 @@ public class ProcurementService {
}
/**
* Mark the deal fully live (advance to the active stage) once payment settles. In production
* this is the {@code invoice.paid} webhook; here it's the demo/manual stand-in. Re-affirms the
* Mark the deal fully live (advance to the active stage) once payment settles — driven by the
* {@code invoice.paid} webhook, and by the demo control when those are enabled. Re-affirms the
* annual licence in case provisioning didn't run at accept.
*
* <p>Idempotent per invoice rather than per stage, which matters because {@code invoice.paid}
* carries two different meanings. Stripe redelivers events, so the <em>same</em> invoice
* arriving twice must do nothing. But the renewal payment a year later is also an {@code
* invoice.paid}, and the committed licence expires term years from issue — so a
* <em>different</em> invoice has to re-issue, moving the expiry out, or the customer's licence
* lapses after they have paid. Keying on the stage alone couldn't tell those apart and treated
* every renewal as a duplicate.
*
* @param paidInvoiceId the Stripe invoice that was paid, or null when the caller has no invoice
* to identify the payment by (the demo control). Null keeps the old conservative behaviour:
* a live deal short-circuits, since there is nothing to tell a renewal from a repeat.
*/
@Transactional
public ProcurementDeal markLive(Long teamId) {
public ProcurementDeal markLive(Long teamId, String paidInvoiceId) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId)
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())
&& (paidInvoiceId == null || paidInvoiceId.equals(deal.getLastPaidInvoiceId()))) {
log.debug(
"[procurement] invoice.paid already applied team={} deal={} invoice={}",
teamId,
deal.getDealId(),
paidInvoiceId);
return deal;
}
boolean renewal = ProcurementDeal.STAGE_LIVE.equals(deal.getStage());
deal.setLicenseRef(issueOrUpgradeAnnual(deal));
if (paidInvoiceId != null) deal.setLastPaidInvoiceId(paidInvoiceId);
deal.setStage(ProcurementDeal.STAGE_LIVE);
deal = dealRepo.save(deal);
log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId());
log.info(
"[procurement] deal live team={} deal={} renewal={} invoice={}",
teamId,
deal.getDealId(),
renewal,
paidInvoiceId);
return deal;
}
/**
* Go live with no invoice reference — the demo control. See {@link #markLive(Long, String)}.
*/
@Transactional
public ProcurementDeal markLive(Long teamId) {
return markLive(teamId, null);
}
/**
* Issue or upgrade the committed annual licence from the deal's accepted (else latest) quote,
* stamping the full entitlement snapshot onto it and upgrading the trial licence in place when
@@ -327,15 +666,34 @@ public class ProcurementService {
* before paying — that's bounded: the trial licence carries {@code expiry = trialEndsAt}, so
* the file the verifier accepts self-expires at trial end. The buyer must re-download after
* provisioning to get the committed-term file (the portal warns about this).
*
* <p>Once a quote exists, the quote's deployment decides — not the deal's. They are two
* different values: the deal's is chosen free at trial setup, the quote's is the one carrying
* the air-gap deploy fee. Reading the deal's here meant selecting air-gapped in the trial and
* then buying a cloud quote still yielded the offline file, and after provisioning it was
* checked out against the committed annual licence — so the self-expiry above no longer bounded
* it.
*/
@Transactional(readOnly = true)
public Optional<String> offlineLicenseFile(Long teamId) {
ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElse(null);
if (deal == null || deal.getLicenseRef() == null) return Optional.empty();
if (!"airgap".equalsIgnoreCase(deal.getDeployment())) return Optional.empty();
if (!"airgap".equalsIgnoreCase(entitledDeployment(deal))) return Optional.empty();
return Optional.of(licenses.checkOutLicenseFile(deal.getLicenseRef()));
}
/**
* The deployment the team is actually entitled to: the priced quote's once one exists,
* otherwise the trial's self-selected target. Paid entitlements must follow what was quoted.
*/
private String entitledDeployment(ProcurementDeal deal) {
ProcurementQuote quote = currentQuote(deal.getTeamId()).orElse(null);
if (quote != null && quote.getDeployment() != null && !quote.getDeployment().isBlank()) {
return quote.getDeployment();
}
return deal.getDeployment();
}
/**
* Reset a team's procurement: delete the deal (quotes + activity cascade). For
* re-demos/testing.
@@ -346,12 +704,6 @@ public class ProcurementService {
log.info("[procurement] deal reset team={}", teamId);
}
private String nextQuoteNumber(Long dealId) {
int seq = quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId).size() + 1;
String token = UUID.randomUUID().toString().substring(0, 4).toUpperCase(Locale.ROOT);
return String.format(Locale.ROOT, "QT-%s-%04d", token, seq);
}
private String writeLineItems(QuoteBreakdown breakdown) {
try {
return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems());
@@ -0,0 +1,53 @@
## Part C — Data Processing Addendum
This DPA forms part of the Agreement and applies where Provider processes Personal Data on Customer's behalf.
### C1. Roles; scope; instructions
Customer is the controller (or a processor on behalf of its own controllers); Provider is a processor (or subprocessor, as applicable). Provider processes Personal Data only on Customer's documented instructions — including processing initiated by Customer's users, policies, pipelines, and API calls — unless required by law (in which case Provider informs Customer unless legally prohibited). **Provider will inform Customer without undue delay if, in Provider's opinion, an instruction infringes the GDPR, UK GDPR, or other applicable data-protection law.** Customer is responsible for the lawfulness of the Personal Data it submits and the instructions it gives; Customer's rights under this DPA include instruction, audit (C9), objection to subprocessors (C5), assistance (C6), and return or deletion of data (C10).
### C2. Details of processing
**Subject matter/nature:** PDF processing and governance (classification, redaction, routing, retention, conversion, signing, extraction, AI-assisted analysis). **Duration:** the Term plus the deletion period. **Categories of data:** any Personal Data contained in Customer files and metadata (names, contact details, identifiers, financial or health data if present in Customer files), account data of Customer users. **Data subjects:** Customer's employees, users, customers, and other persons appearing in Customer files. **Sensitive data:** may be present in Customer files at Customer's discretion; Customer is responsible for the lawful basis.
### C3. Confidentiality; personnel
Provider ensures persons authorized to process Personal Data are bound by confidentiality and receive security training. Zero-standing-access applies: content access is just-in-time, logged, and audited (MSA Section 4.2).
### C4. Security measures (Annex II summary)
Encryption in transit (TLS 1.2+) and at rest (AES-256); zero-standing-access with audited JIT elevation; role-based access control; SSO/SCIM; tenant isolation; vulnerability management and penetration testing; audit logging of processing events (including file name, hash, size, and operations); backup and recovery. For Self-hosted and Air-gapped deployments, Customer operates the runtime environment and is responsible for infrastructure-level controls; Provider's measures apply to license/metering services and support access.
### C5. Subprocessors
Customer generally authorizes the subprocessors listed at {{subprocessor_url}}: cloud infrastructure (Amazon Web Services), payment processing (Stripe, as independent controller for payment data), email delivery (Google), account infrastructure (Supabase), product telemetry (PostHog, EU-hosted; pseudonymous usage events, never file content), and AI model providers: **Anthropic** (Claude models — receives prompts and the document text or excerpts needed for the requested AI feature) and **Voyage AI** (embedding models — receives extracted text excerpts solely to generate embeddings where Customer enables Ingestion/RAG features). AI features are optional and may be disabled; where used, AI providers receive only the content needed for the requested feature. **Whole Customer files are never transmitted to any AI provider.** Neither AI provider trains on Customer data (verified against the signed provider agreements, Jul 10, 2026). Provider gives thirty (30) days' notice of new subprocessors; Customer may object on reasonable data-protection grounds, and if unresolved, may terminate the affected Services with a pro-rata refund. **Provider imposes data-protection obligations on each subprocessor by written contract that are at least as protective as this DPA, and remains fully responsible to Customer for each subprocessor's performance.**
### C6. Data subject requests; assistance
Taking into account the nature of the processing, Provider provides reasonable assistance (including through the Processor's search, redaction, and audit tools) for Customer's obligations under GDPR Articles 3236: security of processing, breach notification to authorities and data subjects, data protection impact assessments, and prior consultations with supervisory authorities, as well as responses to data subject requests. Provider forwards requests received directly to Customer and does not respond except as legally required. **Provider makes available to Customer all information necessary to demonstrate compliance with this DPA and allows for and contributes to audits, including inspections, per Section C9.**
### C7. Breach notification
Per MSA Section 5.3: without undue delay after becoming aware of a Personal Data Breach, and in any event within forty-eight (48) hours of awareness, with information provided in phases as available — including the nature of the breach, categories and approximate volumes affected, likely consequences, and measures taken or proposed.
### C8. International transfers
Where Personal Data subject to GDPR/UK GDPR is transferred to countries without adequacy, the Parties incorporate the EU Standard Contractual Clauses (Commission Decision 2021/914): **Module 2** (controller-to-processor) where Customer is a controller, and **Module 3** (processor-to-processor) where Customer acts as a processor, with the following selections — Clause 7 (docking): included; Clause 9(a): Option 2 (general written authorization, 30 days' notice per C5); Clause 11(a) optional language: not used; Clause 17: the law of Ireland; Clause 18: the courts of Ireland; competent supervisory authority: the Irish Data Protection Commission (per Annex I.C). Annex I (parties, description of transfer: as per Section C2), Annex II (technical and organizational measures: as per Section C4), and Annex III (subprocessors: as per Section C5 and {{subprocessor_url}}) are completed by reference to this DPA. For UK transfers, the UK International Data Transfer Addendum applies with its Tables completed by reference to the foregoing. Provider is not certified under the EU-U.S. Data Privacy Framework; the SCCs are the transfer mechanism.
**Note:** Provider does not currently offer contractual EU data residency for Stirling Cloud; residency is achieved via Self-hosted or Air-gapped deployment.
### C9. Audits
Provider's security reports and documentation (Section 5.1) are the ordinary means of demonstrating compliance. Customer may additionally audit — by itself or a mandated auditor — once per year on thirty (30) days' notice, and at any time where: (a) a security incident affecting Customer Personal Data has occurred; (b) provided documentation reveals a material deficiency; (c) a competent supervisory authority requires it; or (d) Customer reasonably suspects material noncompliance with this DPA. Audits are conducted during business hours, under confidentiality, at Customer's cost, with reasonable notice, without unreasonable interference with Provider's operations, and without access to other customers' data.
### C10. Return & deletion
On termination, at Customer's choice, Provider returns Customer file content and Personal Data (export of Customer files and the governed-record metadata) and/or deletes them — from live systems within thirty (30) days and from backups within ninety (90) days — except as retention is required by law, and certifies deletion on request. Where Customer uses HYOK, key destruction by Customer renders content cryptographically inaccessible immediately.
### C11. CCPA/CPRA
Provider is a "service provider" under the CCPA/CPRA. Provider: (a) processes Personal Information only for the business purposes specified in this Agreement — providing, securing, metering, and supporting the Services described in Section C2; (b) shall not sell or share Personal Information; (c) shall not retain, use, or disclose it for any purpose other than those business purposes, or outside the direct business relationship between the Parties; (d) shall not combine it with Personal Information received from other sources, except as permitted by CCPA regulations for the business purposes; (e) provides the same level of privacy protection required of businesses by the CCPA; (f) will notify Customer if it determines it can no longer meet its CCPA obligations; (g) grants Customer the right, upon reasonable notice, to take reasonable and appropriate steps to ensure Provider's use of Personal Information is consistent with Customer's obligations, and to stop and remediate any unauthorized use; and (h) flows these requirements down to its subprocessors per Section C5. Provider certifies that it understands these restrictions and will comply with them.
### C12. Liability
Liability under this DPA is subject to the MSA's limitations (Section 8).

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