# Description of Changes
## The problem
`useAdminSettings` backs all 18 admin config sections. Each section
fetched its own copy of its settings block, held it in hand-rolled
loading/saving state, and refetched manually after every save.
Three consequences:
- **Duplicate fetching.** Four AI tabs all read the `aiEngine` block.
Nothing was shared, so each open refetched it.
- **Duplicated wiring.** All 18 sections carried the same effect to
trigger the fetch, each one depending on a `fetchSettings` callback that
would have refetched on every render had it ever become unstable.
- **Console noise.** The hook made 11 `console.*` calls, four of them
`JSON.stringify(settings, null, 2)` on **every fetch and every save** —
admin configuration serialised into the console of every admin session.
Every save also ended with a hand-written `await fetchSettings()`.
Forget it in a new section and its pending badges silently go stale.
## The fix
The hook uses TanStack Query, keyed on `sectionName`, so sections
reading the same block share one fetch and one cache entry.
The fetch gate moved into the hook. Sections used to write:
```ts
const { settings, fetchSettings } = useAdminSettings({ sectionName: "legal" });
useEffect(() => {
if (loginEnabled) fetchSettings();
}, [loginEnabled, fetchSettings]);
```
and now write:
```ts
const { settings } = useAdminSettings({
sectionName: "legal",
enabled: loginEnabled,
});
```
Saving is a mutation that invalidates the section on success, so the
refetch is structural rather than something each section remembers.
The delta computation and the save transformer are unchanged — that is
domain logic, not fetching. `settings` is still an editable draft seeded
from the server response, so forms behave exactly as before.
## Why it is better
Measured against the previous implementation across identical scenarios.
`commits` counts committed renders.
| Scenario | Before | After |
|---|---|---|
| Open one section | 2 commits, 1 request | 2 commits, 1 request |
| Browse the four AI tabs | 8 commits, 4 requests | **5 commits, 1
request** |
| Edit and save | 4 commits, 2 requests | 4 commits, 2 requests |
Committed renders are equal or better everywhere; browsing the AI tabs
costs a quarter of the requests.
The diff reads +449 / −303, but that includes a test file for a hook
that had no tests:
| | Added | Removed | Net |
|---|---|---|---|
| Production code (21 files) | 154 | 303 | **−149** |
| Tests (1 file) | 295 | 0 | +295 |
The 18 section files account for −133 of that: each drops an effect, a
destructure and usually an import, and gains one `enabled:` line. The
hook itself goes from 234 to 180 lines. `console.*` calls go from 11 to
0.
## Caching
Settings inherit the client's 30s stale window rather than refetching on
every mount, which is where the request saving comes from.
Nothing inside a cached block is server-observed — the only live reads
in these sections, `/api/v1/ai/health` and the tessdata language list,
are separate calls outside this query. A block therefore only changes
when another admin writes it.
Two things bound the staleness:
- Sections already held a single snapshot for as long as the modal
stayed open, with no refetch on focus. 30s is shorter than that window,
not longer.
- `computeDelta` only emits fields whose draft differs from the baseline
it was seeded from, so a stale baseline cannot produce a collateral
write. The only race is two admins editing the same field, which is
unchanged. Saving invalidates, so acting refreshes to current values.
The blocks where a stale read would matter most — `security`, `premium`,
`database` — are set once at deployment and effectively never edited
concurrently. The block with the most cache reuse, `aiEngine`, is the
least consequential.
**Convention:** config blocks cache; observed state does not. A section
that displays live server state inside its settings block should
override `staleTime` locally.
## Testing
14 tests, covering the shared fetch, cache reuse across tab reopens, key
separation between blocks, the `enabled` gate, delta-only saves, the
empty-delta short circuit, post-save invalidation, pending-value
display, and draft reseeding.
Each was checked by breaking the implementation and confirming the suite
fails: per-consumer query keys, sending the whole draft instead of the
delta, dropping the post-save invalidate, reporting loaded while
disabled, skipping the empty-delta short circuit, and reverting the
stale window to zero.
`task frontend:check` green. Two unrelated tests fail on this branch —
`workbenchSession.test.ts` and `notificationActions.test.tsx` — and fail
identically on `main`.
## Follow-ups
The sections that fetch through services rather than this hook — Teams,
TeamDetails, People, roughly 2,600 lines — are unchanged. Between them
they share two reads (`getTeams` and `getUsers`, both used by all three)
and carry ten distinct write operations, with no test coverage today.
---
## Primer: mutations
`useQuery` is for reads. It caches, dedupes, and re-renders when data
arrives. `useMutation` is for writes, where none of that applies — a
write happens once, when the user asks.
```ts
const save = useMutation({
mutationFn: (body) => putAdminSection("legal", body),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
save.mutate(body); // fire and forget
await save.mutateAsync(body); // or await it
save.isPending; // disable the button
save.error; // show the failure
```
`isPending` and `error` replace the `useState` flag and
`try/catch/finally` you would otherwise write around every save.
After a write the cache holds stale data. Two ways to fix it:
| | What it does | Use when |
|---|---|---|
| `invalidateQueries` | Marks the data stale so it refetches | The
server may transform, queue or reject part of what you sent |
| `setQueryData` | Writes your value into the cache, no request | The
response tells you exactly what the server now holds |
**Invalidate by default. Use `setQueryData` only when the response is
authoritative.**
This hook has to invalidate: the server can queue a settings change
rather than applying it, returning it in a `_pending` block that the
form renders as a badge. Writing the local draft into the cache would
show a queued change as applied.
Most mutations are not like that. A "rename a team" write, where the
response is the new team, is a `setQueryData` case.
One gotcha: `mutate` does not throw, `mutateAsync` does. An awaited
`mutateAsync` without a `try/catch` is an unhandled rejection.
Copy only. No behaviour, no lookup keys, no licence semantics, no
backend.
## Current state
Every surface that sells the paid self-hosted tier offers **"unlimited
seats"** for **"$99/server/mo"**, and the portal's free plan badges
**"Unlimited users"** and **"SSO included"** as free-tier facts.
## Problem
Both claims are now enforceably false.
[#7492](https://github.com/Stirling-Tools/Stirling-PDF/pull/7492) makes
the licence carry a real user cap, and
[Stirling-PDF-SaaS#325](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/325)
sells capacity in blocks of 100 users. An admin reading "unlimited
seats" and then hitting a 409 at the invite screen is the worst version
of this.
The demo has already dropped both claims; ours were the last ones
standing.
## Solution
| Surface | Was | Now |
|---|---|---|
| Onboarding licence slide | "Stirling Server plan, **unlimited seats**
… $99/server/mo" | "Stirling Team plan, **100 users** … $99/mo" |
| Plan comparison table | `unlimitedUsers` = "Unlimited users" |
`usersIncluded` = "100 users included" |
| Plan card highlights | "Unlimited users" | "100 users included" |
| Static plan section | `name: "Server"`, `maxUsers: "Unlimited users"`
| `plan.team.name`, `plan.team.maxUsers` |
| Upgrade banner | "Upgrade to Server Plan" / "unlimited users" |
"Upgrade to the Team plan" / "100 users, SSO" |
| Portal free plan | "Editor" + "SSO included" + "Unlimited users" |
"Editor" + "Every PDF tool" + "Web, desktop & self-hosted" |
The i18n keys are **renamed** (`unlimitedUsers` to `usersIncluded`)
rather than just revalued, so the key name cannot outlive the claim.
Also drops "per server" from `plan.licenseWarning` — we price a block of
100 users and count the provisioned roster, never nodes. And deletes the
orphaned `[settings.planBilling.tier]` block: zero source references,
and it described a retired model (50 credits/mo free, 500 included plus
overage billing).
## Deliberately unchanged
**"Processor" stays the name of the product surface.** The demo names
each plan for its price tier (Editor = $0, Team = $99/mo, Credits = 1¢
each) while keeping Processor as the surface a plan unlocks. Renaming
the surface here would conflate the two, so the plan-name split is left
for the explicit plan catalogue. The free plan also gains no "500 free
credits monthly" badge yet: that is true in the demo but not in our
backend, which still grants a one-time lifetime pool.
## How to test
Self-hosted, as an admin over the free user limit: Settings → Plan
should offer the Team plan at "100 users included", and the onboarding
licence slide should no longer promise unlimited seats. On the portal
billing page, the free plan should read "Free" with no SSO or
unlimited-users badge.
Green locally: 4/4 i18n audits (missing, unused, structure,
translation), 876 tests across 110 files, oxlint, prettier, and all four
typecheck variants (core, proprietary, saas, portal).
Review Flow PR 5a — the first half of #7479, which stays open for
reference until both halves land. This PR is the ranking and the
bookkeeping; #7762 adds the retry handlers. Merging both reproduces
#7479's diff byte-for-byte.
## What's added
**The action slot model (backend).** `FailureActionSlot` ranks each of a
kind's offers as its `RESOLUTION`, `SECONDARY` or `OVERFLOW`.
`FailureKind` now declares placement per offer — the password-protected
kind names `DECRYPT_AND_RETRY` as its resolution, `UNKNOWN` leads with a
plain `RETRY` — and `FailureActionId` gains those two ids. The
declarations are data; their client handlers arrive in the follow-up, so
this build withholds them with a reason rather than rendering unwired
buttons (the same forward-compatibility #7478 relied on).
**A resolve transition.** `POST /api/v1/notifications/{id}/resolved`
lets a client report a failure fixed. `NotificationSource.parse` turns a
qualified notification id back into the source that owns it, and
`FileRunEventService` folds the resolution into the incident rather than
deleting it.
**`viewerReviewsTeam` on the list response.** A member sees only rows
whose document this browser holds — they can neither open nor fix
anything else — while a team reviewer keeps every row.
**The bell renders the ranking** (`promoteActions`): one primary button,
at most one secondary, the rest in an overflow menu beside **Copy log**.
The row's body is the kind's own sentence; the raw failure message moves
into the menu.
**Read state is a timestamp, not a row id.** `readThroughAt` replaces
`lastSeenId`: when a resolved or dismissed row leaves the list, the rows
below it stay read instead of re-lighting the badge.
## How to test
Needs a proprietary or SaaS build with login enabled (`task dev:all`,
sign in).
1. **Create a failure.** Add a password-protected PDF to the editor and
choose **Skip for now**; the upload's policy run fails on it.
2. **Open the bell.** The row reads the kind's sentence, not a stack
trace. Its primary button is **View file** — the server offers Decrypt
and retry as the resolution, but this build withholds it (handler lands
in the follow-up), so the best renderable offer is promoted instead.
3. **Open the row's ⋯ menu.** View in processor and Dismiss sit there,
along with **Copy log**, which copies the raw message.
4. **Check the read marker survives a departure.** With two failures,
open the bell (badge clears), dismiss the newer row, and refresh: the
badge stays dark. On main, the marker held the departed row's id and the
older row re-read as unread.
5. **Member visibility.** As a plain member, a failure recorded from
another browser does not appear in the bell; as a team reviewer it does.
6. **Resolve endpoint.** `POST
/api/v1/notifications/failure-{eventId}/resolved` as the owner removes
the row on the next poll; `NotificationResolveTest` pins refusal for a
non-owner, an unknown id, and a foreign prefix.
## Migration
None.
Redesigns the policies system so that the backend has an understanding
of policies running over the Editor. The Editor is not set up as a
source for the backend because the backend can't actively get files from
it, they come in via the frontend sending them to the backend, so
instead pipelines have a specific editor key in them to encode whether
the pipeline is triggered on file upload/export in the editor.
Also make a big effort in the frontend code towards genericising policy
running. Previously, there was specific support in the main policy
executor for each policy that it had to run, which was not going to be
appropriate long-term, especially when users can run any pipeline in the
editor. There's more work needed here for me to really be happy with it
but this PR is plenty large on its own and moves it in the right
direction.
All of the above was required to allow arbitrary user pipelines to run
in the editor. This PR makes it so that the user can select Editor as a
source in the pipeline creator, along with whether it should run on
upload or export.
<img width="1437" height="506" alt="image"
src="https://github.com/user-attachments/assets/b2d176a1-185c-480b-9916-abdd1447d8e1"
/>
---------
Co-authored-by: James Brunton <james@stirlingpdf.com>
Replaces the bare account-link login box with a guided Connect flow, and
wires up the triggers that actually put it in front of someone.
## Top bar
<img width="1580" height="422" alt="image"
src="https://github.com/user-attachments/assets/719e12fc-121a-4caa-bc72-124c5167b011"
/>
## The modal
Three steps on the portal's own `FlowModal` + `StepModalHeader`, the
shells procurement and prepay already wear:
1. **What you unlock** — six benefits as a plain list.
<img width="817" height="503" alt="image"
src="https://github.com/user-attachments/assets/4644ddd2-6181-44e1-9be9-7a961972195d"
/>
2. **Sign in** — the existing `SupabaseLoginForm`, reseated.
<img width="880" height="930" alt="image"
src="https://github.com/user-attachments/assets/fc66cbbb-9f98-40a4-9daa-4f2447713f39"
/>
3. **Connected** — confirms, then deep links into Users, Pipelines and
Policies.
<img width="876" height="752" alt="image"
src="https://github.com/user-attachments/assets/28358e4d-a44f-4118-a8ae-8275984ebd00"
/>
Re-auth stays a single step with no pitch and no success screen.
## The triggers
**`LinkGate` stops being dead code.** It was built as the drop-anywhere
"link to unlock" wrapper and was imported by nothing. It is now a
blocking empty state that replaces the feature it guards, wired into
Pipelines, Policies, Users, Sources and Integrations.
**Scoped to creating and editing, never viewing.** Existing pipelines,
policies, sources and connections keep listing and running, so upgrading
an unlinked instance cannot take away something that already works. The
clicks that would open a builder or a create modal ask for the
connection first, which is the moment an admin has already declared
intent.
## Capability signal
`accountLinkAvailable` on `/api/v1/config/app-config`. Gating needs two
facts: whether the instance is linked (`LinkContext`) and whether it
*could* be (this flag). The account-link endpoints 404 when the feature
flag is off, which the client cannot distinguish from "not linked yet" —
so gating on link state alone would lock all five views on every default
install with no way out. `useConnectGate` holds that decision in one
place and shares the app-config query key, so it costs no extra request.
Read from the environment rather than `AccountLinkProperties` because
`:core` cannot depend on `:proprietary`.
# Description of Changes
## The problem
The portal's query client was created per mount:
```ts
const [queryClient] = useState(createPortalQueryClient);
```
The portal is a route (`/processor/*`, a lazy element), and the switch
to the editor is a client-side `navigate()`. So leaving the processor
unmounts `PortalApp`, the client goes with the component, and the cache
goes with the client. Coming back refetches everything, whether or not
anything changed: four requests for the Users page alone (roster,
grants, teams, auth config), and 21 `useQuery` sites across the portal.
The editor's client sits above the router in `AppProviders` and survives
the same trip. The round trip only ever cost in one direction.
## The fix
The module already kept the instance in a module-level slot so
`tryGetPortalQueryClient()` could find it. It just replaced it on every
mount instead of reusing it, so the change is to create it lazily and
hand out the same one:
```ts
export function getPortalQueryClient(): QueryClient {
current ??= new QueryClient({ defaultOptions: { queries: baseQueryOptions } });
return current;
}
```
Still a separate instance from the editor's. The two namespace their
keys apart (`["portal", ...]` against `["editor", ...]`) and invalidate
independently, which this does not change.
## What this does not do
`gcTime` is 5 minutes, from the shared `baseQueryOptions`. An entry with
no observer is still collected on that timer, so this warms a quick trip
to the editor and back, not a return after a long editing session.
Raising the portal's `gcTime` is a separate decision and is not made
here.
## Why it is safe
**Signing out.** A cache that outlives a mount must not outlive a
session, because the portal's holds the admin roster, emails and roles.
Logout goes through `window.location.assign`, a full page load, so the
whole JS context is discarded and no cache can survive it. Nothing in
the codebase calls `queryClient.clear()` on sign-out, and nothing needs
to. If logout ever becomes a client-side navigation, this needs an
explicit reset, and `resetPortalQueryClient()` is the hook for it.
**The one caller of the null check.** `resolveTeam` in
`saas/portal/usersBackend.ts` uses `tryGetPortalQueryClient()` and falls
back to a direct fetch when there is no client, which its comment
describes as the unit-test path; the cache path is preferred because it
honours both `staleTime` and invalidation. A longer-lived client means
the preferred path is taken more often, not less.
## Testing
Three tests in `queryClient.test.tsx`, and the first two fail if the
client goes back to being created per call:
| | |
|---|---|
| A remount is served from cache rather than refetching | the behaviour
this changes |
| Every caller gets the same instance | the mechanism |
| No client is reported until the portal first mounts | the contract
`resolveTeam` reads |
The three existing portal caching suites called the factory expecting a
fresh client per case. They now call `resetPortalQueryClient()` in a
`beforeEach`, which is what keeps `sharing.test.tsx`'s "a later screen
refetches nothing" case honest rather than passing on a leaked cache.
`task frontend:check` passes typecheck, lint and oxfmt, and 2402 of 2404
editor tests. The two failures, `workbenchSession.test.ts` and
`notificationActions.test.tsx`, are untouched here and fail the same way
on `main`.
> [!WARNING]
> Cooldown could not be applied because no publication date was
available from the registry.
>
Bumps the eclipse-temurin group with 1 update in the /docker/backend
directory: eclipse-temurin.
Bumps the eclipse-temurin group with 1 update in the /docker/base
directory: eclipse-temurin.
Bumps the eclipse-temurin group with 1 update in the /docker/embedded
directory: eclipse-temurin.
Updates `eclipse-temurin` from `fbcf915` to `b4c93a5`
Updates `eclipse-temurin` from `fbcf915` to `b4c93a5`
Updates `eclipse-temurin` from `fbcf915` to `b4c93a5`
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
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
> [!WARNING]
> Cooldown could not be applied because no publication date was
available from the registry.
>
Bumps the ubuntu group with 1 update in the /docker/base directory:
ubuntu.
Bumps the ubuntu group with 1 update in the /docker/unoserver directory:
ubuntu.
Updates `ubuntu` from `561618e` to `33ceb71`
Updates `ubuntu` from `561618e` to `33ceb71`
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Auto-generated by stirlingbot[bot]
This PR updates the frontend license report based on changes to
package.json dependencies.
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
# Description of Changes
This PR removes workflow-specific cache suffixes from Python dependency
caching in several CI workflows.
Previously, the following workflows appended their own `cache-suffix`
even though they use the same Python dependency files:
- `ai-engine.yml`
- `check-generated-models.yml`
- `pre_commit.yml`
- `sync_files_v2.yml`
All of these workflows use the same cache dependency inputs:
- `engine/pyproject.toml`
- `engine/uv.lock`
The workflow-specific suffixes caused separate cache entries to be
created for effectively identical dependency sets. This resulted in
unnecessary cache duplication and reduced cache reuse between workflows.
By removing the suffixes, these workflows can now share the same cache
when their dependency inputs and other cache key components match.
This change reduces redundant cache storage, improves cache hit
potential across CI workflows, and avoids repeatedly creating equivalent
caches under different names.
No functional application behavior is changed. The modification only
affects CI cache key generation and reuse.
---
## 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: Copilot <223556219+Copilot@users.noreply.github.com>
Bumps the iconify group with 1 update in the /frontend directory:
[@iconify-json/material-symbols](https://github.com/iconify/icon-sets).
Updates `@iconify-json/material-symbols` from 1.2.83 to 1.2.89
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/iconify/icon-sets/commits">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>
# Description of Changes
Step 4 of the TanStack Query rollout, and the first of the polling
hooks. Follows #7264, #7283, #7285.
## The problem
`useSigningSessions` hand-rolled its own fetch, loading state and
`setInterval`. Two consequences:
- **A raw `setInterval` keeps polling a hidden tab.** Browsers throttle
background timers, they do not stop them, so a backgrounded editor with
Shared Sign open keeps hitting both endpoints for as long as it is open.
- **No tests.** The hook had none, and its quietest behaviour (below) is
the easiest thing to break without noticing.
## End state
One query behind `qk.signingSessions()`, with the polling lifecycle
handed to the library:
- Polling stops while the tab is hidden, and refetches on return rather
than leaving data up to a full interval stale.
- Mounts render from cache while they revalidate, so moving between the
tool picker and the signing tool no longer flashes an empty list.
- 12 tests where there were none.
Same return shape, so no consumer files change.
### What this is not
This is not a deduplication win. The three consumers are never mounted
at the same time: `ToolPanel` renders the tool picker or the active tool
and never both, so the badge cannot be on screen with either of the
others, and `SharedSigningLauncher` and `useSigningSessionController`
sit inside two different tools. The shared key earns its keep on cache
reuse across those transitions, not on concurrent fetches.
## The bit worth reviewing
The hand-rolled `{ silent: true }` flag encoded three states, and no
single Query flag reproduces them:
| | Spinner | Toast on failure |
|---|---|---|
| First load | yes | yes |
| Background poll | no | no |
| Explicit refetch | **yes** | **yes** |
`isLoading` is false during an explicit refetch when data is already on
screen; `isFetching` is true during a background poll. Neither matches,
so the user-initiated case is tracked with a small flag and the failure
toast is gated on `isLoadingError` plus the explicit path.
## Testing
Twelve tests. Rather than trust them, each claim was checked by breaking
the implementation and confirming the relevant test fails:
| Mutation | Caught by |
|---|---|
| `refetchIntervalInBackground: true` | hidden-tab test |
| Drop `refetchOnWindowFocus` | returns-to-view test |
| Drop the user-initiated spinner flag | manual-refresh test |
| Toast on every error | background-failure-is-silent test |
| Give each observer its own key | dedupe test |
Three things worth knowing for the next conversion:
- **`waitFor` flushes renders.** Recording an index *after*
`waitFor(callCount === 2)` skips past the in-flight render, so a "did
the spinner flip on" assertion passes vacuously. The marker has to go
before the poll.
- **Fake timers hide in-flight state.** The fetch settles inside the
same `act()`, so the intermediate render never happens. That test uses
real timers and a held-open promise.
- **`visibilitychange` has to bubble.** query-core listens for it on
`window`, and the real event bubbles from `document`. A test helper
dispatching a non-bubbling event never reaches the focus manager, and
the pause behaviour still appears to work because `refetchInterval`
reads `document.visibilityState` directly at tick time rather than
through the event.
**One claim is deliberately unguarded.** `isLoading` vs `isFetching` for
a background poll produces no re-render at all, so there is nothing
observable for a test to assert and no user-visible difference to
protect.
## Pre-existing failures
`task frontend:check` passes typecheck, lint and oxfmt, and 2363 of 2365
editor tests. The two failures, `workbenchSession.test.ts` and
`notificationActions.test.tsx`, fail identically with this branch's
changes reverted and are untouched by it.
## Scope
This is one of five pollers. The remaining four, `useLocalFolderPoller`,
`WatchedFolderWorkbenchView`, `SessionDetailPanel` and cloud
`TeamSection`, are separate files with their own consumers and follow
separately, now that the silent-refresh pattern has a worked example.
---------
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
# Description of Changes
Step 5 of the TanStack Query rollout, covering the admin People, Teams
and Team details screens. Follows #7264, #7283, #7285.
## The problem
Two separate ones, in the same three files.
**Reads.** Each section fetched and held its own copy of the same
resources: People read the roster and the team list, Teams read the team
list plus the roster again when its add-member modal opened, Team
details read all three. Cost scaled with how many screens you visited
rather than with how much data exists.
**Writes.** Thirteen handlers each did the same five things by hand: set
a processing flag, call the service, toast the outcome, dig a message
out of an axios error, and reload their own slice. Refreshing was a
convention, not a mechanism, and one handler had already forgotten it.
## The fix
Three shared query keys (`adminUsers`, `teams`, `teamDetails`), and one
`useAdminMutation` helper that every write is declared against:
```ts
const createTeam = useAdminMutation({
write: (name: string) => teamService.createTeam(name),
invalidates: ["teams"],
success: t("workspace.teams.createTeam.success"),
errorFallback: t("workspace.teams.createTeam.error"),
onDone: () => { setNewTeamName(""); setCreateModalOpened(false); },
});
```
Each write names the slices it disturbs, which is the part that only
works when reads and writes are designed together: `createTeam`
invalidates the team list, while a membership move invalidates the list,
both teams' detail rows and the roster, because it genuinely changes all
three. Invalidation refetches only mounted queries, so this costs
nothing extra.
The blanket "invalidate everything" helper survives in exactly one role:
child components (invite, password change, seat update) that write
through their own services, where the affected scopes are not visible
from the call site.
## Why it is better, measured
Request counts come from one harness driving `teams -> team details ->
back -> people`, run against the branch point and against this branch.
The assertion is committed, so it cannot silently regress.
| | Before | After |
|---|---|---|
| Requests | 7 | **3** |
| `getTeams` | 4 | **1** |
| `getUsers` | 2 | **1** |
| `getTeamDetails` | 1 | 1 |
| Committed renders | 17 | **15** |
Three is one per distinct resource, the floor for that sequence. The
four `getTeams` were the Teams table, Team details fetching the same
list for its "move to team" dropdown, the explicit refresh on the back
button, and People.
Renders barely move, which is expected: this changes where data lives,
not how often React draws. It is reported because a caching change can
quietly cost renders, and this one does not.
On the code itself, across the three sections:
| | |
|---|---|
| Net lines | **-216** |
| `useState`/`useEffect` removed | **11**, none added |
| Duplicated `isAxiosError` blocks | 13 to **1** |
| `setProcessing` calls | 19 to **0** |
`isAxiosError` is no longer imported by any of the three files.
## Bug fixed
`disableMfaByAdmin` showed a success toast and never refreshed. The menu
item renders only when `user.mfaEnabled` is true, so an admin disabled
MFA, was told it worked, and watched the option stay on screen until a
manual reload. It is covered by a test that fails if the invalidation is
removed.
## Behaviour worth checking in review
- A write no longer blocks its handler before closing the modal. The
dialog closes when the write succeeds and the table updates when the
refetch lands, rather than the button spinning through both.
- Modal submit buttons now track their own mutation rather than one
shared flag. Team details still derives a single busy flag, now from its
five mutations rather than a `useState`, so its row actions disable
together as before.
- The per-handler `console.error` is kept, once, in the shared error
path.
## Testing
Five tests, each verified by breaking the implementation and confirming
that one test, and only that one, fails:
| Mutation | Caught by |
|---|---|
| Drop the shared stale window (`staleTime: 0`) | request-count test |
| Make invalidation a no-op | write-visibility test |
| Ignore the login-enabled gate | login-disabled test |
| Stop invalidating after the MFA write | MFA-refresh test |
| Fall back to the generic error message | server-message test |
The write tests drive the real flows through their modals and menus
rather than calling hooks directly.
`task frontend:check` passes typecheck, lint and oxfmt, and 2383 of 2385
editor tests. The two failures, `workbenchSession.test.ts` and
`notificationActions.test.tsx`, are untouched here and fail identically
with this branch's changes reverted.
## Scope
The three services keep their current shape; nothing outside these three
sections and the new hook module changes. Child modals that write
through their own services still refresh via the blanket helper, and
converting those is separate work.
## What
Every dialog in the processor is the shared `.sui-modal` shell, and its
backdrop was top-aligning the panel:
```css
align-items: flex-start;
padding: 5rem 1.5rem 1.5rem; /* 80px above, 24px below */
```
On a 900px-tall viewport that started every dialog at `y=80` with ~350px
of dead space beneath it. Phones already had an `align-items: center`
override; desktop never got one.
## Change
`frontend/editor/src/core/ui/Modal.css` only:
- Symmetric block inset, `align-items: center`.
- The inset is published as `--modal-inset-block`, and `.sui-modal`'s
`max-height` derives from it. That coupling is the point: if the two
drift apart, a tall modal overflows a centre-aligned backdrop and loses
its header off the top of the screen, unreachable.
- The phone breakpoint now only moves the variable. Measured at 375x812
it resolves to exactly the previous values (`16px 12px`, `max-height:
780px`), so mobile behaviour is unchanged.
One shared file, so this covers flow modals, source / user / pipeline /
API-key modals, billing and procurement.
## Before / After
<img width="2104" height="2284" alt="image"
src="https://github.com/user-attachments/assets/bcb50145-f75e-449e-92c6-a0b085cc091c"
/>
## Testing
- `task frontend:check` passes (lint + typecheck + 2356 tests).
- Phone breakpoint measured directly in the browser, values match the
previous behaviour.
## The problem
AI PRs write comments that restate the line below them, mark sections
with box drawing, and narrate the diff. Nothing in the repo said not to,
and nothing checked. `AGENTS.md` had one line about comments and it was
buried in the Python section.
Banners and `Step N:` narration have zero occurrences in the 15 months
before Aug 2025, so this is new.
## The fix
A written standard, plus a linter that enforces the mechanical part of
it on added lines only.
-
[devGuide/CODE_COMMENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/devGuide/CODE_COMMENTS.md)
holds the reasoning and worked examples; a section in
[AGENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/AGENTS.md)
holds the operative rules, kept short so they stay in an agent's
context. The two are split by kind rather than duplicated, because the
same prose in two places drifts.
- Rules in
[comment-rules.mjs](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs),
shared by both engines.
- Two engines. `.ts` / `.tsx` / `.mjs` go to an [oxlint JS
plugin](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-oxlint-plugin.mjs)
so comments come from the parser rather than a line scan; `.java` /
`.py` go to a [line
scanner](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint.mjs).
Neither reads the other's files, so they cannot disagree about one file.
- Between them they read every comment form the repo writes: `//` and
`/* */`, Javadoc and JSDoc, JSX comments, `#`, and Python docstrings.
- Runs in `task pre-commit`, so the git hook and the `pre_commit.yml` CI
job both get it, and as a Claude Code [`Stop`
hook](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-hook.mjs)
so an agent fixes the comment inside the turn that wrote it.
## The rules
The part worth arguing about. **Every rule blocks.** A rule that only
warns is a rule nobody acts on, so a finding you believe is wrong is a
bug in the rule: narrow it, or mark the line and say why.
| | Fires on |
| --- | --- |
|
[CMT001](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L71)
| Every word in the comment already appears in the code below it. Max 6
words, skipped for prose punctuation and for a bare Arrange/Act/Assert
marker |
|
[CMT002](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L92)
| 4+ rule or box-drawing characters, or a bare section label from [a
fixed
list](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L84)
(`Types`, `Helpers`, `State`, `Handlers`, ...) |
|
[CMT003](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L110)
| `Step N:` with a separator, or `Then,` / `Next,` / `Finally,`.
Suppressed in test files |
|
[CMT004](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L129)
| A comment about the code's own past: `this used to`, `renamed from`,
`was previously called`. Suppressed in test files |
|
[CMT005](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L154)
| 3+ consecutive comment lines where 2/3 [parse as
code](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L143)
|
|
[CMT006](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L31)
| A run of implementation comment over 12 lines, outside the first 5
lines of a file. Doc blocks are exempt, because the standard asks for
thorough contracts |
|
[CMT007](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L180)
| A parameter or return description that adds no word its name lacks.
Reads Javadoc/JSDoc `@param`, Sphinx `:param name:` and Google `name:
description` |
|
[CMT008](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L239)
| An allow directive naming a rule that does not exist, or one that
silenced nothing |
|
[CMT009](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L219)
| A `TODO` / `FIXME` / `HACK` naming no issue or link. An owner is not
accepted: a username goes stale, an issue outlives it |
Each rule carries the readings it deliberately excludes, next to the
rule. Those exclusions came from running the rules over this repo, not
from taste: `CMT004` does not match a bare "no longer needed" because
that is as often about runtime lifecycle as about history, and `CMT003`
needs a separator after the number so a wrapped line beginning "step 2
unmounts + remounts the panel" reads as the prose it is.
A comment sharing a line with code is judged by the rules that do not
depend on the code below it, so a trailing `// TODO fix this` or `/*
this used to run before the flush */` still reports, while `50L * 1024 *
1024 // 50 MB` does not. `CMT001` would have been wrong about six in
seven trailing comments here, so it stays out of them.
If a finding is wrong, `// comment-lint-allow: CMT002` on the line
above. Rule-specific, [no blanket
disable](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L229).
A directive naming a rule that does not exist, or silencing nothing, is
itself a `CMT008` failure, so a typo cannot quietly disable a rule and a
stale one gets deleted rather than accumulating.
No native linter covers `CMT007`. `eslint-plugin-jsdoc`'s
`require-param-description`, Checkstyle's `NonEmptyAtclauseDescription`
and ruff's D-rules all check that a description exists, not whether it
says anything.
## Scoping
Added comment **text** only, not lines git calls new. Reindenting a file
or moving a block makes git mark untouched comments as added; findings
are matched against the comment text at the base, so only genuinely new
content reports.
The whole file is read and every comment in it evaluated. Only the
*reporting* is filtered, so a rule still sees the code a comment
introduces, the full run it belongs to, and the base version of the
file.
Existing tree is untouched. `task pre-commit:comment-lint:all` reports
it and always exits 0:
| | java | ts/js | py |
| --- | --- | --- | --- |
| findings | 1,218 | 741 | 204 |
2,163 across 542 files, mostly `CMT002` banners (1,482) and `CMT001`
restatements (456). Clearing it is separate work, by directory.
Not in this PR: an advisory LLM review layer for the things no pattern
can judge.
## Verification
Run against
[#7494](https://github.com/Stirling-Tools/Stirling-PDF/pull/7494) as CI
would, in a throwaway worktree: **two findings on a 78 file, +4,512 line
change, both genuine banners, in 952ms**. A whole-file scan of those
same files gives 11; the other 9 were withheld because that PR's author
did not write them, and they are the `@param teamId the team ID` shape
this standard exists to stop.
Both scanners blank string and character literals before looking for
comment markers, because a partial lex desynchronises everything after
it: one apostrophe in a Java comment, or one Python template whose
closing quotes start a line, is enough to read dozens of lines of code
as a single comment. Two fixtures carry canaries that stop being
reported if either engine ever desynchronises again.
The [fixture
corpus](https://github.com/Stirling-Tools/Stirling-PDF/tree/claude/ai-pr-comment-quality-dd970e/scripts/lint/fixtures)
pins all 9 rules against both engines, and `--selftest` fails if the two
disagree about the same file.
## Two things reviewers should know
**The oxlint JS plugin API is alpha.** oxlint itself is stable and
already this repo's frontend linter; the plugin API is the new
dependency. Its documented failure mode
([oxc#25203](https://github.com/oxc-project/oxc/issues/25203)) is being
skipped silently while oxlint still reports success. That affects the
standalone release binary rather than the npm package this invokes, but
the class of failure reads exactly like clean code, so the run asserts
`number_of_rules >= 1` from oxlint's own report and a broken engine
exits 2 rather than passing. If the API ever breaks, the fallback is
folding these rules into the line scanner, which already implements all
nine for Java and Python.
**`.claude/settings.json` is now committed**, carrying the hook and
nothing else: 19 lines, no `permissions`, nothing machine-specific. That
partly reverts `c35546a212` ("Ignore claude dir"), which existed because
this file had twice been committed by accident with a personal
`permissions` allowlist, once with absolute machine paths. Personal
config still belongs in `.claude/settings.local.json`, which the new
pattern keeps ignored, and hook entries merge across the two so nobody's
own hooks are lost.
If you already hand-wrote a `.claude/settings.json`, copy it somewhere
first: that path used to be git-ignored, and git overwrites an ignored
file without warning when a commit starts tracking it. Across 19 local
checkouts here, 13 have `settings.local.json` and none has a
hand-written `settings.json`.
To turn the hook off, `{ "env": { "COMMENT_LINT_HOOK": "0" } }` in local
settings. Claude Code can only disable all hooks at once, hence the
switch. The commit-time gate still applies.
## How to test
```bash
task pre-commit:comment-lint:ci
```
The fixture corpus, then the diff. The corpus checks the rules
themselves rather than the code under review, so it runs on CI and
before a rule change, not on every local commit.
```bash
task comment-lint:branch
```
`clean (34 files in scope)`. `task comment-lint` is the same thing
scoped to uncommitted work, which is what the git hook and CI run.
To watch it bite, add `// Is banner` above `export function isBanner` in
`scripts/lint/comment-rules.mjs` and run `task comment-lint`: one
`CMT001`, exit 1. The gate covers its own source, which is why these
scripts have no section dividers.
```bash
task pre-commit:comment-lint:all
```
The standing backlog, report-only.
Verified on the pinned oxlint 1.77.0, not only the 1.79 the plugin was
prototyped against.
# Description of Changes
When #6697 merged, the CI didn't run for some reason so it was never
caught that the tool models were out of date. This PR updates them to
the correct state.
# Description of Changes
Fixes various bugs that affected SaaS (and some self-hosted):
- Refreshing on Editor caused the user to be redirected to Processor
- User was unable to access Processor in SaaS
- Deep link hijacking fixes
- Fix double prefix `/app/app` issue
- Fix going from tool -> editor -> processor -> editor putting you back
into tool
---------
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
## Description of Changes
- Removed overlapping Gradle subdirectory entries from
.github/dependabot.yml.
- Dependabot now monitors the root Gradle project through /.
- Prevents duplicate pull requests for dependencies declared in Gradle
subprojects.
Closes: Not applicable
---
## Checklist
### General
- [ ] I have read the Contribution Guidelines
- [ ] I have read the Stirling-PDF Developer Guide (if applicable)
- [x] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant documentation (if applicable)
- [ ] I have read the translation tag documentation (for new translation
tags only)
### UI Changes (if applicable)
- [ ] Screenshots or videos are attached
### Testing (if applicable)
- [ ] I have tested my changes locally
## What
The `promo` banner tone was a full-bleed `indigo-500 → purple-500`
gradient with white text and a black drop-shadow on the CTA. It was the
only saturated fill in the app, and against the warm neutral palette it
read as a foreign object above the workbench.
The bar is now app chrome:
| | Before | After |
|---|---|---|
| Background | 135° indigo→purple gradient | `--c-bg-raised` |
| Border | `transparent` | `--c-border-subtle` hairline |
| Icon | white glyph, no container | neutral glyph in a
`--c-surface-sunken` chip |
| Text | forced white | `--c-text` / `--c-text-muted` |
| CTA | `premium` accent (violet gradient) | `default` accent (same
primary button as the rest of the app) |
Before
<img width="1504" height="739" alt="Screenshot 2026-08-27 at 4 49 00 PM"
src="https://github.com/user-attachments/assets/a912d9e9-9590-4e1d-8202-1abb22a00f23"
/>
After
<img width="1061" height="665" alt="Screenshot 2026-08-27 at 4 48 30 PM"
src="https://github.com/user-attachments/assets/3be14aec-ccfe-4448-93b3-339a57be4937"
/>
Only caller is the friendly variant of `UpgradeBanner` (self-hosted,
under the free-tier user limit).
## Notes
- **No new theme tokens.** Every value is an existing `--c-*` semantic
token, so light and dark both follow automatically with no per-theme
overrides.
- The `premium` accent itself is untouched, so the upgrade CTAs in
`OfflineActivationCard` and `PairingPanel` are unaffected.
- `--c-hue-indigo` / `--c-hue-purple` are still used by
`SaaSOnboardingSlides`, `PaygFree` and `UpgradeModal`, so no tokens are
orphaned.
- Deleted comments describe rules that no longer exist (the gradient,
the white-on-gradient text overrides, the CTA shadow). No new comments
added.
## Verification
- `task frontend:check:all` passes (typecheck, oxlint, all four theme
linters, stylelint, format, tests, build, storybook build).
- `task frontend:storybook:a11y:changed` passes light and dark: 7
AppBanner stories, 0 violations. Both a11y baselines are empty, so this
is zero known violations rather than a baselined pass.
- Checked in Storybook under **Shared / AppBanner → All Top Bars**,
which renders every top bar the app can show side by side, in both
themes.
Our classification labels were rendering their hardcoded English names
because the en-US locale file had no `classification` section at all, so
this adds the missing keys (labels and category names).
Also wires the category names through i18n, since those had no `t()`
call, and adds a test so a new label can't ship without its key.
# Description of Changes
After ui rework all scrolling in all tool panels stopped working
This fixes this to allow tool panels to be scrollabe again
## What was wrong
PDF/UA is the only convert target whose settings panel overflows the
tool rail. Measured at 1920×1080: overflow was 0px for pdfa, pdfx, png,
docx, epub, and 158px for pdfua. Its action button sat at bottom: 1220
in a 1080px viewport — 140px below the fold — and the info alert was
clipped mid-sentence. The panel could be scrolled, but nothing said so
(Mantine's scrollbar auto-hides).
Normally the app would scroll the button into view for you. It didn't,
because both mechanisms built to do that were dead
## Cause:
Two separate mechanisms, both broken since the same commit (0a50e765b7,
frontend editor restructure, 2026-05-22):
1. ReviewToolStep - shared by all 47 tools. It looked for its scroll
container with:
stepRef.current.closest('[style*="overflow: auto"]')
Mantine's ScrollArea viewport sets inline overflow: scroll, not auto. I
measured it live - closest() returns null, and
document.querySelectorAll('[style*="overflow: auto"]') finds exactly 1
element anywhere in the page, and it isn't an ancestor of the panel. So
the lookup silently found nothing and the scrollTo never ran, for every
tool.
2. Convert.tsx - Convert only. It declared scrollContainerRef and a
scrollToBottom() wired to two useEffects, but the ref was never attached
to any element - createToolFlow() builds the JSX and no ref is passed
through. Always null, so both effects were no-ops.
Nothing else in the codebase has this pattern - I grepped for other
closest('[style*="overflow…"]') lookups and other
scrollToBottom/scrollContainerRef uses and both came back empty.
## The fix
createToolFlow.module.css (new) + createToolFlow.tsx:156 — the execute
button gets a position: sticky; bottom: 0 footer, the house pattern
already used by FormFill.module.css. Applied only when the review step
isn't visible, so it can never float over results. Sticky is inert when
content fits, so the other 46 tools are untouched.
ReviewToolStep.tsx:21 — real findScrollParent() walk replacing the
broken selector, scrolling by the minimum delta needed and only the
panel itself (never scrollIntoView(), which drags every ancestor). Also
added the missing clearTimeout cleanup.
Convert.tsx — deleted the dead ref and its two effects.
---
## 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.
Links a self-hosted instance to a SaaS team over an ordinary redirect,
and leaves the admin's browser holding a Stirling session at the same
time.
## The problem
A self-hosted server needs a device credential bound to a SaaS team, and
the admin's Supabase JWT must never reach the instance backend. Three
things ruled out the obvious approaches:
- **A customer hostname can never be in Supabase's redirect
allow-list**, so the sign-in cannot happen on the instance's own origin.
That is why SSO and sign-up did not work for linking at all.
- **A device credential identifies a server, not a person.** Every
attended portal read (Usage, Billing, Documents, Infrastructure) goes
through `getPortalSaasToken()` and needs a *user* session, so a
credential-only link left all of them asking for a second sign-in.
- **The previous design relayed a JWT** from the browser into the
instance, which is the thing we wanted to avoid. That path is deleted
here.
## The solution
Redirect and nonce, modelled on desktop's
`authService.loginWithSelfHostedOAuth`: mint a nonce, hand the browser
off, accept only a callback carrying that nonce back. Desktop has the OS
route the reply; self-hosted has no OS hop, so our own approval page
performs it. That is the point — the human half happens on an origin we
control.
```
instance SaaS admin's browser
| POST connect/request | |
| (name, callback, nonce, | |
| claim-secret hash) | |
|-------------------------->| |
| <- requestId + authorizeUrl |
| | GET /link?request=... |
| |<-------------------------------|
| | sign in (SSO works here), |
| | see ACCOUNT + ORIGIN, approve |
| |------------------------------->|
| | 302 callback#nonce+session |
| POST connect/claim | |
| (requestId, claim secret)| |
|-------------------------->| |
| <- device credential | |
```
Four properties carry the safety, and each is stated in the code because
each is easy to lose in a refactor:
- **The redirect target is never caller-supplied.** Validated once at
creation, then read back from the stored row, so nothing in the approval
page's URL can steer the token elsewhere.
- **Approval and minting are separate.** Approval records the team and
hands out nothing usable; the credential is minted only on claim,
authenticated by a secret that never entered a browser.
- **A re-authentication cannot move a server between teams.** The team
is pinned at creation from the credential only that instance holds, so
an approver from another team gets `WRONG_TEAM` instead of a rebind.
- **The approver has to confirm what they are binding.** The page shows
the address and the signed-in account, with a way to switch, and a
checkbox naming the address gates the approve button. The name the
server reports is deliberately not shown: the requester picks it on an
unauthenticated endpoint, and its honest value is the hostname already
in the address.
The session rides the URL fragment, so it stays out of access logs and
`Referer`, and is stripped before anything awaits. The claim is
row-locked, so one approval mints once. A request lives 30 minutes; a
settled one is not offered again, since approving it fails server-side.
Signing in mid-flow no longer loses the request. The id is kept on the
SaaS origin and resumed after any sign-in, which is what makes creating
an account work: the confirmation email opens a new tab, where the
`next` parameter is gone. Reading it does not consume it — the request
may be open in two tabs — and only a recorded decision retires it.
The result lands as a modal over the portal the admin started from, and
the portal re-reads its link status so the page behind agrees with the
modal.
Plaintext `http://` callbacks are accepted rather than refused, because
many self-hosted instances legitimately run plain HTTP on a private
network; the address carries a warning icon explaining the risk, derived
server-side so a requester cannot suppress it. Hard-refusing `http://`
to a public IP literal is a reasonable follow-up; a bare hostname can't
be classified without a DNS lookup, so the warning stays the general
mechanism.
## Configuration
Four surfaces. Placeholders below, not values.
**SaaS backend**
| Setting | Needed | Why |
|---|---|---|
| `stirling.billing.account-link.enabled` | Yes, `true` | The connect
controller and service are `@ConditionalOnProperty` with no default, so
without it the endpoints do not exist. |
| `system.frontendUrl` | Only when the approval page is not on the API's
own origin | Where the approver is sent. Must include the app's base
path if it is served under one, or the redirect misses `/link`. |
**SaaS frontend**
| Setting | Needed | Why |
|---|---|---|
| `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes |
Its own sign-in. Must be the project the SaaS backend validates tokens
against. |
| `RUN_SUBPATH` | Only if served under a subpath | Moves the approval
page to `<base>/<subpath>/link`, so `system.frontendUrl` has to agree. |
**Self-hosted backend**
| Setting | Needed | Why |
|---|---|---|
| `stirling.billing.account-link.enabled` | Yes, `true` | Defaults to
`false`. |
| `stirling.billing.account-link.saas-base-url` | Yes | Origin of the
SaaS API it links to. Not the SaaS frontend. |
| `system.frontendUrl` | Optional | Externally reachable base URL for
the callback. Otherwise derived from the request's `Origin`, which is
right for ordinary deployments and wrong behind a rewriting proxy. |
**Self-hosted frontend**
| Setting | Needed | Why |
|---|---|---|
| `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes |
Accepts the session handed over in the callback fragment. |
| `VITE_SAAS_API_URL` | For Usage and Billing | Attended reads go to the
SaaS API with the admin's token. Absent, those surfaces stay on the
mock. |
| `VITE_INCLUDE_PORTAL` | Production builds | Dev builds include the
portal automatically; without it there is no link UI and no callback
route. |
Two things worth stating because neither fails loudly:
- **Both frontends must use the URL *and* key of the same Supabase
project**, and the same one the SaaS backend validates against. A key
from one project with a URL from another is accepted by the browser and
rejected by Supabase, which surfaces much later as "session expired" on
Usage rather than as an error at hand-over.
- **The Supabase redirect allow-list must contain the SaaS app's
`/auth/callback`**, since a confirmation email returns through it.
Entries are matched exactly.
- **`system.frontendUrl` is the existing setting for this**, not a new
one, so each side reads its own value and there is nothing extra to
configure. It also gates share links, so on a stack with storage and
sharing already on, setting it here turns those on too.
The self-hosted side deliberately does **not** configure where the
approval page lives — SaaS answers that in the connect-request reply,
being the only party that knows.
Also here, because testing this needs two stacks side by side:
`linked:staging` / `linked:dev` (which derive `system.frontendUrl` and
`RUN_SUBPATH` themselves), the missing `frontend:staging:saas`, and a
per-mode vite `cacheDir` — two dev servers in different modes otherwise
re-optimise over one shared dep cache.
## How to test
Automated and green: `task frontend:check:all` plus both backend
modules. `ConnectRequestServiceTest` covers callback validation, the
per-IP cap, single-use approval, claim outcomes, expiry, `WRONG_TEAM`
and reauth confirming without minting; `ConnectServiceTest` covers
callback-resolution precedence including a foreign-origin callback being
discarded; `ConnectControllerTest` covers the authorize URL, including
the forwarded-header path and only the first hop being trusted;
`ConnectCallback.test.tsx` covers the fragment being stripped
synchronously and malformed fragments refused;
`LinkAccountModal.test.tsx` covers link and reauth hitting different
endpoints.
Manual walkthrough:
1. `task linked:staging` — added here; brings up a SaaS stack and a
self-hosted instance pointed at it, on discovered ports, and prints the
four addresses.
2. Open the link-account modal in the self-hosted portal and continue.
Expect the SaaS approval page at `/link?request=<id>`.
3. Sign in as a team leader, or create an account and confirm the email.
Either way you should come back to the approval page.
4. Tick the acknowledgement and approve. Expect the fragment gone from
the address bar immediately, a result modal over the portal, the portal
showing linked without a reload, and attended reads (Usage, Billing)
working without a second sign-in.
5. Repeat, approving as a member of a different team. Expect a refusal,
not a rebind.
## Outstanding
- #7415 to be reworked against this design once this lands.
- **No SaaS-side UI to disconnect a server.** `GET
/account-link/instances` and `POST /account-link/instances/{id}/revoke`
are already team-scoped and leader-gated, and the portal has a panel
that uses them, but
`portal-saas/components/settings/accountLinkSettings.tsx` exports `null`
on the reasoning that "SaaS has no account-link concept". That held when
linking was a self-hosted admin managing their own instance; here a
leader approves a server they may not administer, and has no way to
withdraw it. The seam to fill is that one file. Expected to land with
the CTA work in #7415.
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
## What
Switching editor -> processor (or reloading) unmounts every editor
provider, which emptied the workbench. This PR mirrors the workbench
into per-tab sessionStorage and refills an empty one from that record on
the next mount:
- **Files, selection, view and active document survive** the shell
switch and reloads. Each recorded file is resolved to its *current leaf*
version on restore, so a file versioned by a policy or another tab comes
back at its latest state.
- **The switch back lands where the user left**: the processor sidebar's
"editor" button consumes a one-shot return path saved at switch time.
- **The app switch respects unsaved changes**: `useOtherAppSwitch`
(proprietary + saas) now routes through `requestNavigation`, so the same
warning guards it as any other navigation.
- Desktop shadows `WorkbenchSessionPersistence` with a stub (OS-launched
files own boot there).
## How to test
I've run through each of these manually:
- Upload several PDFs in the editor, select a couple, and switch to the
Active Files grid. Click "Open PDF Processor" in the sidebar footer,
then switch back to the editor. The same files, selection and view
should return, and you should land on the editor page you left.
- Open a document in the viewer, then reload the tab. The workbench
should refill and come back on the viewer with the same document active.
- With unsaved changes in a tool, click the processor switch. The
unsaved-changes warning should appear, and the switch should only
proceed if you confirm.
- Open a second browser tab with different files. Each tab should
restore its own workbench independently (the record is per-tab
sessionStorage).
- While in the processor, delete one of the open files from storage,
then switch back. The remaining files should restore and a warning toast
should report "Restored X of Y files".
---------
Co-authored-by: Claude <noreply@anthropic.com>
Follow-up to #7580: the escalation it added could never fire.
## What's broken
The auto-run skips a policy that has already run on a file, keyed on
`(categoryId, fileId)`. `recordRunStart` claims that key — and #7580 has
the **browser-side first pass** record its own run under `categoryId:
"classification"` for the uploaded file. So the local heuristic ticks
the very key the server escalation checks, and the AI is never asked, at
any confidence.
Trigger is the default seeded setup: **Classification as the only
on-upload policy**, and a local verdict below `high`. Any other
on-upload policy masks it, because classification then targets that
policy's output — a new file id whose key was never claimed. That's why
this went unnoticed.
Two smaller faults in the same path:
- A chained output carried no `classificationConfidence`, so
`shouldDispatchToAi` waited for a verdict that could never arrive (a
tool-derived file gets no local pass).
- Browser-local runs were polled against the server: 3 × 404 per file,
after which `MAX_NOT_FOUND` marked a local run that had actually
**succeeded** as `FAILED`.
## The fix
- `PolicyRunRecord.browserLocal`; `recordRunStart` skips the dispatch
claim for such a run. It is the first pass, not the policy's run.
- The local pass meters under `classification:local-meter` instead of
the category id, so metering dedupe survives without suppressing
dispatch.
- The poll effect skips browser-local runs.
- `CONSUME_FILES` inherits `classificationConfidence` alongside the
labels, so the verdict survives a version bump.
## How to test
Download
[`low-confidence-classification.pdf`](https://github.com/Stirling-Tools/Stirling-PDF/raw/fix/chained-classification-confidence/frontend/editor/src/proprietary/services/heuristic/fixtures/low-confidence-classification.pdf)
(checked in as a fixture, verdict pinned by a test).
With **Classification as the only on-upload policy**, upload it and
watch the Network tab:
- **Before:** no `POST /api/v1/policies/{id}/run` for classification,
ever. Console shows `local-classification-*` 404s.
- **After:** exactly one, and the engine receives `POST
/api/v1/documents/classify`.
Judge it on that request, not on the resulting label — the model's
answer varies, so a label comparison can pass or fail for the wrong
reason.
Headless equivalent:
```
npx vitest run --project proprietary src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx
```
Passes here, fails on `main` on "asks the AI about an unsure verdict
even though the local pass already ran". Its other two cases pass on
both, so the guards still hold: a confident verdict still costs nothing,
and a file with no verdict yet still waits rather than racing the free
pass.
New tests drive the **real** run store — mocking it is what let this
through.
`task frontend:check`: 255 files / 2202 tests.
# Description of Changes
Originally, I wanted to re-enable typed linting on our repo but using
Oxlint this time to avoid the memory and speed issues that ESLint was
causing. Unfortunately, it's not stable enough yet to actually use on
our repo (although it is close, I suspect it'll be stable enough fairly
soon). I was able to remove many of the unnecessary casts that it found
though, so even though this won't be enforced, it's still worth cleaning
up what I've found.
The bundled signing test certificates expired at **07:41:10 UTC on
2026-08-26**. They were issued exactly one year earlier, so they went
from fine to fatal mid-morning with no warning, and they take down
`main` and every open branch, not just one PR.
First casualty was the `docker-compose-tests` job on #6802, which
started at 07:45:
```
java.security.cert.CertificateExpiredException: NotAfter: Wed Aug 26 07:41:10 UTC 2026
at CreateSignatureBase.checkValidity(CreateSignatureBase.java:159)
at CertSignControllerTest.testSignPdfWithPkcs12(CertSignControllerTest.java:205)
```
```
$ openssl x509 -in app/core/src/test/resources/certs/test-cert.pem -noout -dates
notBefore=Aug 26 07:41:10 2025 GMT
notAfter =Aug 26 07:41:10 2026 GMT
```
## What was broken
`CertSignControllerTest` (7 tests) and `PdfSigningServiceImplTest` (2)
fail outright. `ValidateSignatureControllerMoreTest` and
`CertificateValidationServiceMoreTest` read the same fixtures.
Auditing the rest of the repo turned up three more time bombs that had
not gone off yet:
| Fixture | Was | Problem |
|---|---|---|
| `app/core/.../certs/test-cert.*` + `test-key.*` | expired 2026-08-26 |
**already breaking every branch** |
| `test-certs/valid-test.p12`, `valid-test.jks` (proprietary + frontend
copies) | expire 2027-03-25 | same failure, seven months out |
| `test-certs/not-yet-valid-test.p12` | valid **from** 2027-03-25 |
becomes valid, so its test silently stops proving anything, on the same
day |
## What this does
**Regenerates every fixture** with the identical subject DN, alias,
password, key size and signature algorithm as before, changing only the
validity window. Nothing that any test asserts on has moved.
- valid fixtures: `2025-01-01` to `2125-01-01`
- `not-yet-valid-test.p12`: `2125-01-01` to `2126-01-01`, so it stays in
the future
- `expired-test.p12`: pinned to its permanently-past 2024 window
**Adds `scripts/generate-test-certs.sh`** as the source of truth, so the
next regeneration is one command instead of archaeology. It documents
every DN, alias and password, pins the validity windows, and runs on
Linux, macOS and Git Bash.
**Adds two guard tests** that fail with an actionable message, naming
the script, while there is still a year of runway:
- `BundledTestCertificateExpiryTest` (app/core) checks all seven formats
parse, are in their validity window, and have more than 365 days left
- `BundledWorkflowCertificateExpiryTest` (proprietary) does the same for
the valid pair, and additionally asserts the expired fixture is still
expired and the not-yet-valid one is still in the future
That last pair matters: those two fixtures exist to test a validity
outcome, and each one silently stops testing anything once the clock
passes its window.
## Verification
Run locally against the regenerated bytes, on the exact content
committed here:
```
./gradlew :stirling-pdf:test --tests '*CertSignControllerTest*' --tests '*BundledTestCertificateExpiryTest*' \
--tests '*PdfSigningServiceImplTest*' --tests '*ValidateSignatureControllerMoreTest*' \
--tests '*CertificateValidationServiceMoreTest*'
BUILD SUCCESSFUL
./gradlew :proprietary:test --tests '*BundledWorkflowCertificateExpiryTest*' --tests '*CertificateValidationIntegrationTest*' \
--tests '*SigningFinalizationServiceMoreTest*' --tests '*ServerCertificateServiceTest*' \
--tests '*CertificateSubmissionValidatorTest*' --tests '*WorkflowSessionServiceTest*'
BUILD SUCCESSFUL
```
`spotlessCheck` passes on both modules.
# Description of Changes
e2e Playwright tests are currently failing intermittently on all
platforms for different reasons, most notably WebKit, which seems to
fail much more often than the others. This PR attempts to fix the
issues. I've ran the e2e tests a few times now and they don't seem to be
inconsistent any more, but it's difficult to tell if all the issues are
genuinely fixed due to the inconsistent nature. As far as I can tell,
I've not broken anything though.
Review Flow PR 4. Stacked on #7477. Recorded failures appear in a
notification bell, showing each reader the failures they are allowed to
see and the actions they can actually take.
Scope is deliberately viewing and routing only. Resolving a failure —
retry, decrypt-and-retry — is #7479, which also brings the write path
for it; nothing resolution-shaped ships here, not even dark.
## What's added
**A notification bell** in the editor and the processor shell. Polls
`GET /api/v1/notifications` every 30 seconds, shows an unread badge, and
lists open failures newest first. Each row shows the failure's title,
its message with **Copy error** and **Show full message** chips, an
occurrence count, and its available actions.
**A notification API** (`stirling.software.proprietary.notification`),
derived from failures on read rather than stored in its own table:
| Route | Purpose |
|---|---|
| `GET /api/v1/notifications` | the caller's open failures, newest first
|
Read-only by design: every action the bell offers is one the client runs
on its own device, so there is nothing to post back. Every id is
prefixed (`failure:<uuid>`), so the bell never holds a raw failure id it
could hand to a failure endpoint.
**Per-reader actions.** A `FailureKind` declares each action with an
audience (`OWNER`, `TEAM_REVIEWER`, `ANYONE_WHO_SEES`). The server
resolves that against the reader and derives `Ownership` (`MINE` /
`THEIRS` / `UNOWNED`) from the row's actor, so an admin reviewing
someone else's failure is not offered a document their browser does not
hold. Adding a failure kind requires no frontend change.
**Server-run and client-run actions are distinguished.**
`FailureActionId` carries an `Execution` facet; the registry requires a
bean only for server actions, and dispatching a client action on the
failure surface returns 400. The notification projection goes further:
it carries only client-run offers, so the bell cannot be sent a button
it would refuse to draw.
**Actions in the bell:** at most two. The owner of the document gets
**View file** (opens it in the editor); a team reviewer gets **View in
processor** (dev builds only). Dismiss stays on the failure queue in
`/processor/documents` — deciding a failure's fate belongs to the review
surface, not the panel that announces it. An action id the build has not
wired is skipped rather than rendered dead, so the server can ship new
kinds ahead of the clients that understand them.
**Attended policy runs record their document.** `POST
/api/v1/policies/{id}/run` accepts an optional opaque `fileId`, recorded
when the run carries exactly one primary document. This is what lets a
repeat fold onto one incident instead of opening a new one per upload,
lets deleting the file clear its failure, and lets the owner open the
document from the row.
## Behaviour changes
- **The bell re-reads as soon as a failure you caused is recorded**,
rather than leaving you to wait out a poll interval for news of your own
upload. Applies to a failed tool run and to a policy run reaching
`FAILED`. Other people's failures still arrive on the poll, which is
what it is for.
- **An action the reader cannot use is not rendered.** Where the server
gave a reason for withholding it, that reason appears as the row's
one-line note. An action that was never offered to that reader produces
no note.
- **Deleting a document closes every incident about it that the deleter
caused**, including a failed policy run on their own upload, so a user's
own errors leave the bell with the file rather than lingering with a
dead button.
- **The failures list in `/processor/documents` stays behind
`import.meta.env.DEV`**, and View in processor is gated to match so it
cannot navigate to a section that is not mounted. Both lift when
failures get their own review screen.
- **One poll for all bells.** The bell is mounted in three places; the
list, document lookups and read marker are shared, so mounting more than
one does not multiply requests.
- `ACKNOWLEDGE` is no longer offered by any kind. The id, bean and
status remain so existing rows stay readable.
## Known limits
- The poll does not pause when the tab is hidden.
- No retention or per-team cap on `file_run_events`.
## How to test
Needs a proprietary or SaaS build with login enabled. `task dev:all`,
then sign in.
1. **Create a failure.** Add a password-protected PDF to the editor and
choose **Skip for now** when it asks to unlock. The upload starts a
policy run that fails on it.
2. **Watch the bell.** The badge should appear within a second or two,
not after 30 — this is the refresh-on-failure path. Open it: a row
titled "Password-protected document" with the error message and the two
chips.
3. **The buttons should be View file and View in processor, nothing
else.** No Dismiss and no retries: dispositions live on the review
surface, resolutions in #7479.
4. **View file** closes the panel and selects that document in the
editor.
5. **Dismiss from the queue instead.** Open `/processor/documents` (dev
build), find the row in the failures list and dismiss it there; the bell
drops it on its next read.
6. **Confirm the local-document probe.** Create a second failure, then
delete that file from the editor and reload. Its incident closes with
it; a row whose document is still present keeps **View file**.
7. **Confirm attribution end to end.** Sign in as a plain member, run a
shared policy on your own upload so it fails. The member sees their own
row in the bell. Sign in as the team leader: they see it too, but with
**View in processor** instead of **View file**, because the document is
not in their browser.
8. **Confirm folding.** Add the same locked PDF again and skip again.
The existing row's occurrence count increases rather than a second row
appearing.
9. **Confirm one poll for many bells.** Open the editor and the
processor in two tabs. Each tab issues its own poll, but within a tab
the several mounted bells share one — the Network tab should show one
`GET /api/v1/notifications` per 30s per tab, not three.
## Migration
None. No new column and no new value in any CHECK-constrained enum;
`CheckConstrainedEnumsTest` fails if that changes.
Auto-generated by stirlingbot[bot]
This PR updates the frontend license report based on changes to
package.json dependencies.
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Auto-generated by stirlingbot[bot]
This PR updates the backend license report based on dependency changes.
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
# Description of Changes
Building ontop of a users draft PR for form creation tools
**Fill Form** becomes a full **Form Editor**: fill, create, modify and
delete AcroForm fields visually. Builds on the community form-creation
draft, plus a UX/UI rework pass.
- **Backend**: `/api/v1/form` endpoints — `fields-with-coordinates`,
`add/modify/delete-fields`, combined `edit-fields` (one round-trip),
`fill`, `extract-csv/xlsx`; supports text (multiline, comb), checkbox,
dropdown, list box, radio, button actions (reset/print/URL/submit) and
signature placeholders
- **Create**: type palette, click-or-drag placement with snap guides,
inline property editor, batch "Add N fields"
- **Modify**: move/resize on the page, arrow-nudge + Delete key, X/Y/W/H
inputs, staged edits/deletes with chips, discard
- **Fill**: live progress + required tracking, flatten toggle, Export
menu (JSON/CSV/XLSX), Ctrl/Cmd+S
- **Safety**: confirm dialog before discarding staged work; empty
required fields warn with "Save anyway" instead of blocking
- **UI**: consistent panel skeleton (fixed header / scrolling list /
pinned actions), empty states that link into Create, full i18n with
plural keys
[walkthrough.html](https://github.com/user-attachments/files/30508976/walkthrough.html)
---
## 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: Denys Vitali <denys@denv.it>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
# Description of Changes
The Documents tab in the Processor is supposed to be available to all
Processor users, but because the API is built on top of the Audit data,
which is only for enterprise users, the API call always fails with 403.
This means that it never fills the query cache, so every time you go
back to the tab it has to reload all the data for a couple of seconds
(and will fail again). This fixes the API so that it's available to any
Processor user instead of just enterprise users. Also, the documents
data was only being written to the log on an enterprise license, so I've
changed it so that data is always tracked in the audit log because
otherwise the Documents tab would still be useless to non-enterprise
users.
The Audit Log tab was also available to all Processor users, but would
have the same issue where the table would never load because the API
would 403 as well. I've just made the Audit Log tab disabled for
non-enterprise users now. We might want to do something to signpost it a
bit more that it's an enterprise-specific feature, but it's better than
nothing for now.
Split out of #7574 — this is the classification half, which is
independent of the editor-source work and can land on its own.
## What this does
- **Runs the local heuristic first and only escalates an unsure verdict
to the AI.** A high-confidence local answer stands; anything less (or a
file the heuristic hasn't reached yet) goes to the engine. A wrong label
costs more than an engine call, so the bar is deliberately strict.
- **Makes `classify` an authorable pipeline task**, so it can be used as
a step like any other tool, and skips files that are already classified.
- **Leaves the seeded Classification policy unowned** rather than naming
a `system` placeholder that was never a real user; existing seeds are
repaired on boot.
## Review feedback applied
From @jbrunton96 on #7574:
- **The generic runner no longer names classification.** Everything
classification-specific moved into
`proprietary/data/classificationPolicy.ts`, and `usePolicyAutoRun` now
asks capability questions instead: `policyRewritesDocument`,
`policyDeliversOutputFiles`, `policyRequiresAiEngine`,
`shouldDispatchToAi`. There is no `id === "classification"` left in the
runner.
- **Ordering is no longer a name in the runner.**
`pinClassificationLast` is gone; the runner sorts annotating policies
after rewriting ones. The constraint is real: an annotating policy is
non-blocking, so a rewriting one running after it forks from the
pre-annotation version and drops the labels. To be straight about what
this is and isn't - see "Still open" below - `policyRewritesDocument` is
still keyed on the category id, not on a property each policy declares.
The check moved out of the runner; it did not stop being a check on one
id.
- **Confidence is typed.** New `ClassificationConfidence` union in
`core/types/fileContext.ts`, reused by `fileStorage`,
`HeuristicConfidence`, and the trusted-verdict constant instead of being
respelled at each site.
- **Comments trimmed** to the repo's 2-line guideline, and a stale
seeder javadoc that still claimed an internal-user owner was corrected.
## Still open, deliberately
`classificationPolicy.ts` answers its capability questions with
`categoryId === "classification"`. That is the same check relocated, not
removed, and the module doc now says so outright.
Deliberate, for two reasons:
- **The concept it would be declared against is going away.** Policies
are becoming pipelines with labels behind a separate enforcement layer,
which removes the category the flag would live on. A capability system
built on `categoryId` today gets migrated twice.
- **Classification is genuinely privileged, not accidentally special.**
It is the only policy with a browser-side implementation, so it can
answer without the server. That is a product decision, and a local-only
mode for set scenarios is planned - the flag for it should be designed
with that feature, not guessed at now.
The end state for the rest: an in-place output mode retires the ordering
rule and `policyDeliversOutputFiles`, and a run result that can carry
findings as well as files retires the remainder. Both touch the import
path, which is the most delicate code in `usePolicyAutoRun` - not
something to bolt on to a PR that has already been split once.
Nothing is broken by leaving it. A user-built classify pipeline still
gets its labels: the generic import path reads them off the returned
PDF. It versions the file instead of labelling in place, and it misses
the local-heuristic shortcut, so it always bills the engine.
## Testing
- `classificationPolicy.test.ts` — 12 cases covering each capability and
the escalation rule
- Full frontend `proprietary` project: 39 files / 442 tests
- `:proprietary:test` for `DefaultClassificationPolicySeederTest` +
`ClassifyLabelControllerTest`
- `tsc --noEmit` on core, proprietary, portal, saas, desktop, cloud
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Bumps org.snakeyaml:snakeyaml-engine from 3.0.1 to 3.1.1.
[](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>
# Description of Changes
Prettier takes about 10 seconds to run over our frontend folder, but
[Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html) does an (almost)
identical job in 0.2 seconds. This PR converts our Prettier integration
to an equivalent Oxfmt integration. There's exactly 2 files in the
frontend folder that Oxfmt formats differently to Prettier so it'll
barely cause any disruption to the source.
I've removed the `--check` option from the frontend tool models
generator as part of this because we can do the same thing with Task
easily enough and Oxfmt isn't directly importable like Prettier since
it's a Rust binary instead of a JS library. Originally I was shelling
out to Oxfmt on single-file mode to keep it all in memory but it just
seemed more likely that there'd be config mismatches between that script
and the task so I think it's better this way.
# Description of Changes
Follow-on from #7334. Fix more `any` type usages and ban them in the
linter. We're starting to get down to only difficult folders left now,
so some of these fixes replace an excluded folder with a couple of
individual files to reduce scope to manageable levels.
There are two real behaviour changes in this PR because of bugs that
were never caught due to the lack of proper typing:
- In the Google Drive service, `lastModified` was always `undefined`
because it should have been read via `lastModifiedUtc`, which it now is.
This means that files being read from Google Drive should now accurately
retain their last modified date from Drive.
- In the error toasts, there was translation logic to try and make
friendlier error messages, but it'd never actually fire since it relied
on `i18n` being written to `globalThis`, which it never was. It now
imports the singleton instead so that translation should start working.
I also had to tweak the way that FitText works because it was relying on
`any` typing to mix refs between different places where they weren't
technically compatible but I've changed it to go via a function and the
behaviour doesn't change.
# Description of Changes
Follow-up to #7518, picking up two mobile rough edges found while going
over that branch. Two changes, one commit each.
## 1. Tool search back in the tool list (mobile)
Tool search lives in the workbench bar's super search, which on mobile
sits on the Workspace slide. So searching for a tool meant swiping off
the tool list, typing, then swiping back. This puts a filter at the head
of the tool panel on mobile. Reuses the existing `ToolSearch` component
in `mode="filter"`, the same one the desktop fullscreen picker uses.
Drives `setSearchQuery` on `ToolWorkflowContext`, so the query,
filtering and grouped results are all existing paths. `ToolPanel` takes
a new `showSearch` prop; `RightSidebar` passes `showSearch={isMobile}`.
Desktop renders exactly as before.
**To test:**
- Open the editor at a phone-width viewport (under 1024px).
- A "Search tools..." field should sit above Favourites / Recommended in
the Tools pane.
- Typing filters into grouped results. Clearing goes back to the compact
list.
- It hides once a tool is open, and comes back on the way out.
- On desktop the field should not appear at all.
## 2. The mobile overflow menu opened with nothing in it
`WorkbenchBarMobileActions` rendered its kebab trigger unconditionally.
But every item inside is gated on `currentView === "viewer"` or
`!isCustomView`. In a `custom:*` workbench both are false, so the
dropdown was empty. `WorkbenchBarDesktopActions` renders nothing in that
case, so this only showed on phones. Now returns `null` when neither
group applies, with the two conditions named so the trigger and the
items can't drift apart again.
**To test:**
- Phone-width viewport, load a PDF.
- Open a tool with its own workbench view: Compare, Get Info report,
Show JS, Validate Signature, Edit Table of Contents, or PDF Text Editor.
- The kebab at the right of the workbench bar should be gone entirely,
rather than opening an empty menu.
- Back in the viewer or page editor it should still be there, with Print
/ Download / Save As / Close.
Bumps `logback` from 1.6.1 to 1.6.3.
Updates `ch.qos.logback:logback-core` from 1.6.1 to 1.6.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/qos-ch/logback/releases">ch.qos.logback:logback-core's
releases</a>.</em></p>
<blockquote>
<h2>Logback 1.6.3</h2>
<h1>2026-08-14 Release of logback version 1.6.3</h1>
<ul>
<li>
<p>In response <a
href="https://www.cve.org/cverecord?id=CVE-2026-19880">CVE-2026-19880</a>,
<code>MDCBasedDiscriminator</code> (used by
<code>SiftingAppender</code>) now strips forward and backward slashes
(<code>/</code>, <code>\</code>) from MDC values before they are used as
discriminating keys. This prevents path segments from escaping into
destinations controlled by an attacker. When sanitisation actually
changes a value, a warning is emitted; the warning is rate-limited (a
small batch, then a lull of about ten minutes).</p>
</li>
<li>
<p>Colour console support is split out into a dedicated <a
href="https://logback.qos.ch/manual/appenders.html#JansiConsoleAppender"><code>JansiConsoleAppender</code></a>.
It wraps stdout or stderr with Jansi so ANSI escape sequences (for
example coloured patterns) render correctly on terminals that need it,
notably Windows. Prefer this class over the older path described next.
See the <a
href="https://logback.qos.ch/manual/appenders.html#JansiConsoleAppender">appenders
documentation</a>.</p>
</li>
<li>
<p>The <code>withJansi</code> property on <code>ConsoleAppender</code>
is <strong>deprecated</strong>. Existing configurations that still set
<code><withJansi>true</withJansi></code> continue to work
for compatibility, but new setups should use
<code>JansiConsoleAppender</code> instead.</p>
</li>
<li>
<p><code>ConsoleAppender</code> no longer treats the process console as
an exclusive resource: stopping it does not close
<code>System.out</code> / <code>System.err</code>.
<code>JansiConsoleAppender</code> pairs each
<code>AnsiConsole.systemInstall()</code> with
<code>systemUninstall()</code> on stop, so repeated start/stop cycles do
not leave Jansi installed or tear down streams shared with the rest of
the JVM. Related behavior is covered by tests for <a
href="https://redirect.github.com/qos-ch/logback/issues/1063">issues/1063</a>.</p>
</li>
<li>
<p>Invocation throttling helpers were reworked:
<code>SimpleInvocationGate</code> is renamed
<code>FixedIntervalInvocationGate</code>, and
<code>BatchedFixedIntervalInvocationGate</code> allows a short burst of
invocations before applying a fixed lull. The sanitisation
warning above uses the batched gate.</p>
</li>
<li>
<p>The JPMS <code>module-info</code> for logback-core now exports the
<code>ch.qos.logback.core.property</code> package, which had been
missing from the module descriptor.</p>
</li>
<li>
<p>A bit-wise identical binary of this version can be reproduced by
building from <a href="https://github.com/qos-ch/logback">source
code</a> at commit <code>e8e824dede022a6d7208b36cfa875b0d1b7772f3</code>
associated with the tag <code>v_1.6.3</code>. The release was built
using Java "21" 2023-10-17 LTS build 21.0.1.+12-LTS-29 under
Linux Debian 11.6.</p>
</li>
</ul>
<p>--
Sponsoring SLF4J/logback/reload4j at <a
href="https://github.com/sponsors/qos-ch">https://github.com/sponsors/qos-ch</a></p>
<h2>Logback 1.6.2</h2>
<p><a
href="https://github.com/user-attachments/assets/9ceaf157-b758-4188-815d-edfe4e1b4edd">https://github.com/user-attachments/assets/9ceaf157-b758-4188-815d-edfe4e1b4edd</a></p>
<h1>2026-08-10 Release of logback version 1.6.2</h1>
<ul>
<li>
<p>Configuration analysis now detects <em>contradictory caller-data
inclusion instructions</em>. For example, an <code>AsyncAppender</code>,
<code>SocketAppender</code> or <code>SMTPAppender</code> with
<code>includeCallerData</code> left at the default <code>false</code> is
incompatible with a layout or encoder pattern that uses a caller-data
converter such as <code>%C</code>, <code>%M</code>, <code>%L</code>,
<code>%F</code>, <code>%l</code> or <code>%caller</code>. At runtime
those converters would print question marks and still incur extraction
cost on a worker thread. Logback now emits a configuration-time warning
when such instructions disagree. See <a
href="https://logback.qos.ch/codes.html#callerContradiction">codes.html#callerContradiction</a>
for details. This issue was reported in <a
href="https://redirect.github.com/qos-ch/logback/issues/1059">issues/1059</a>
by <a href="https://github.com/leeychee">leeychee</a>. The initial
analysis was contributed by <a
href="https://github.com/seonwooj0810">seonwoo_jung</a>.</p>
</li>
<li>
<p>Caller-contradiction analysis can be turned off by setting the
<code>logback.skipCallerContradictionAnalysis</code> variable to
<code>true</code>, either as a system property
(<code>-Dlogback.skipCallerContradictionAnalysis=true</code>) or as a
property in the configuration file:</p>
<pre lang="xml"><code><property
name="logback.skipCallerContradictionAnalysis"
value="true"/>
</code></pre>
</li>
<li>
<p><code>SimpleSocketServer</code> and
<code>SimpleSSLSocketServer</code> now require an explicit client IP
whitelist. On the command line, pass one or more allowed addresses
(single IPs or CIDR ranges) after the configuration file. An empty
whitelist means no clients are accepted. When embedding the server
programmatically, register allowed addresses with
<code>addAllowedClientAddress(String)</code> or
<code>setAllowedClientAddresses(Collection)</code> before clients
connect. See the documentation on <a
href="https://logback.qos.ch/manual/appenders.html#simpleSocketServerClientAccess">restricting
client access</a>.</p>
</li>
<li>
<p>Added <code>ThrowableProxyVOBuilder</code> for assembling a
<code>ThrowableProxyVO</code> field by field, with a corresponding
<code>ThrowableProxyVO.builder()</code> entry point.</p>
</li>
<li>
<p>Dependency analysis handlers now run their <code>postHandle</code>
method after child models have been processed, so checks that depend on
nested appenders (such as caller-contradiction analysis) see a complete
picture.</p>
</li>
<li>
<p>Updated several dependencies, including Angus Mail to 2.0.4 and Jetty
(test) to 12.1.12.</p>
</li>
<li>
<p>A bit-wise identical binary of this version can be reproduced by
building from <a href="https://github.com/qos-ch/logback">source
code</a> at commit e3d78330ad1ba024fd987fd00c3ffb9cfcdb07dc associated
with the tag <code>v_1.6.2</code>. The release was built using Java
"21" 2023-10-17 LTS build 21.0.1.+12-LTS-29 under Linux Debian
11.6.</p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/qos-ch/logback/commit/e8e824dede022a6d7208b36cfa875b0d1b7772f3"><code>e8e824d</code></a>
prepare release 1.6.3</li>
<li><a
href="https://github.com/qos-ch/logback/commit/761821bfaacac3a0ad44fa546cfc814429bf9312"><code>761821b</code></a>
MDCBasedDiscriminator has a gated warning mechanism</li>
<li><a
href="https://github.com/qos-ch/logback/commit/53ed1229008d8b1902f5c234deaa07d742890879"><code>53ed122</code></a>
update copyright year</li>
<li><a
href="https://github.com/qos-ch/logback/commit/c7e2db244671ffa916182b5da8c89579eb54a645"><code>c7e2db2</code></a>
rename SimpleInvocationGate as FixedIntervalInvocationGate</li>
<li><a
href="https://github.com/qos-ch/logback/commit/b5aa931b096a4b0b6a9e140b74fabe7da152cbf0"><code>b5aa931</code></a>
added BatchedSimpleInvocationGate</li>
<li><a
href="https://github.com/qos-ch/logback/commit/1f22af7686aadd25c08b4bd1e6943a906a743ad4"><code>1f22af7</code></a>
add javadocs to SimpleInvocationGate</li>
<li><a
href="https://github.com/qos-ch/logback/commit/638ffa7e7852478b605a91b3e91238ff26f8158c"><code>638ffa7</code></a>
prevent forward and backward slashes to escape to other directories</li>
<li><a
href="https://github.com/qos-ch/logback/commit/7d6b9a4f8c8996834c0a694f6c141705a003d7bb"><code>7d6b9a4</code></a>
add missing ch.qos.logback.core.property package</li>
<li><a
href="https://github.com/qos-ch/logback/commit/fa25930346f35636fb6a077c1f66ebb06edd3b6f"><code>fa25930</code></a>
add an extension path in ConsoleAppender for JansiConsoleAppender</li>
<li><a
href="https://github.com/qos-ch/logback/commit/c73b43f2011f9d4545abc7ea461172276a0a43b3"><code>c73b43f</code></a>
deprecate the withJansi path</li>
<li>Additional commits viewable in <a
href="https://github.com/qos-ch/logback/compare/v_1.6.1...v_1.6.3">compare
view</a></li>
</ul>
</details>
<br />
Updates `ch.qos.logback:logback-classic` from 1.6.1 to 1.6.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/qos-ch/logback/releases">ch.qos.logback:logback-classic's
releases</a>.</em></p>
<blockquote>
<h2>Logback 1.6.3</h2>
<h1>2026-08-14 Release of logback version 1.6.3</h1>
<ul>
<li>
<p>In response <a
href="https://www.cve.org/cverecord?id=CVE-2026-19880">CVE-2026-19880</a>,
<code>MDCBasedDiscriminator</code> (used by
<code>SiftingAppender</code>) now strips forward and backward slashes
(<code>/</code>, <code>\</code>) from MDC values before they are used as
discriminating keys. This prevents path segments from escaping into
destinations controlled by an attacker. When sanitisation actually
changes a value, a warning is emitted; the warning is rate-limited (a
small batch, then a lull of about ten minutes).</p>
</li>
<li>
<p>Colour console support is split out into a dedicated <a
href="https://logback.qos.ch/manual/appenders.html#JansiConsoleAppender"><code>JansiConsoleAppender</code></a>.
It wraps stdout or stderr with Jansi so ANSI escape sequences (for
example coloured patterns) render correctly on terminals that need it,
notably Windows. Prefer this class over the older path described next.
See the <a
href="https://logback.qos.ch/manual/appenders.html#JansiConsoleAppender">appenders
documentation</a>.</p>
</li>
<li>
<p>The <code>withJansi</code> property on <code>ConsoleAppender</code>
is <strong>deprecated</strong>. Existing configurations that still set
<code><withJansi>true</withJansi></code> continue to work
for compatibility, but new setups should use
<code>JansiConsoleAppender</code> instead.</p>
</li>
<li>
<p><code>ConsoleAppender</code> no longer treats the process console as
an exclusive resource: stopping it does not close
<code>System.out</code> / <code>System.err</code>.
<code>JansiConsoleAppender</code> pairs each
<code>AnsiConsole.systemInstall()</code> with
<code>systemUninstall()</code> on stop, so repeated start/stop cycles do
not leave Jansi installed or tear down streams shared with the rest of
the JVM. Related behavior is covered by tests for <a
href="https://redirect.github.com/qos-ch/logback/issues/1063">issues/1063</a>.</p>
</li>
<li>
<p>Invocation throttling helpers were reworked:
<code>SimpleInvocationGate</code> is renamed
<code>FixedIntervalInvocationGate</code>, and
<code>BatchedFixedIntervalInvocationGate</code> allows a short burst of
invocations before applying a fixed lull. The sanitisation
warning above uses the batched gate.</p>
</li>
<li>
<p>The JPMS <code>module-info</code> for logback-core now exports the
<code>ch.qos.logback.core.property</code> package, which had been
missing from the module descriptor.</p>
</li>
<li>
<p>A bit-wise identical binary of this version can be reproduced by
building from <a href="https://github.com/qos-ch/logback">source
code</a> at commit <code>e8e824dede022a6d7208b36cfa875b0d1b7772f3</code>
associated with the tag <code>v_1.6.3</code>. The release was built
using Java "21" 2023-10-17 LTS build 21.0.1.+12-LTS-29 under
Linux Debian 11.6.</p>
</li>
</ul>
<p>--
Sponsoring SLF4J/logback/reload4j at <a
href="https://github.com/sponsors/qos-ch">https://github.com/sponsors/qos-ch</a></p>
<h2>Logback 1.6.2</h2>
<p><a
href="https://github.com/user-attachments/assets/9ceaf157-b758-4188-815d-edfe4e1b4edd">https://github.com/user-attachments/assets/9ceaf157-b758-4188-815d-edfe4e1b4edd</a></p>
<h1>2026-08-10 Release of logback version 1.6.2</h1>
<ul>
<li>
<p>Configuration analysis now detects <em>contradictory caller-data
inclusion instructions</em>. For example, an <code>AsyncAppender</code>,
<code>SocketAppender</code> or <code>SMTPAppender</code> with
<code>includeCallerData</code> left at the default <code>false</code> is
incompatible with a layout or encoder pattern that uses a caller-data
converter such as <code>%C</code>, <code>%M</code>, <code>%L</code>,
<code>%F</code>, <code>%l</code> or <code>%caller</code>. At runtime
those converters would print question marks and still incur extraction
cost on a worker thread. Logback now emits a configuration-time warning
when such instructions disagree. See <a
href="https://logback.qos.ch/codes.html#callerContradiction">codes.html#callerContradiction</a>
for details. This issue was reported in <a
href="https://redirect.github.com/qos-ch/logback/issues/1059">issues/1059</a>
by <a href="https://github.com/leeychee">leeychee</a>. The initial
analysis was contributed by <a
href="https://github.com/seonwooj0810">seonwoo_jung</a>.</p>
</li>
<li>
<p>Caller-contradiction analysis can be turned off by setting the
<code>logback.skipCallerContradictionAnalysis</code> variable to
<code>true</code>, either as a system property
(<code>-Dlogback.skipCallerContradictionAnalysis=true</code>) or as a
property in the configuration file:</p>
<pre lang="xml"><code><property
name="logback.skipCallerContradictionAnalysis"
value="true"/>
</code></pre>
</li>
<li>
<p><code>SimpleSocketServer</code> and
<code>SimpleSSLSocketServer</code> now require an explicit client IP
whitelist. On the command line, pass one or more allowed addresses
(single IPs or CIDR ranges) after the configuration file. An empty
whitelist means no clients are accepted. When embedding the server
programmatically, register allowed addresses with
<code>addAllowedClientAddress(String)</code> or
<code>setAllowedClientAddresses(Collection)</code> before clients
connect. See the documentation on <a
href="https://logback.qos.ch/manual/appenders.html#simpleSocketServerClientAccess">restricting
client access</a>.</p>
</li>
<li>
<p>Added <code>ThrowableProxyVOBuilder</code> for assembling a
<code>ThrowableProxyVO</code> field by field, with a corresponding
<code>ThrowableProxyVO.builder()</code> entry point.</p>
</li>
<li>
<p>Dependency analysis handlers now run their <code>postHandle</code>
method after child models have been processed, so checks that depend on
nested appenders (such as caller-contradiction analysis) see a complete
picture.</p>
</li>
<li>
<p>Updated several dependencies, including Angus Mail to 2.0.4 and Jetty
(test) to 12.1.12.</p>
</li>
<li>
<p>A bit-wise identical binary of this version can be reproduced by
building from <a href="https://github.com/qos-ch/logback">source
code</a> at commit e3d78330ad1ba024fd987fd00c3ffb9cfcdb07dc associated
with the tag <code>v_1.6.2</code>. The release was built using Java
"21" 2023-10-17 LTS build 21.0.1.+12-LTS-29 under Linux Debian
11.6.</p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/qos-ch/logback/commit/e8e824dede022a6d7208b36cfa875b0d1b7772f3"><code>e8e824d</code></a>
prepare release 1.6.3</li>
<li><a
href="https://github.com/qos-ch/logback/commit/761821bfaacac3a0ad44fa546cfc814429bf9312"><code>761821b</code></a>
MDCBasedDiscriminator has a gated warning mechanism</li>
<li><a
href="https://github.com/qos-ch/logback/commit/53ed1229008d8b1902f5c234deaa07d742890879"><code>53ed122</code></a>
update copyright year</li>
<li><a
href="https://github.com/qos-ch/logback/commit/c7e2db244671ffa916182b5da8c89579eb54a645"><code>c7e2db2</code></a>
rename SimpleInvocationGate as FixedIntervalInvocationGate</li>
<li><a
href="https://github.com/qos-ch/logback/commit/b5aa931b096a4b0b6a9e140b74fabe7da152cbf0"><code>b5aa931</code></a>
added BatchedSimpleInvocationGate</li>
<li><a
href="https://github.com/qos-ch/logback/commit/1f22af7686aadd25c08b4bd1e6943a906a743ad4"><code>1f22af7</code></a>
add javadocs to SimpleInvocationGate</li>
<li><a
href="https://github.com/qos-ch/logback/commit/638ffa7e7852478b605a91b3e91238ff26f8158c"><code>638ffa7</code></a>
prevent forward and backward slashes to escape to other directories</li>
<li><a
href="https://github.com/qos-ch/logback/commit/7d6b9a4f8c8996834c0a694f6c141705a003d7bb"><code>7d6b9a4</code></a>
add missing ch.qos.logback.core.property package</li>
<li><a
href="https://github.com/qos-ch/logback/commit/fa25930346f35636fb6a077c1f66ebb06edd3b6f"><code>fa25930</code></a>
add an extension path in ConsoleAppender for JansiConsoleAppender</li>
<li><a
href="https://github.com/qos-ch/logback/commit/c73b43f2011f9d4545abc7ea461172276a0a43b3"><code>c73b43f</code></a>
deprecate the withJansi path</li>
<li>Additional commits viewable in <a
href="https://github.com/qos-ch/logback/compare/v_1.6.1...v_1.6.3">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 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>
# Description of Changes
The macOS Dock icon renders noticeably larger than every other app. The
cause is that
`icon.icns` was **100% full-bleed** - the red rounded square filled all
1024x1024 with zero
margin. macOS does not mask or inset legacy `.icns` icons, so the
artwork has to carry Apple's
grid itself: an **824x824 body centred on a 1024x1024 canvas**.
Full-bleed therefore rendered
**24% wider and 54% larger in area** than its neighbours.
Linux had the same defect for the same reason - the hicolor PNGs were
94.9-100% full-bleed,
and GNOME's HIG says an app icon is drawn within the canvas but must not
fill it (~10% margin,
so a body around 80%). Those small existing margins were resampling
artifacts, not padding.
Windows is deliberately **left full-bleed**. Microsoft imposes no inset:
target-size assets are
drawn without tile padding and the taskbar simply scales the bitmap into
the slot. `app.ico` is
a pure rename here, byte-identical to before.
## What changed
Icons are now split per platform, since the three platforms disagree
about how much of the
canvas the artwork may fill:
| Path | Owner | Treatment |
| --- | --- | --- |
| `icons/macos/app.icns` | `dmg`, `app` | 824/1024 Apple grid |
| `icons/macos/app-512.png` | build-time only | see note below |
| `icons/linux/app-{16..512}.png` | `deb`, `rpm`, `appimage` | ~10%
margin, KDE's small-size exception at 16/32 |
| `icons/windows/app.ico` | `msi`, NSIS | unchanged, full-bleed |
Linux is selected by a new `tauri.linux.conf.json`. Tauri merges
platform configs with
JSON Merge Patch (RFC 7396), so `bundle.icon` is **replaced wholesale**
rather than appended.
## Notes for reviewers
- **`icons/macos/app-512.png` is build ballast, not a real asset.**
`tauri-codegen` requires a
PNG in the icon list for every non-Windows target, with a hardcoded
fallback to
`icons/icon.png` - a file this PR deletes. Without it the build fails.
It is embedded as
`default_window_icon`, which tao's macOS backend discards
(`set_window_icon` there is a no-op:
"macOS doesn't have window icons"). Nothing renders it.
- **Linux icon order matters.** The bundler derives the hicolor
directory from each PNG's real
pixel dimensions, so `app-128.png` lands in `128x128/`. `app-512.png` is
listed first because
the first PNG in the list also becomes the window icon, which GTK does
honour.
- **`.imgbotconfig` had to be repointed.** Its previous entry named
`icons/icon.png`, a path this
PR deletes. That exclusion is load-bearing: ImgBot once optimised the
icon to an indexed
palette and `tauri::generate_context!()` rejects non-RGBA icons,
breaking the desktop build
(#6990). All 15 generated PNGs, including the eight inside the `.icns`,
are verified colour
type 6.
- **Not fixed here:** our corner radius is 14.3% of the body where macOS
and GNOME neighbours sit
near 22%, so the icon still reads squarer than its neighbours. That is a
brand-silhouette
decision rather than the sizing bug, so it was left alone.
- The 15 pre-existing unused assets (`Square*Logo.png`, `mstile-*`,
`android-chrome-*`,
`android/`, `ios/`) are untouched. No configured bundle target consumes
them.
## Verification
`task check` was **not** run - this PR touches no Java, TypeScript or
engine Python, so it
cannot exercise the change. What was verified directly instead:
- Simulated the RFC 7396 merge and Tauri's `find_icon` resolution per
platform: Windows resolves
to `app.ico`, macOS to `app.icns` plus the stub PNG, Linux to its own
six PNGs. Every path exists.
- Both configs validate against the bundled
`@tauri-apps/cli/config.schema.json`, base and merged.
- Every PNG's real dimensions match its filename, and every body
measures exactly its nominal
inset (410/512, 154/192, 102/128, 52/64, 28/32, 14/16).
- An overlay diff of the new macOS body against the old artwork shows
only 1px antialiasing
hairlines - the mark itself is unchanged, only inset.
- Pre-commit hooks pass.
---
## 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
- [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.
## Problem
In the self-hosted build with login enabled, admin-generated invite
links point to the SPA route `/invite/<token>`, but that route is not
covered by the anonymous whitelist. Anonymous users get 401 / redirected
to `/login` before the React app can mount - even though the APIs the
page calls (`/api/v1/invite/validate`, `/api/v1/invite/accept`) are
already whitelisted. Since accepting an invite is how a *new* account is
created, requiring authentication first makes the feature unusable.
## Fix
Add `INVITE_LINK_PATTERN` (`^/invite/[^/]+/?$`) in
`RequestUriUtils.java`, matched at the end of `isPublicAuthEndpoint()` -
mirroring the existing `SHARE_LINK_PATTERN` handling. The invite data
APIs remain protected by their own token validation; only the SPA
bootstrap page becomes anonymously reachable.
## Tests
Added unit tests in `RequestUriUtilsTest.java` mirroring the share-link
tests:
- `/invite/<token>` (with/without trailing slash, with context path) ?
public
- bare `/invite` and `/invite/` ? NOT public (token segment required)
- `/invite/<token>/foo` nested paths ? NOT public
- `/inviteX` prefix over-match ? NOT public
## Verification
Pattern behavior validated against all test cases above. Live-tested on
2.14.3 self-hosted: anonymous `GET /invite/<token>` returned 401 before
the fix; the whitelisted accept flow itself (`validate` + `accept` APIs)
works anonymously end-to-end.
Auto-generated by stirlingbot[bot]
This PR updates the frontend license report based on changes to
package.json dependencies.
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Auto-generated by stirlingbot[bot]
This PR updates the backend license report based on dependency changes.
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Bumps io.swagger.core.v3:swagger-core-jakarta from 2.2.46 to 2.2.53.
[](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>
### Description of Changes
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`.
- Ensured consistency and synchronization across all supported language
files.
- Highlighted any missing or incomplete translations.
- **Format**: TOML
#### **2. Update README.md**
- Generated the translation progress table in `README.md` using
`counter_translation_v3.py`.
- Added a summary of the current translation status for all supported
languages.
- Included up-to-date statistics on translation coverage.
#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.
---
Auto-generated by [create-pull-request][1].
[1]: https://github.com/peter-evans/create-pull-request
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
# Description of Changes
This PR decouples the Docker base-image preparation from the embedded
Docker image matrix builds in `.github/workflows/test-build-docker.yml`.
- Added a dedicated `prepare-base-image` job that runs once when a pull
request changes the Docker base image.
- Builds `stirling-pdf-base:pr-test` once for `linux/amd64` instead of
rebuilding the same image independently in every matrix job.
- Exports the prepared image with `docker save`, compresses it, and
uploads it as a short-lived GitHub Actions artifact.
- Added a dependency from `test-build-docker-images` to
`prepare-base-image`, while still allowing the matrix job to run when
base-image preparation is skipped.
- Each matrix entry downloads and loads the prepared Docker image when
`docker-base-changed` is enabled.
- Removed the previous per-matrix `Build base image locally` step.
- Keeps the prepared image available to the embedded Docker builds
through the local Docker daemon.
The change was made to eliminate redundant base-image builds across the
Docker test matrix. Previously, pull requests modifying `docker/base`
caused each matrix entry to build the identical base image independently
and in parallel. Preparing the image once reduces duplicated CI work,
improves consistency between matrix entries, and should reduce CI
resource usage and execution time for Docker-related pull requests.
---
## 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.
Bumps `imageioVersion` from 3.13.1 to 3.14.0.
Updates `com.twelvemonkeys.imageio:imageio-batik` from 3.13.1 to 3.14.0
Updates `com.twelvemonkeys.imageio:imageio-bmp` from 3.13.1 to 3.14.0
Updates `com.twelvemonkeys.imageio:imageio-jpeg` from 3.13.1 to 3.14.0
Updates `com.twelvemonkeys.imageio:imageio-tiff` from 3.13.1 to 3.14.0
Updates `com.twelvemonkeys.imageio:imageio-webp` from 3.13.1 to 3.14.0
Updates `com.twelvemonkeys.imageio:imageio-psd` from 3.13.1 to 3.14.0
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>
## Summary
- Add a scheduled GitHub Actions workflow that checks for the latest
stable Gradle release.
- Update the Gradle wrapper and pinned Gradle Docker build images
automatically.
- Open an automated pull request when an update is available.
- Update the developer prerequisites to Node.js 22+ and Gradle 9.0+.
## Details
The workflow runs every Monday at 03:00 UTC and can also be triggered
manually. It:
1. Resolves the latest stable Gradle version.
2. Finds the matching `gradle:<version>-jdk25` Docker image digest.
3. Updates the Gradle wrapper and Dockerfiles.
4. Verifies the resolved wrapper version and checks the resulting diff.
5. Creates or updates an automated dependency pull request.
The current wrapper and Docker image changes are included as the initial
update generated by this workflow.
## Testing
- Verified the generated changes with `git diff --check`.
- The workflow validates the wrapper version before opening the
automated pull request.
Auto-generated by stirlingbot[bot]
This PR updates the frontend license report based on changes to
package.json dependencies.
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Bumps the simple-java-mail group with 1 update in the / directory:
[org.simplejavamail:simple-java-mail](https://github.com/bbottema/simple-java-mail).
Bumps the simple-java-mail group with 1 update in the /app/common
directory:
[org.simplejavamail:simple-java-mail](https://github.com/bbottema/simple-java-mail).
Updates `org.simplejavamail:simple-java-mail` from 9.2.0 to 9.3.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/bbottema/simple-java-mail/releases">org.simplejavamail:simple-java-mail's
releases</a>.</em></p>
<blockquote>
<h2>v9.3.1</h2>
<p>Simple Java Mail 9.3.1 is a Java 8-compatible build-tool maintenance
release.</p>
<h2>Changes</h2>
<ul>
<li>Updated Maven Antrun Plugin from 3.1.0 to 3.2.0 for the JPMS
consumer-compilation check.</li>
<li>Updated Maven Dependency Plugin from 3.8.1 to 3.11.0 for
construction of the JPMS module path.</li>
</ul>
<p>These changes affect project build tooling only. This release
contains no runtime-dependency changes, public API changes, or intended
mail-sending behavior changes. Java 8 remains the minimum supported
runtime.</p>
<p>The maintenance pull requests are <a
href="https://redirect.github.com/bbottema/simple-java-mail/pull/700">#700</a>
and <a
href="https://redirect.github.com/bbottema/simple-java-mail/pull/701">#701</a>.</p>
<h2>v9.3.0</h2>
<p>Simple Java Mail 9.3.0 exposes <code>batch-module</code> as a
supported standalone Jakarta Mail orchestration API.</p>
<ul>
<li><a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/698">#698</a>
adds <code>BatchTransportExecutor<K></code> for applications that
create their own <code>Session</code> and <code>MimeMessage</code>
objects without adopting <code>EmailBuilder</code> or
<code>Mailer</code>. The main <code>simple-java-mail</code> facade is
not required.</li>
<li>Register Sessions by cluster key, then run cluster-selected or
exact-Session callbacks synchronously or submit them as
<code>CompletableFuture</code> work. Each callback receives the actually
selected <code>Session</code> and connected <code>Transport</code>.</li>
<li>The facade keeps raw leases private, releases connections after
successful callbacks, invalidates them after escaping failures, resolves
OAuth2 credentials from the selected Session, and provides deterministic
graceful or forced shutdown. Its default executor is module-owned; an
injected executor remains caller-owned.</li>
<li>The existing pooled <code>Mailer</code> path and the standalone
facade now share one transport engine. <code>smtp-connection-pool</code>
remains the only physical pool owner; do not stack batch/direct
orchestration over the Jakarta <code>smtppool</code> provider.</li>
<li>The supporting chain is updated to <code>smtp-connection-pool
4.0.1</code>, <code>clustered-object-pool 4.0.3</code>, and
<code>generic-object-pool 2.4.2</code>. The published JPMS names are
<code>org.simplejavamail.batch</code>,
<code>org.simplejavamail.smtpconnectionpool</code>,
<code>org.bbottema.clusteredobjectpool</code>, and
<code>org.bbottema.genericobjectpool</code>.</li>
</ul>
<p>See the <a
href="https://www.simplejavamail.org/smtp-connection-pooling.html">SMTP
connection pooling and batch orchestration guide</a> for the comparison
matrix, ownership rules, and complete examples.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/bbottema/simple-java-mail/blob/master/RELEASE.txt">org.simplejavamail:simple-java-mail's
changelog</a>.</em></p>
<blockquote>
<p><a
href="https://www.simplejavamail.org">https://www.simplejavamail.org</a></p>
<!-- raw HTML omitted -->
<p>v9.3.0 - v9.3.2</p>
<ul>
<li><strong>v9.3.2:</strong> <a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/702">#702</a>:
clarified the single-address contract of RecipientBuilder by renaming
its misleading implementation parameter and validation label; use
RecipientsBuilder for comma- or semicolon-delimited address lists.</li>
<li><strong>v9.3.1:</strong> Updated Maven Antrun Plugin from 3.1.0 to
3.2.0 (<a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/700">#700</a>)
and Maven Dependency Plugin from 3.8.1 to 3.11.0 (<a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/701">#701</a>),
retaining Java 8 compatibility.</li>
<li><strong>v9.3.0:</strong> <a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/698">#698</a>:
exposed batch-module as a supported standalone Jakarta Mail
orchestration API with clustered and exact-Session callbacks,
asynchronous submission, automatic lease release/invalidation, and
deterministic graceful or forced shutdown.</li>
<li><strong>v9.3.0:</strong> Updated smtp-connection-pool from 3.1.0 to
4.0.1 and migrated the existing Mailer integration to its explicit
SmtpTransportLease contract. The complete generic, clustered, SMTP, and
batch dependency chain now publishes stable JPMS automatic module
names.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/e44c4c983e48ec9372ff745ccb9ab01d731867d0"><code>e44c4c9</code></a>
released 9.3.1 [skip ci]</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/461e5f7c3b97d4882b21bca7ecb4843530184f32"><code>461e5f7</code></a>
docs(release): prepare 9.3.1 release notes</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/68728f3457f7b0e85cf66e758f41985b86c4a125"><code>68728f3</code></a>
build(deps-dev): bump org.apache.maven.plugins:maven-dependency-plugin
(<a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/701">#701</a>)</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/d491c334bea647a99538984d917cd6ffdcb3cbce"><code>d491c33</code></a>
build(deps-dev): bump org.apache.maven.plugins:maven-antrun-plugin (<a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/700">#700</a>)</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/b3111e232a23f9187c2b0c6b98a0bb1a895d9309"><code>b3111e2</code></a>
docs(website): publish pooling guidance update [skip ci]</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/cb3a9a133b91372d112c1b6a07c92e721f1a6f9e"><code>cb3a9a1</code></a>
docs(website): publish pooling guide follow-up [skip ci]</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/29f25e83c654d5dbadd4da321ce9511a4eceeeef"><code>29f25e8</code></a>
released 9.3.0 [skip ci]</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/b9c0cb8b58f4477e92fdd7ea395583863f875310"><code>b9c0cb8</code></a>
feat(batch): expose standalone transport orchestration</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/92549b27f44a0042ca908d22c25db58ccc1e1630"><code>92549b2</code></a>
merge(release): reconcile master with develop [skip ci]</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/b499d76ec28e0566b2000ec6418473da66e1398a"><code>b499d76</code></a>
docs(readme): rebuild developer landing page [skip ci]</li>
<li>Additional commits viewable in <a
href="https://github.com/bbottema/simple-java-mail/compare/9.2.0...9.3.1">compare
view</a></li>
</ul>
</details>
<br />
Updates `org.simplejavamail:outlook-module` from 9.2.0 to 9.3.1
Updates `org.simplejavamail:simple-java-mail` from 9.2.0 to 9.3.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/bbottema/simple-java-mail/releases">org.simplejavamail:simple-java-mail's
releases</a>.</em></p>
<blockquote>
<h2>v9.3.1</h2>
<p>Simple Java Mail 9.3.1 is a Java 8-compatible build-tool maintenance
release.</p>
<h2>Changes</h2>
<ul>
<li>Updated Maven Antrun Plugin from 3.1.0 to 3.2.0 for the JPMS
consumer-compilation check.</li>
<li>Updated Maven Dependency Plugin from 3.8.1 to 3.11.0 for
construction of the JPMS module path.</li>
</ul>
<p>These changes affect project build tooling only. This release
contains no runtime-dependency changes, public API changes, or intended
mail-sending behavior changes. Java 8 remains the minimum supported
runtime.</p>
<p>The maintenance pull requests are <a
href="https://redirect.github.com/bbottema/simple-java-mail/pull/700">#700</a>
and <a
href="https://redirect.github.com/bbottema/simple-java-mail/pull/701">#701</a>.</p>
<h2>v9.3.0</h2>
<p>Simple Java Mail 9.3.0 exposes <code>batch-module</code> as a
supported standalone Jakarta Mail orchestration API.</p>
<ul>
<li><a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/698">#698</a>
adds <code>BatchTransportExecutor<K></code> for applications that
create their own <code>Session</code> and <code>MimeMessage</code>
objects without adopting <code>EmailBuilder</code> or
<code>Mailer</code>. The main <code>simple-java-mail</code> facade is
not required.</li>
<li>Register Sessions by cluster key, then run cluster-selected or
exact-Session callbacks synchronously or submit them as
<code>CompletableFuture</code> work. Each callback receives the actually
selected <code>Session</code> and connected <code>Transport</code>.</li>
<li>The facade keeps raw leases private, releases connections after
successful callbacks, invalidates them after escaping failures, resolves
OAuth2 credentials from the selected Session, and provides deterministic
graceful or forced shutdown. Its default executor is module-owned; an
injected executor remains caller-owned.</li>
<li>The existing pooled <code>Mailer</code> path and the standalone
facade now share one transport engine. <code>smtp-connection-pool</code>
remains the only physical pool owner; do not stack batch/direct
orchestration over the Jakarta <code>smtppool</code> provider.</li>
<li>The supporting chain is updated to <code>smtp-connection-pool
4.0.1</code>, <code>clustered-object-pool 4.0.3</code>, and
<code>generic-object-pool 2.4.2</code>. The published JPMS names are
<code>org.simplejavamail.batch</code>,
<code>org.simplejavamail.smtpconnectionpool</code>,
<code>org.bbottema.clusteredobjectpool</code>, and
<code>org.bbottema.genericobjectpool</code>.</li>
</ul>
<p>See the <a
href="https://www.simplejavamail.org/smtp-connection-pooling.html">SMTP
connection pooling and batch orchestration guide</a> for the comparison
matrix, ownership rules, and complete examples.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/bbottema/simple-java-mail/blob/master/RELEASE.txt">org.simplejavamail:simple-java-mail's
changelog</a>.</em></p>
<blockquote>
<p><a
href="https://www.simplejavamail.org">https://www.simplejavamail.org</a></p>
<!-- raw HTML omitted -->
<p>v9.3.0 - v9.3.2</p>
<ul>
<li><strong>v9.3.2:</strong> <a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/702">#702</a>:
clarified the single-address contract of RecipientBuilder by renaming
its misleading implementation parameter and validation label; use
RecipientsBuilder for comma- or semicolon-delimited address lists.</li>
<li><strong>v9.3.1:</strong> Updated Maven Antrun Plugin from 3.1.0 to
3.2.0 (<a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/700">#700</a>)
and Maven Dependency Plugin from 3.8.1 to 3.11.0 (<a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/701">#701</a>),
retaining Java 8 compatibility.</li>
<li><strong>v9.3.0:</strong> <a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/698">#698</a>:
exposed batch-module as a supported standalone Jakarta Mail
orchestration API with clustered and exact-Session callbacks,
asynchronous submission, automatic lease release/invalidation, and
deterministic graceful or forced shutdown.</li>
<li><strong>v9.3.0:</strong> Updated smtp-connection-pool from 3.1.0 to
4.0.1 and migrated the existing Mailer integration to its explicit
SmtpTransportLease contract. The complete generic, clustered, SMTP, and
batch dependency chain now publishes stable JPMS automatic module
names.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/e44c4c983e48ec9372ff745ccb9ab01d731867d0"><code>e44c4c9</code></a>
released 9.3.1 [skip ci]</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/461e5f7c3b97d4882b21bca7ecb4843530184f32"><code>461e5f7</code></a>
docs(release): prepare 9.3.1 release notes</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/68728f3457f7b0e85cf66e758f41985b86c4a125"><code>68728f3</code></a>
build(deps-dev): bump org.apache.maven.plugins:maven-dependency-plugin
(<a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/701">#701</a>)</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/d491c334bea647a99538984d917cd6ffdcb3cbce"><code>d491c33</code></a>
build(deps-dev): bump org.apache.maven.plugins:maven-antrun-plugin (<a
href="https://redirect.github.com/bbottema/simple-java-mail/issues/700">#700</a>)</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/b3111e232a23f9187c2b0c6b98a0bb1a895d9309"><code>b3111e2</code></a>
docs(website): publish pooling guidance update [skip ci]</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/cb3a9a133b91372d112c1b6a07c92e721f1a6f9e"><code>cb3a9a1</code></a>
docs(website): publish pooling guide follow-up [skip ci]</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/29f25e83c654d5dbadd4da321ce9511a4eceeeef"><code>29f25e8</code></a>
released 9.3.0 [skip ci]</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/b9c0cb8b58f4477e92fdd7ea395583863f875310"><code>b9c0cb8</code></a>
feat(batch): expose standalone transport orchestration</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/92549b27f44a0042ca908d22c25db58ccc1e1630"><code>92549b2</code></a>
merge(release): reconcile master with develop [skip ci]</li>
<li><a
href="https://github.com/bbottema/simple-java-mail/commit/b499d76ec28e0566b2000ec6418473da66e1398a"><code>b499d76</code></a>
docs(readme): rebuild developer landing page [skip ci]</li>
<li>Additional commits viewable in <a
href="https://github.com/bbottema/simple-java-mail/compare/9.2.0...9.3.1">compare
view</a></li>
</ul>
</details>
<br />
Updates `org.simplejavamail:outlook-module` from 9.2.0 to 9.3.1
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
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps the embedpdf group with 21 updates in the /frontend directory:
| Package | From | To |
| --- | --- | --- |
|
[@embedpdf/core](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/core/main)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/models](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/models)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-annotation](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-annotation)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-attachment](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-attachment)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-bookmark](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-bookmark)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-document-manager](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-document-manager)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-export](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-download)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-history](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-history)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-interaction-manager](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-interaction-manager)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-pan](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-pan)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-print](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-print)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-redaction](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-redaction)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-render](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-render)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-rotate](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-rotate)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-scroll](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-scroll)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-search](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-search)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-spread](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-spread)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-thumbnail](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-thumbnail)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-tiling](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-tiling)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-viewport](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-viewport)
| `2.14.4` | `2.15.0` |
|
[@embedpdf/plugin-zoom](https://github.com/embedpdf/embed-pdf-viewer/tree/HEAD/packages/plugin-zoom)
| `2.14.4` | `2.15.0` |
Updates `@embedpdf/core` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/core's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/core/main">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/engines` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/engines's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/blob/v2.15.0/packages/engines/CHANGELOG.md">@embedpdf/engines's
changelog</a>.</em></p>
<blockquote>
<h2>2.15.0</h2>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/embedpdf/embed-pdf-viewer/commit/a8faf04b15291c895118b819d7395c3bca8a4959"><code>a8faf04</code></a>
chore: version packages</li>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/engines">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/models` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/models's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/blob/v2.15.0/packages/models/CHANGELOG.md">@embedpdf/models's
changelog</a>.</em></p>
<blockquote>
<h2>2.15.0</h2>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/embedpdf/embed-pdf-viewer/commit/a8faf04b15291c895118b819d7395c3bca8a4959"><code>a8faf04</code></a>
chore: version packages</li>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/models">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/plugin-annotation` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/plugin-annotation's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/blob/v2.15.0/packages/plugin-annotation/CHANGELOG.md">@embedpdf/plugin-annotation's
changelog</a>.</em></p>
<blockquote>
<h2>2.15.0</h2>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/embedpdf/embed-pdf-viewer/commit/a8faf04b15291c895118b819d7395c3bca8a4959"><code>a8faf04</code></a>
chore: version packages</li>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/plugin-annotation">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/plugin-attachment` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/plugin-attachment's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/blob/v2.15.0/packages/plugin-attachment/CHANGELOG.md">@embedpdf/plugin-attachment's
changelog</a>.</em></p>
<blockquote>
<h2>2.15.0</h2>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/embedpdf/embed-pdf-viewer/commit/a8faf04b15291c895118b819d7395c3bca8a4959"><code>a8faf04</code></a>
chore: version packages</li>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/plugin-attachment">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/plugin-bookmark` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/plugin-bookmark's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/blob/v2.15.0/packages/plugin-bookmark/CHANGELOG.md">@embedpdf/plugin-bookmark's
changelog</a>.</em></p>
<blockquote>
<h2>2.15.0</h2>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/embedpdf/embed-pdf-viewer/commit/a8faf04b15291c895118b819d7395c3bca8a4959"><code>a8faf04</code></a>
chore: version packages</li>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/plugin-bookmark">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/plugin-document-manager` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/plugin-document-manager's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/blob/v2.15.0/packages/plugin-document-manager/CHANGELOG.md">@embedpdf/plugin-document-manager's
changelog</a>.</em></p>
<blockquote>
<h2>2.15.0</h2>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/embedpdf/embed-pdf-viewer/commit/a8faf04b15291c895118b819d7395c3bca8a4959"><code>a8faf04</code></a>
chore: version packages</li>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/plugin-document-manager">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/plugin-export` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/plugin-export's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/plugin-download">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/plugin-history` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/plugin-history's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/blob/v2.15.0/packages/plugin-history/CHANGELOG.md">@embedpdf/plugin-history's
changelog</a>.</em></p>
<blockquote>
<h2>2.15.0</h2>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/embedpdf/embed-pdf-viewer/commit/a8faf04b15291c895118b819d7395c3bca8a4959"><code>a8faf04</code></a>
chore: version packages</li>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/plugin-history">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/plugin-interaction-manager` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/plugin-interaction-manager's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/blob/v2.15.0/packages/plugin-interaction-manager/CHANGELOG.md">@embedpdf/plugin-interaction-manager's
changelog</a>.</em></p>
<blockquote>
<h2>2.15.0</h2>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/embedpdf/embed-pdf-viewer/commit/a8faf04b15291c895118b819d7395c3bca8a4959"><code>a8faf04</code></a>
chore: version packages</li>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/plugin-interaction-manager">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/plugin-pan` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/plugin-pan's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/blob/v2.15.0/packages/plugin-pan/CHANGELOG.md">@embedpdf/plugin-pan's
changelog</a>.</em></p>
<blockquote>
<h2>2.15.0</h2>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/embedpdf/embed-pdf-viewer/commit/a8faf04b15291c895118b819d7395c3bca8a4959"><code>a8faf04</code></a>
chore: version packages</li>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/plugin-pan">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/plugin-print` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/plugin-print's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/blob/v2.15.0/packages/plugin-print/CHANGELOG.md">@embedpdf/plugin-print's
changelog</a>.</em></p>
<blockquote>
<h2>2.15.0</h2>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/embedpdf/embed-pdf-viewer/commit/a8faf04b15291c895118b819d7395c3bca8a4959"><code>a8faf04</code></a>
chore: version packages</li>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/plugin-print">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/plugin-redaction` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/plugin-redaction's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/blob/v2.15.0/packages/plugin-redaction/CHANGELOG.md">@embedpdf/plugin-redaction's
changelog</a>.</em></p>
<blockquote>
<h2>2.15.0</h2>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/embedpdf/embed-pdf-viewer/commit/a8faf04b15291c895118b819d7395c3bca8a4959"><code>a8faf04</code></a>
chore: version packages</li>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/plugin-redaction">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/plugin-render` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/plugin-render's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ start, end
}</code> glyph pointers) shape emitted by
<code>onSelectionChange</code>, so a saved selection can be passed
straight back in to restore it; passing <code>null</code> clears the
selection. Page geometry is loaded on demand, so the returned task
resolves only once the highlight rects are computed. The range is
normalized (start/end may be given in any order), invalid input
(malformed range, non-integer/negative indices, out-of-bounds pages) is
rejected, glyph indices are clamped to the available page geometry, and
previously highlighted pages are repainted so switching to a disjoint
selection no longer leaves stale highlights behind.</p>
</li>
</ul>
<h2><code>@embedpdf/core</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/703">#703</a>
by <a
href="https://github.com/berkayozdin"><code>@berkayozdin</code></a> –
Make the precompiled Svelte output work on every Svelte 5 runtime</p>
<p>Svelte 5.56 changed the <code>exclude</code> argument of the private
<code>rest_props</code> runtime helper from an array
(<code>exclude.includes(key)</code>) to a <code>Set</code>
(<code>exclude.has(key)</code>). The <code>*/svelte</code> entry points
ship precompiled component code, so output built against one side of
that change throws on the other: the currently published packages fail
with <code>TypeError: exclude.has is not a function</code> on Svelte
>= 5.56, which aborts the render of every EmbedPDF Svelte
component.</p>
<p>The Svelte build now routes those calls through a wrapper that hands
the runtime an <code>exclude</code> value satisfying both contracts, so
one published build stays valid across the whole <code>svelte:
">=5 <6"</code> peer range.</p>
</li>
</ul>
<h2><code>@embedpdf/engines</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/models</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/pdfium</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-annotation</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-attachment</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-bookmark</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-capture</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-commands</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-document-manager</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-export</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h2><code>@embedpdf/plugin-form</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/blob/v2.15.0/packages/plugin-render/CHANGELOG.md">@embedpdf/plugin-render's
changelog</a>.</em></p>
<blockquote>
<h2>2.15.0</h2>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/embedpdf/embed-pdf-viewer/commit/a8faf04b15291c895118b819d7395c3bca8a4959"><code>a8faf04</code></a>
chore: version packages</li>
<li>See full diff in <a
href="https://github.com/embedpdf/embed-pdf-viewer/commits/v2.15.0/packages/plugin-render">compare
view</a></li>
</ul>
</details>
<br />
Updates `@embedpdf/plugin-rotate` from 2.14.4 to 2.15.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/embedpdf/embed-pdf-viewer/releases">@embedpdf/plugin-rotate's
releases</a>.</em></p>
<blockquote>
<h2>Release v2.15.0</h2>
<h2><code>@embedpdf/plugin-selection</code><a
href="https://github.com/2"><code>@2</code></a>.15.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/embedpdf/embed-pdf-viewer/pull/685">#685</a>
by <a href="https://github.com/simonmysun"><code>@simonmysun</code></a>
– Add <code>setSelection(range, documentId?)</code> to the selection
capability and document scope for programmatically applying or restoring
a text selection.</p>
<p>It accepts the same <code>SelectionRangeX</code> (<code>{ star...
_Description has been truncated_
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
# Description of Changes
PR updates built from source dep versions in Dockerfiles
Changes:
* Updated `CALIBRE_VERSION` from 9.4.0 to 9.13.0 in
`docker/base/Dockerfile`.
* Updated `GS_VERSION` (Ghostscript) from 10.06.0 to 10.07.1 in
`docker/base/Dockerfile`.
* Updated `IM_VERSION` (ImageMagick) from 7.1.2-13 to 7.1.2-29 in
`docker/base/Dockerfile`.
* Updated `QPDF_VERSION` is already at 12.3.2, no change.
* Updated `UNOSERVER_VERSION` from 3.6 to 3.7 in both
`docker/base/Dockerfile` and `docker/unoserver/Dockerfile` to align with
the client version and avoid wire mismatches.
* Updated `TASK_VERSION` from 3.49.1 to 3.52.0 in
`docker/embedded/Dockerfile`, `docker/embedded/Dockerfile.fat`,
`docker/embedded/Dockerfile.ultra-lite`, and `engine/Dockerfile`.
<!--
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.
# Description of Changes
Adds a PDF/UA converter, an accessibility report, and PDF/A conformance
level A.
**New: `POST /api/v1/convert/pdf/ua`** (Convert tool, "PDF/UA" target).
Tags an untagged PDF, marks
decorative content as artifacts, embeds missing fonts and applies the
document-level PDF/UA
requirements (title, language, tab order, form-field descriptions), then
validates with veraPDF. The
`pdfuaid` declaration is written only if validation passes, so a
returned file never claims more
than it delivers; response headers report whether it was declared, how
many checks still fail and
how many images still need a description.
**New: `POST /api/v1/security/accessibility-report`.** Reports what
fails, what the converter can fix
on its own, what needs a person, and lists the figures needing a
description with the keys the
conversion accepts back. Read-only; does not modify the file. Capped at
100 MB / 2000 pages and
weighted `LARGE_WEIGHT`, since it runs a full veraPDF pass plus the
converter's layout analysis over
every page.
**PDF/A level A.** `pdfa-1a`, `pdfa-2a` and `pdfa-3a` output formats on
the existing
`/api/v1/convert/pdf/pdfa` endpoint. Level A is level B plus tagging, so
the document is tagged
after Ghostscript (which discards any structure tree it is given) and
the level A claim is written
only if veraPDF agrees. Optional `pdfUa=true` additionally declares
PDF/UA alongside PDF/A, again
only if it validates.
Honesty rules the implementation holds to:
- **Never claim a level that was not reached.** If tagging fails, the
file is returned at level B and
is named `_PDFA-2b.pdf`, not `_PDFA-2a.pdf`. With `strict=true` the
request fails outright rather
than returning a level B file against a level A request, and a level B
pass no longer satisfies a
strict level A request.
- **Never relabel a document's language.** The requested language
(default `en-GB`) is applied only
when the document declares none; a French PDF stays French unless the
caller sets
`overrideLanguage`, and ignoring a requested language is reported as a
warning.
- **Never invent alternative text.** Descriptions come from the caller.
The Convert panel can list
the images needing one (via the report endpoint) and send them back per
figure; any image left
undescribed blocks the conformance claim rather than being papered over.
- **Never certify hidden content.** Marking images decorative, or
suppressing text that could not be
tagged reliably, withdraws the claim instead of passing the checker by
hiding content.
PDF/UA-1 and PDF/UA-2 are both offered; UA-2 raises the file to PDF 2.0
and namespaces the structure
tree, and its test asserts conformance rather than merely reporting it.
Convert steps saved in Automations/Pipelines round-trip their PDF/UA
settings (profile, language,
override, title, font embedding, descriptions).
---
## 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.
Auto-generated by stirlingbot[bot]
This PR updates the backend license report based on dependency changes.
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
# Description of Changes
This PR bumps the Stirling PDF application version from `2.14.2` to
`2.14.3` across the project.
Changes include:
- Updated the Gradle project version in `build.gradle` to `2.14.3`.
- Updated the Tauri desktop application version in
`frontend/editor/src-tauri/tauri.conf.json`.
- Updated the AUR package version for `stirling-pdf-desktop`.
- Updated the AUR package version for `stirling-pdf-server-bin`.
- Updated the mocked `appVersion` used by the core frontend server
experience simulations.
- Updated the mocked `appVersion` used by the proprietary frontend
server experience simulations.
- Kept all application, desktop, packaging, and test/simulation version
references synchronized for the `2.14.3` release.
The change prepares the project metadata and packaging configuration for
the `2.14.3` release and prevents different components from reporting or
packaging the previous `2.14.2` version.
---
## 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.
## What
The super-search work pinned the editor's `WorkbenchBar` visible on
every view except My Files, even with no file open. That left an empty
workbench showing a fully painted bar whose only live control was the
search — download / close / print / save were all disabled, because
those actions only make sense with a file open.
This stops forcing the bar. When nothing is open, only the global search
floats (unpainted, centered), mirroring how the Processor already works.
When a file **is** open, the `WorkbenchBar` renders exactly as before.
Also fixes a smaller Processor issue: its floating search strip was
shorter than the sidebar logo row, so the search sat higher than the
brand. Its height now matches the logo row (51px) so they line up.
## Changes
- **`Workbench.tsx`** — render the `WorkbenchBar` only when a file is
open (or a custom view supplies content); otherwise render the new
floating search. My Files and `hideTopControls` custom views are
unchanged.
- **`WorkbenchFloatingSearch.tsx` / `.css`** (new) — the editor's
`SuperSearch` floated in an unpainted strip, mirroring
`PortalSearchBar`. Its vertical band matches the bar's so opening a file
swaps in the bar without a shift.
- **`PortalSearchBar.css`** — strip height matched to
`.portal-sidebar__logo` (51px) so the Processor search aligns with the
logo.
The notification bell is intentionally out of scope — it ships in a
separate PR.
## Before / after
(ignore the bell icon in the after that’s not live yet)
<img width="2056" height="1077" alt="Screenshot 2026-08-20 at 2 28
17 AM"
src="https://github.com/user-attachments/assets/144f5216-4784-42b2-8c09-afda43577ad0"
/>
<img width="2056" height="1071" alt="Screenshot 2026-08-20 at 2 28
31 AM"
src="https://github.com/user-attachments/assets/63af9303-af8a-48dd-b113-485169fb4924"
/>
- **Editor, no file:** painted bar with disabled buttons → just a
floating search.
- **Editor, file open:** unchanged.
- **Processor:** search now vertically aligned with the logo.
## Testing
- `task frontend:check` — lint (incl. colour linters) + typecheck +
tests (247 files / 2137 tests) all pass.
- Processor alignment verified in Storybook (`Portal/Shell/AppShell`):
logo row, search strip, and search pill share the same vertical center.
- Editor float not verified in-browser (local backend is behind a login
gate); covered by types/tests and reuses the verified Processor pattern.
# Description of Changes
Currently in the Processor's Pipelines page, none of the tools which
require supporting files are usable because it's never been hooked up to
the new API to upload supporting files. This PR hooks it up to that so
all tools using supporting files work in the processor. I had to tweak
the type generation a little for this so we have a static map of which
params are for supporting files so we know to handle them differently.
The `Test with a file` button has to work a little differently than the
main run since it's running an ad-hoc pipeline so the files haven't
necessarily been saved to the server yet. In this case, it'll use
whatever local changes the user has made for those pipeline steps, and
for all other steps, it'll just use what's saved in the server.
Auto-generated by stirlingbot[bot]
This PR updates the frontend license report based on changes to
package.json dependencies.
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
# Description of Changes
This pull request upgrades the frontend PDF dependency from
`@cantoo/pdf-lib` 2.6.5 to 2.8.2.
- Updated `frontend/package.json` to require `@cantoo/pdf-lib` `^2.8.2`.
- Regenerated `frontend/package-lock.json` with `@cantoo/pdf-lib@2.8.2`,
`pako@2.2.0`, and `node-html-better-parser@1.5.9`.
- Added the root npm `pako` override recommended by the upstream
release.
- The upgrade brings upstream parser, object-stream, encryption, form,
PNG, and PDF serialization fixes into the frontend dependency.
- No application API migration was required because the project does not
use the newly added PDF/A, XFA, Factur-X, incremental-update, fontkit,
or page-content-extraction APIs.
The main challenge was validating the broad upstream change set against
the project's actual usage. The frontend typecheck and a direct PDF
create/save/load smoke test passed. The complete `frontend:check` and
`frontend:test` tasks exceeded the available execution timeout without
reporting a test failure.
No related issue.
---
## 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/HowToAddNewLanguage.md)
(if applicable)
- [x] 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/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### 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 tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.
## The problem
The `dev` profile hardcoded one project ref (`qacaivhsjtftfwtgjvva`) in
five places: the ref, the Supabase URL, the publishable key, the
datasource host and the meter endpoint.
That made it both the shared environment everyone relies on *and* the
only thing you could point the backend at. Testing an open SaaS PR meant
hand-overriding all five via env just to reach that PR's Supabase
preview branch, which is the only place the PR's migrations have
actually been applied. Get it wrong and you see `relation
"stirling_pdf.<new table>" does not exist` for a table the PR added,
which is what happened on
[#7414](https://github.com/Stirling-Tools/Stirling-PDF/pull/7414).
## One task per environment
```bash
task dev:saas # backend + frontend + engine, against this PR's preview branch
task staging:saas # backend + frontend + engine, against the shared v3 project
task backend:dev:saas # backend only, preview branch
task backend:staging:saas # backend only, v3
```
| | how | vars | project |
|---|---|---|---|
| prod | `PROFILES=none` | `SAAS_DB_*` | the live one |
| staging | `PROFILES=staging` | `SAAS_STAGING_*` | pinned to v3, always
there |
| dev | `PROFILES=dev` | `SAAS_DEV_*` | follows a SaaS PR's preview
branch |
`PROFILES` is still the underlying switch, so the old spelling keeps
working. Production deliberately has no named task: reaching it should
take a conscious `PROFILES=none`, not a tab-complete.
**staging** is the old `dev` configuration, moved and kept pinned. The
value of a shared environment is that it is still there tomorrow:
reproduce a bug, paste a link to a colleague, share data.
**dev** is parameterised by `SAAS_DEV_PROJECT_REF` and derives the
Supabase URL, JWT issuer, JWKS, meter endpoint and (unless overridden)
the database host from it. Switching which PR you are testing is one
variable instead of five. With no ref set, `task backend:dev:saas` stops
and says what to set rather than falling back.
## The frontend was the real gap
`frontend/editor/.env` is committed and pins the **production** Supabase
project, and nothing in the frontend knew about dev or staging. So `task
dev:saas` gave you a backend on a preview branch and a login against
prod, unless you happened to have hand-written
`frontend/editor/.env.saas.local`.
The dev tasks now read the backend's env files and derive
`VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` from the
same project ref the backend resolved, so the two halves cannot point at
different projects. Nothing to keep in sync by hand, no new vite mode,
and `SAAS_ENV=prod` opts back out to the committed values.
## Where to put your local values
Two files, both gitignored, neither ever committed:
**`app/.env.saas.local`** is the only one you normally need. The tasks
load it for the backend *and* the frontend.
```bash
# staging: everything else is already defaulted, so this is all it takes
SAAS_STAGING_DB_PASSWORD=...
# dev: the preview branch of the PR you are testing, from its "Supabase Preview" check.
# A branch has its OWN password and API keys; the parent project's will not authenticate.
SAAS_DEV_PROJECT_REF=...
SAAS_DEV_DB_PASSWORD=...
SAAS_DEV_PUBLISHABLE_KEY=...
# prod, if you ever need it
SAAS_DB_PROJECT_REF=...
SAAS_DB_URL=...
SAAS_DB_PASSWORD=...
SUPABASE_EDGE_FUNCTION_SECRET=...
```
**`frontend/editor/.env.saas.local`** is no longer needed for choosing a
Supabase project, and is best left empty or deleted. If you have one
from before this PR, note that the task-supplied values now win, which
is the point: the frontend follows the backend.
**A blank is not the same as absent.** A dotenv line with an empty value
still *sets* the variable, and Spring's `${VAR:default}` only falls back
when a variable is absent. So `.env.saas` lists what you must set as
blanks, and leaves out the two `*_DB_URL` overrides, which have real
defaults to fall back to. This is not theoretical, see below.
Committed `app/.env.saas` holds non-secret defaults only. Real secrets
are passwords, the edge-function secret and service-role keys. Project
refs and publishable keys are neither: a ref is the public
`<ref>.supabase.co` subdomain and a publishable key ships in the browser
bundle by design, which is why `frontend/editor/.env` has always carried
prod's.
## Three bugs found while building the tasks
All three were in this PR's own earlier commits, and all three were
caught by actually booting things rather than by reading the config.
**staging could not boot at all.** A blank `SAAS_STAGING_DB_URL=` in
`.env.saas` set the variable to empty, so
`${SAAS_STAGING_DB_URL:jdbc:...}` resolved to `""` and startup failed
with `spring.datasource.url is required when the saas profile is
active`. The file already carried a comment warning about exactly this;
it had only been applied to the dev block. The original verification for
this PR was "placeholders resolve" and "the task parses", neither of
which boots anything.
**The dev to staging fallback ran `ddl-auto=update` against shared v3.**
The dev profile sets `update`, which is right for a disposable preview
branch, and separately fell back to staging's project ref. Together that
meant Hibernate was free to reconcile tables that RLS policies depend
on. `application-staging.properties` pins `none`, but that only applies
when the staging profile is the active one, which it was not on the
fallback path. There is no fallback now: with no ref the task stops
before gradle, and the frontend fails the same way, both naming the
variable.
**`PROFILES=` never selected production.** Go template `default` treats
`""` as absent, so it silently resolved back to `dev`. It is
`PROFILES=none` now.
## Two choices worth reviewing
**Staging keeps its committed project ref**, now as a
`${SAAS_STAGING_PROJECT_REF:...}` default in one place, with the URL,
database host and meter endpoint all derived from it. So staging still
works with zero setup, and repointing it is one variable. Nothing in CI
referenced the ref or the profile. Its publishable key default carries
no inline `gitleaks:allow`: a trailing comment in a `.properties` file
is part of the value, so the pragma ended up inside the key. It is in
`.gitleaksignore` instead.
**`SAAS_DEV_DB_URL` still overrides the whole URL**, so a branch needing
the pooler host rather than the direct one is reachable without touching
committed config.
## Verification
- `task backend:staging:saas` boots against v3 and serves `200`. It
could not boot before this commit.
- `task backend:dev:saas` with no ref stops before gradle naming the
variable, and `PROFILES=none` still reaches production. `task
frontend:dev:saas` fails the same way; `SAAS_ENV=staging` still resolves
with no local config.
- Frontend routing picks the SaaS runner for dev/staging and the plain
runner for prod; the derivation returns the right URL and key for each.
- Vite's `process.env` precedence and Task's dotenv/env semantics were
measured, not assumed. That is how one trap surfaced: Task sets an
`env:` key even when its value resolves to empty, and Vite treats an
empty `process.env` `VITE_*` as authoritative over a committed `.env`.
Putting the Supabase vars on the shared `dev:_run` would have blanked
Supabase config for the core, proprietary and desktop dev servers, so
the SaaS path has its own runner.
- `:saas:spotlessApply` and `:saas:compileJava` green.
`DevProfileProjectNotice` becomes `SaasProjectNotice` and covers both
profiles, stating the project ref and `ddl-auto` at startup so which
environment you are on is never a guess.
No behaviour change for prod: the `saas` profile is untouched.
## What
Running a stored policy against its **configured sources** (`POST
/api/v1/policies/{id}/trigger`, the manual "run now") now requires the
policy-management role — global admin self-hosted, team leader on SaaS —
alongside the existing team scoping.
## Why
A source sweep operates on the team's configured sources using the
server's stored connection credentials, so it belongs with the other
policy-management capabilities rather than with ordinary use. Team
scoping on its own didn't express that distinction.
## Not changed
- `POST /{id}/run` — running a policy over documents the **caller
supplied** stays open to every team member. That's ordinary editor
enforcement on upload and export, and gating it would break it.
- Ad-hoc pipelines (`/run`, `/run/stream`).
- The scheduled, folder-watch and webhook triggers.
- Single-user deployments (login disabled), which have no roles.
## Implementation
`PolicyManagementAuthority` gains `canTriggerPolicies()`, kept separate
from `canEditPolicies()` so the two capabilities can diverge later. Both
current implementations grant it to the same principals that may edit
policies.
## Tests
- role absent → 403, rejected before any run starts
- role present → 202
- login disabled → check skipped entirely
- `/{id}/run` asserted to consult neither authority method, so the gate
can't quietly extend to the editor path later
## Summary
This pull request restructures Gradle dependency caching across the
GitHub Actions workflows.
The central `gradle-cache-prime` job is responsible for preparing the
shared backend Gradle cache. Reusable workflows restore that shared
cache without writing to the same key, while independently triggered
workflows use isolated cache namespaces.
## What changed
### Shared Gradle cache
- Added a stable `gradle-v1-` cache namespace for the shared backend
cache.
- The cache key includes the runner OS, runner architecture, JDK
version, and the relevant Gradle configuration files.
- The cache key is calculated before Gradle runs and reused for the
later save step.
- The prime job performs a lookup first and resolves backend
dependencies only when the exact cache is missing.
- This prevents Gradle or Spotless changes during the prime step from
producing a different save key from the key used by downstream jobs.
### Reusable workflows
- Backend, OpenAPI, license, Docker, E2E, and migration workflows
restore the shared cache instead of writing to the shared key.
- The backend build matrix includes `matrix.jdk-version` in its cache
key.
- Enterprise, Tauri, and generated-model workflows support the
`use_shared_cache` boolean input.
- When `use_shared_cache` is enabled, those workflows restore the shared
cache.
- When it is disabled, they use workflow-specific cache namespaces.
### Independent workflows
Independent workflows now use separate cache prefixes, including:
- `gradle-license-report-v1-`
- `gradle-swagger-v1-`
- `gradle-push-docker-v1-`
- `gradle-tauri-releases-v1-`
- `gradle-deploy-pr-v1-`
- `gradle-playwright-e2e-v1-`
- `gradle-generated-models-v1-`
This prevents them from creating or affecting the shared backend cache
before the prime job.
### Build and E2E flow
- Removed the `-PnoSpotless` option from the central Gradle
dependency-resolution command.
- Removed the separate Gradle dependency prime/retry logic from the live
E2E workflow.
- Connected the Tauri build and generated-models check to the central
cache-prime job.
## Motivation
Previously, multiple workflows could use and save the same Gradle cache
key independently. The first workflow to save the cache could therefore
determine its contents, even if it had resolved a different or
incomplete set of dependencies.
The cache key was also evaluated after some Gradle tasks had run. If
Gradle or Spotless modified a file covered by `hashFiles(...)`, the save
key could differ from the restore key used by downstream jobs.
This change gives the shared cache a single owner, isolates
workflow-specific caches, and makes cache usage deterministic across the
CI pipeline.
## Expected result
- `gradle-cache-prime` is the single writer for the shared backend
Gradle cache.
- Downstream jobs restore the same cache without competing cache writes.
- Independently triggered workflows remain isolated through their own
cache namespaces.
- Changes to the monitored Gradle configuration files produce a new
cache key.
- The normal Gradle/Spotless path is included when the shared cache is
populated.
## Validation
- Compared the cache key expressions and `hashFiles(...)` inputs across
the affected workflows.
- Verified that the central restore and save steps use the same
precomputed key.
- CI should confirm that the prime job populates the shared cache and
downstream workflows only restore it.
## Checklist
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have performed a self-review of my changes
- [ ] I have run the relevant CI checks
- [ ] I have tested the workflow changes
## What
Both sidebars ended in a different bottom section. The editor showed an
account row (avatar, name, settings); the processor showed a "Link
Stirling account" CTA plus a `Settings` nav item and no identity at all.
They are now **one shared `<NavFooter>`** rendering the same rows in
both apps, in this order:
1. the link-account CTA (self-hosted, when unlinked)
2. free credits remaining
3. **Open \<the other app\>**
4. the account row — avatar, name, settings
It's a **single surface** with hairline dividers between rows, not
stacked cards. Rows are assembled as a list, so a row this build doesn't
show (no wallet, no processor access, nothing to link) takes its divider
with it rather than leaving a stray line.
This also fixes the profile-picture/initials desync between the sidebar
and the account settings page.
## Screenshots
Captured with the stubbed Playwright harness at 1600x900, scoped to the
sidebar and auto-cropped to the region that actually changed. Base is
`origin/main`; every state is driven by dummy backend stubs so all the
nav-bar permutations are covered.
<img width="2104" height="3044" alt="montage_cloud-dark"
src="https://github.com/user-attachments/assets/667c9a71-3ab7-4582-9259-08cda521e238"
/>
<img width="2104" height="3044" alt="montage_cloud-light"
src="https://github.com/user-attachments/assets/126bc994-efa3-436b-bf50-31d76f574eaa"
/>
<img width="2104" height="1492" alt="montage_editor-dark"
src="https://github.com/user-attachments/assets/c725726d-ea90-4fa9-bf0a-6014f3729869"
/>
<img width="2104" height="1490" alt="montage_editor-light"
src="https://github.com/user-attachments/assets/d0d34e0a-ac60-4f8c-af15-afb66fbd679e"
/>
<img width="2104" height="1066" alt="montage_processor-dark"
src="https://github.com/user-attachments/assets/51ccfe63-c327-41f4-ab91-74555b6f7148"
/>
<img width="2104" height="1068" alt="montage_processor-light"
src="https://github.com/user-attachments/assets/c90d7aba-90fa-4998-ac8f-26ce666dde71"
/>
The free-credits meter is a cloud-build surface, so the self-hosted
capture can't reach it. Those states come from the new Storybook stories
with dummy wallet data (`Shared/NavFooter`), which is also where the
credit tone bands and the collapsed rail are easiest to review.
## How it's wired
`NavFooter` is purely presentational. Each app resolves its own data
through three `@app/*` seams, so core carries no build-specific gating
and any box whose data is absent is dropped rather than rendered empty.
| Seam | core | cloud / proprietary / saas |
|---|---|---|
| `useFreeCreditsSummary` | `null` — self-hosted editor installs aren't
metered | cloud reads `freeRemaining` / `freeAllowance` off the same
`useWallet()` the Plan page's free meter uses, so the sidebar and Plan
can't disagree |
| `useOtherAppSwitch` | `null` — core ships no processor | gated on
`portalAccess` (`/api/v1/auth/me` in SaaS, the Spring session flag
self-hosted) |
| Link-account CTA | n/a | unchanged conditions — passed in as
`accountExtras`, still only when `linkState === "unlinked"`, still a
no-op in SaaS |
- The processor reads the meter through its own
`@portal/hooks/useFreeCreditsSummary` rather than the editor's `@app`
one. Self-hosted resolves `@app/*` as proprietary → core, where the
cloud wallet hook isn't in the cascade, and the implementation can't
live in `proprietary/` because core/desktop builds ship no portal and
must never resolve `@portal`. Keeping it in `portal/` gets the figure to
the linked self-hosted processor without weakening that rule; it reads
the same `GET /api/v1/payg/wallet` the Usage page's trial meter already
renders, gated on link state and behind the portal's query cache.
`portal-saas/` just re-exports the cloud hook, so both footers share one
fetch.
The processor-access gate previously lived in two near-identical
`AppSwitcher` copies. It moves into `useOtherAppSwitch`, `AppSwitcher`
now reads it too, and the duplicate
`saas/components/shared/AppSwitcher.tsx` is deleted — the logo switcher
and the footer row can no longer disagree about access.
## Profile picture sync
One `useAccountIdentity` hook now backs the editor footer, the processor
footer and the account settings page. Previously settings derived its
initial from `email[0]` while the sidebar used `displayName[0]`, and the
two drew different blue discs. Alongside that, the shared `Avatar`:
- falls back to initials when a picture URL fails to load, instead of
leaving an empty disc
- renders one letter for single-word names (`admin` → "A", not "AD")
- gains an `xl` size so the settings hero disc is the same component
## Notes
- Labelled **"Free credits"** rather than "free monthly credits":
`freeAllowance` is documented as a one-time lifetime grant, not a
monthly reset, so "monthly" would misdescribe the data. Happy to change
if the backend semantics differ from the type comments.
## Testing
- `task frontend:check` and `task frontend:typecheck:all` pass (all 9
build variants).
- 9 new `Shared/NavFooter` stories pass the Chromium + axe story scan;
`frontend:storybook:a11y:changed` reports no regressions.
- Stubbed E2E suite passes, including the `config-button` tour/settings
specs that target the account row. Two failures (`console-clean ›
landing`, `viewer-text-selection › Ctrl+C`) also fail on `origin/main`
locally — they need a backend on :8080 and clipboard permissions.
Every top bar styled itself, so none of them matched the new UI. Also,
colors on the premium banner (and possibly others) clashed since the
theme changes.
## Before Example Issue
<img width="1934" height="348" alt="Screenshot 2026-08-17 at 11 47
20 PM"
src="https://github.com/user-attachments/assets/b6f13207-2f47-4084-bd3b-2392f572c1a1"
/>
## After (all)
<img width="2880" height="800" alt="danger__dark"
src="https://github.com/user-attachments/assets/6b311dec-23e7-4059-a6bb-75527cbd2e34"
/>
<img width="2880" height="800" alt="danger__light"
src="https://github.com/user-attachments/assets/3b04b109-5146-4874-88b3-d99770ea51f8"
/>
<img width="2880" height="800" alt="default-app__dark"
src="https://github.com/user-attachments/assets/b0101b98-fce8-467a-99a6-dd40b8864da1"
/>
<img width="2880" height="800" alt="default-app__light"
src="https://github.com/user-attachments/assets/8aa21f01-6549-4d78-b217-c5547d60ab5b"
/>
<img width="2880" height="800" alt="free-tier-limit__dark"
src="https://github.com/user-attachments/assets/07fd7498-f44f-408f-8c79-9b5ea55e13df"
/>
<img width="2880" height="800" alt="free-tier-limit__light"
src="https://github.com/user-attachments/assets/f38c527b-9f64-4db0-b84c-56ca48e464cc"
/>
<img width="2880" height="800" alt="server-attention__dark"
src="https://github.com/user-attachments/assets/447a36fa-056d-4ca9-8b30-04aa0ccd6ed1"
/>
<img width="2880" height="800" alt="server-attention__light"
src="https://github.com/user-attachments/assets/f0311d45-a218-4846-b0ac-47996e2637c5"
/>
<img width="2880" height="800" alt="team-invitation__dark"
src="https://github.com/user-attachments/assets/cc368473-3b9f-4ab0-878a-67da993874c2"
/>
<img width="2880" height="800" alt="team-invitation__light"
src="https://github.com/user-attachments/assets/19d52a3e-4028-46f0-8562-9bd9cef9397a"
/>
<img width="2880" height="800" alt="upgrade-prompt__dark"
src="https://github.com/user-attachments/assets/e9d20daa-f41f-46d9-b827-a84f276e8af1"
/>
<img width="2880" height="800" alt="upgrade-prompt__light"
src="https://github.com/user-attachments/assets/9b6250c1-6a61-40d6-a7ab-e37398d67322"
/>
## What changed
- `InfoBanner` exposed 8 colour-override props (`background`,
`borderColor`, `textColor`, `iconColor`, `buttonColor`,
`buttonTextColor`, `closeIconColor`, `buttonVariant`), so every caller
invented its own look. Replaced with a closed tone set: `info` · `promo`
· `warning` · `danger`.
- Tone drives the whole bar — fill, border, icon and the button — so a
CTA can't drift from the bar it sits on. Text is neutral in every tone;
only the icon carries the tone colour.
- All colour comes from `--c-*` tokens mixed over `--c-surface`, so the
bars follow light and dark instead of ignoring them. The old bars were
hardcoded: in dark mode the two licence warnings stayed cream-on-white.
- `promo` keeps the gradient it was always meant to have, built from the
existing `--c-hue-indigo`/`--c-hue-purple` stops (documented in
`colors.css` as gradient hues, deliberately not accent-following), with
the existing `premium` button accent on it.
- Deleted the hardcoded colours from all four callers: the purple
gradient (`#667eea`→`#764ba2`), the orange soup (`#FFF4E6` / `#9A3412` /
`#EA580C`) duplicated across the urgent banner and the admin plan
section, and the fixed dark bar (`--mantine-color-dark-7`) on the team
invitation.
- `UpgradeBanner|AdminPlanSection` sat on the theme linter's exemption
list, which is how those colours survived the theme migration. Exemption
removed, so `code-colors` now guards them.
- The banner's class was colliding with `core/ui/Banner.css`'s
`.sui-banner` (16 live rules), which restyled it in the app but not in
Storybook — that's why the two disagreed on radius, border and tone.
Renamed to `.app-banner`; the two surfaces now render identically.
- Bar is square and full-bleed with a single hairline rule underneath;
button labels are optically centred.
- Added `--c-warning-subtle`, matching the existing `--c-danger-subtle`
/ `--c-success-subtle`.
- New `Shared → Top bars` story renders all six bars at once, so a
change to the shared component is visible against the whole set.
- Unrelated one-liner: `frontend/.prettierignore` now ignores the
gitignored `editor/screenshots/` capture artifacts, which were failing
`format:check` locally. Happy to drop it if you'd rather keep this PR to
the bars.
## Testing
- `task frontend:check` — typecheck, lint (oxlint + 4 theme-lint passes
+ stylelint), format, 244 files / 2119 tests.
- `frontend:storybook:a11y:changed` — clean in light and dark.
- The a11y gate caught a real defect mid-change: giving each banner
`role="region"` with the same label produced duplicate landmarks, which
the app hits for real whenever two banners show at once. Landmark
removed.
- All six bars captured in the running editor, light and dark, and
diffed against `origin/main`'s component rendered with each caller's
original props.
# Description of Changes
Overlay PDFs and Change Metadata both crashed in the Processor because
they required `FilesModalContext` and `ViewerContext` respectively.
Neither of those contexts make sense to provide in the Processor because
there are no files in context and there is no Viewer, so redesign both
tool settings to only optionally require these contexts. Their behaviour
is unchanged in the Editor but they now work in the Processor (just
without the extra info about the active files, since there are none).
Also hooks up the Reorganise Pages settings so that it can be used from
Automate. The component already existed but just wasn't being used,
which just looks like an oversight.
## The problem
The SaaS database has two writers and always has: the Supabase
migrations in the SaaS repo, and Hibernate's `ddl-auto`. That was a
convention rather than a rule, and it leaked twice.
- An older `ddl-auto` run widened `team_memberships.role` to
varchar(255), which needed [a dedicated
migration](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/blob/v3/supabase/migrations/20260804000000_fix_team_memberships_role_varchar50.sql)
to repair, because RLS policies depended on the column.
- `payg_instance_usage` shipped with an entity and **no migration**, and
nobody noticed for months — staging already had the table from an
earlier `ddl-auto` run. It surfaced only when a fresh preview branch,
built from migrations alone, threw `relation does not exist`.
Both are the same bug: nobody had to *say* who owned a table, so the
answer got decided by accident.
## The fix
`SaasSchemaOwnership` is the register — **29 migration-owned, 29
inherited** and left to Hibernate.
`MigrationOwnedSchemaFilter` applies it via Hibernate's
`hbm2ddl.schema_filter_provider`, wired on the **saas profile only**.
Hibernate is never shown a migration-owned table, so it cannot create,
alter, drop or truncate one whatever `ddl-auto` is set to. Inherited
tables stay managed, so a fresh preview branch still heals itself on
first boot. Self-hosted is untouched — there Hibernate rightly owns
everything.
**Why a filter rather than just `ddl-auto=none`:** off, and a fresh
branch is missing the 29 inherited tables. On, and Hibernate can reach
the other 29. The filter is what lets both be true at once.
**Why per-table, not per-schema:** Hibernate's schema management runs
over every mapped entity regardless of namespace. Moving SaaS tables to
their own schema would *not* by itself keep Hibernate out of them —
worth knowing, because that was the intuitive fix and it doesn't work.
## The part that makes it stick
`SaasSchemaOwnershipTest` makes the register binding: every `@Entity` on
the SaaS classpath must appear in exactly one set, so **a new entity
fails the build until someone states who owns its table**. That's the
forcing function that would have caught `payg_instance_usage`.
I verified it bites rather than assuming it — removing a single entry
fails with:
```
These entity tables are not declared in SaasSchemaOwnership, so nobody owns them.
Offending tables -> entities: [policies (stirling.software.proprietary.policy.store.PolicyEntity)]
```
naming both the table and the class, which is what the next person
actually needs.
## One debatable call
The **validate** filter excludes them too. Letting validation through
would flag drift, which is genuinely useful — but `ddl-auto=validate`
fails startup, and it would fail on differences we've deliberately
accepted (`ai_create_sessions` carries columns from a reverted Typst
feature that nothing maps). A boot failure over a table we chose not to
manage is noise. Argued in the javadoc; happy to flip it if you'd rather
have the signal.
## Dependency
Depends on
[Stirling-PDF-SaaS#324](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/324),
which adds migrations for the four SaaS-owned tables that had none.
They're listed here as migration-owned on that basis, so #324 should
land first.
Companion to
[#7483](https://github.com/Stirling-Tools/Stirling-PDF/pull/7483)
(dev/staging profiles with per-profile `ddl-auto`).
## Verification
`:saas:test` green including the 5 new tests, `spotlessCheck` green, and
the mutation check above.
Review Flow PR 3. Stacked on #7296. A recorded failure becomes readable
by the person who caused it.
## What changes
Before this, reading or triaging a failure required leader permissions:
`FileRunEventController.requireFailureReviewAllowed()` returned 403 to
anyone who could not edit policies. #7296 lets any user report a
failure, so they could file into a queue they could never read.
That gate is removed from the endpoints and the decision moves into
`FileRunEventService`:
| Caller | Reads and closes |
|---|---|
| Team leader or admin | the whole team's failures (unchanged) |
| Anyone else | only failures where `actor` is them |
| Team unresolvable | nothing |
| Name unresolvable | nothing |
`GET /kinds` is also opened. It returns static enum metadata, and a
member needs it to render failures they can already see.
## Additions
- An `actor` predicate on both list queries in `FileRunEventRepository`,
threaded through `FileRunEventStore.list`.
- `ReadScope` (permitted, teamId, actor) replacing `TeamScope`, with
`wholeTeam` / `mine` / `denied` factories.
- An actor filter on `dispatch`, so acting on another person's row
answers **404, not 403** — the same response as an id that does not
exist.
## Fixes
- **`report()` filed rows under the wrong team.** It took the team from
the read scope, which returns null for a caller who cannot be named, so
such a report landed unteamed in the bucket every team shares. It now
uses a dedicated `currentTeamId()`.
- **`forgetFiles` narrows to the caller even for a leader.** File ids
are minted by each client, so scoping on team alone would let one caller
close a colleague's incidents by naming ids.
- The controller no longer injects `PolicyManagementAuthority` or
`ApplicationProperties`; with the gate gone it decides nothing.
## Team isolation
Unchanged and covered by database-backed tests rather than mocks.
`FileRunEventStoreDbTest` asserts that a caller with a team sees only
their own team's rows and never the unteamed ones, and that the actor
predicate narrows within a team without ever widening across one. Delete
either clause from the JPQL and one of those tests fails.
No endpoint accepts a team parameter; the team always comes from the
authenticated principal.
**Attribution is fixed here too, because this PR depends on it.** A
failure's actor was read from the MDC audit principal, which carries the
BILLING identity — for a stored policy, always its owner. Since reads
are now narrowed to the rows you are the actor on, a wrong actor means
the member who caused a failure and holds the document reads nothing,
while the policy owner is handed incidents from runs they never
triggered. The triggering user is now carried on the run, separate from
the billing principal and the output owner, and is null for a
trigger-fired sweep so an unattended failure stays ownerless.
`PolicyFailureAttributionTest` runs the real engine, recorder, store and
service together. The two sides used to assert independently — the
engine's test matched the actor with `any()`, which is how this went
unnoticed.
## How to test
Needs a proprietary or SaaS build with login enabled and two accounts in
the same team, one a leader and one not. `task dev:all` gives you the
stack.
1. **As the member**, fail a tool: open a PDF and run **Remove
Password** with a wrong password.
2. **Still as the member**, go to `/processor/documents` → **Failures**.
Before this PR you got nothing here. Now you see your own row, and only
yours.
3. **As the leader**, open the same view. You see the whole team's rows,
including the member's.
4. **Member cannot reach a colleague's row.** As the leader, copy a
row's id from **Show raw JSON**. As the member, `POST
/api/v1/file-run-events/{thatId}/actions/DISMISS`. It answers **404**,
and the row is untouched — it must not answer 403, which would confirm
the row exists.
5. **Member can close their own.** Dismiss your own row as the member.
It leaves the default view.
6. **Deleting a file only closes your own rows.** As the leader, delete
a file in your editor. The member's incidents are untouched even if the
leader's client happened to name the same ids.
## Migration
None. `actor` is an existing column; this only adds predicates to
existing queries.
Bumps `awsSdkVersion` from 2.51.2 to 2.51.3.
Updates `software.amazon.awssdk:s3` from 2.51.2 to 2.51.3
Updates `software.amazon.awssdk:url-connection-client` from 2.51.2 to
2.51.3
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>
Bumps the eclipse-temurin group with 1 update in the /docker/backend
directory: eclipse-temurin.
Bumps the eclipse-temurin group with 1 update in the /docker/base
directory: eclipse-temurin.
Bumps the eclipse-temurin group with 1 update in the /docker/embedded
directory: eclipse-temurin.
Updates `eclipse-temurin` from `2f1da10` to `fbcf915`
Updates `eclipse-temurin` from `2f1da10` to `fbcf915`
Updates `eclipse-temurin` from `2f1da10` to `fbcf915`
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps `awsSdkVersion` from 2.44.12 to 2.51.2.
Updates `software.amazon.awssdk:s3` from 2.44.12 to 2.51.2
Updates `software.amazon.awssdk:url-connection-client` from 2.44.12 to
2.51.2
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps `logback` from 1.5.32 to 1.6.1.
Updates `ch.qos.logback:logback-core` from 1.5.32 to 1.6.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/qos-ch/logback/releases">ch.qos.logback:logback-core's
releases</a>.</em></p>
<blockquote>
<h2>Logback 1.6.1</h2>
<p><strong>2026-07-28 Release of logback version 1.6.1</strong></p>
<p>• In TimeBasedRollingPolicy, when the file option is set, the
intermediate file renamed before asynchronous compression now receives
the target archive name without the compression suffix (e.g.
<code>.gz</code>, <code>.zip</code>, <code>.xz</code>). Previously it
used a nanotime-based <code>.tmp</code> suffix. This makes the file
easier to identify if compression fails during rollover. (See also the
following paragraph.)</p>
<p>• On GZ, ZIP, or XZ compression failure, the original (uncompressed)
log file is no longer deleted. Compression strategies now delete the
source file only after successful compression and emit a warning that
the original was left intact.</p>
<p>• ConsoleAppender with <!-- raw HTML omitted --> now probes JLine's
org.jline.jansi.AnsiConsole first and falls back to the legacy
FuseSource org.fusesource.jansi.AnsiConsole class. This keeps ANSI
coloring working after Jansi moved under the JLine project. The optional
org.jline:jansi-core artifact is declared as a dependency alongside the
existing FuseSource jansi dependency. A preferredJansiClassName property
was added for tests. This issue was reported in <a
href="https://redirect.github.com/qos-ch/logback/issues/1043">issues/1043</a>
by <a href="https://github.com/seonwooj0810">seonwoo_jung</a> who also
provided the relevant PR.</p>
<p>• LayoutWrappingEncoder now reports an error at start() when no
layout is set and guards encode() against a null layout. Previously, a
missing layout (for example after an ignored <!-- raw HTML omitted
-->/<!-- raw HTML omitted -->/<!-- raw HTML omitted --> branch) allowed
the encoder to start and then fail with a NullPointerException on every
event, resulting in silent log loss. This issue was reported in <a
href="https://redirect.github.com/qos-ch/logback/issues/1046">issues/1046</a>
by <a href="https://github.com/seonwooj0810">seonwoo_jung</a> who also
provided the relevant PR.</p>
<p>• FileCollisionAnalyser now detects file collisions involving nested
appenders of SiftingAppender. When the nested file or fileNamePattern
does not textually reference the discriminator key (e.g. ${userId}), a
warning is issued at configuration time naming the appender, the key,
and the shared target. This closes a gap where statically declared file
appenders were checked but sifted nested appenders were not. This
enhancement was contributed in [PR <a
href="https://redirect.github.com/qos-ch/logback/issues/1041">#1041</a>](<a
href="https://redirect.github.com/qos-ch/logback/issues/1041">qos-ch/logback#1041</a>)
by <a href="https://github.com/seonwooj0810">seonwoo_jung</a>.</p>
<p>• More defensive handling in SyslogOutputStream and
SyslogAppenderBase: the close() method now ensures that resources are
closed, writes and flushes check that the underlying resources are in a
valid state and fallback to no-op otherwise.</p>
<p>• A bit-wise identical binary of this version can be reproduced by
building from source code at commit
57759f433000a133088ef0441038963134437fbd associated with the tag
v_1.6.1. The release was built using Java "21" 2023-10-17 LTS
build 21.0.1.+12-LTS-29 under Linux Debian 11.6.</p>
<p>• See <a
href="https://logback.qos.ch/news.html#1.6.1">https://logback.qos.ch/news.html#1.6.1</a>
for the original text.</p>
<h2>Logback 1.6.0</h2>
<p><strong>2026-07-23 Release of logback version 1.6.0</strong></p>
<p>• Removed certain deprecated variables, methods, and classes. For the
list of removed members see <a
href="https://logback.qos.ch/notes/release_1.6.0.txt">release_1.6.0.txt.</a></p>
<p>• In <code>AsyncAppenderBase</code>, the
<code>put(ILoggingEvent)</code> method now has the protected modifier to
allow access from derived classes. This change was requested by Thomas
Skjølberg in <a
href="https://redirect.github.com/qos-ch/logback/pull/1053">pr#1053</a>.</p>
<p>• Bump SLF4J dependency to version 2.0.18.</p>
<p>• <strong>See also the overview of the <a
href="https://logback.qos.ch/news.html#latest_stable">1.6.x
series</a>.</strong></p>
<p>• A bit-wise identical binary of this version can be reproduced by
building from source code at commit
b07adf36019b51a10f824fdd94009985c587b1d3 associated with the tag
v_1.6.0. The release was built using Java "21" 2023-10-17 LTS
build 21.0.1.+12-LTS-29 under Linux Debian 11.6.</p>
<h2>Logback 1.5.38</h2>
<p><strong>2026-07-09 Release of logback version 1.5.38</strong></p>
<p>• In <code>HardenedObjectInputStream</code>, fixed a typo preventing
<code>Throwable</code> objects from being white-filtered. This issue was
reported in [PR <a
href="https://redirect.github.com/qos-ch/logback/issues/1045">#1045</a>](<a
href="https://redirect.github.com/qos-ch/logback/pull/1045">qos-ch/logback#1045</a>)
by <a href="https://github.com/t0rchwo0d">t0rchwo0d</a>.</p>
<p>• A bitwise identical binary of this version can be reproduced by
building from source code at commit
d04984a41fce42977466f45a2f076f0ee5cc4207 associated with the tag
v_1.5.38. Release built using Java "21" 2023-10-17 LTS build
21.0.1.+12-LTS-29 under Linux Debian 11.6.</p>
<h2>Logback 1.5.37</h2>
<p><strong>2026-06-26 Release of logback version 1.5.37</strong></p>
<ol>
<li>• Given the numerous vulnerabilities related to conditional
configuration processing based on the evaluation of Java expressions
using the Janino library, support for such expressions has been removed.
Users are offered the an <a
href="https://logback.qos.ch/translator/services/conditionalConfigMigrator.html">online
migration service</a> or the <code><condition></code> element
introduced in version 1.5.20. See the <a
href="https://logback.qos.ch/manual/configuration.html#conditional">relevant
documentation</a> for more details.</li>
</ol>
<p>• A bitwise identical binary of this version can be reproduced by
building from source code at commit
c1df7f522e648eec7b4ef6a12c8758fec0f00048 associated with the tag
v_1.5.37. Release built using Java "21" 2023-10-17 LTS build
21.0.1.+12-LTS-29 under Linux Debian 11.6.</p>
<h2>Logback 1.5.36</h2>
<p><strong>2026-06-25 Release of logback version 1.5.36</strong></p>
<p>• The 'condition' attribute in <code><if></code> elements now
reject certain references that are associated with ACE attacks. This
issue was reported by "yulate" (<a
href="mailto:yulate531@gmail.com.com">yulate531@gmail.com.com</a>) and
registered as <a
href="https://www.cve.org/cverecord?id=CVE-2026-13006">CVE-2026-13006</a>.
<strong>Please note that version 1.5.37 provides the full fix to this
vulnerability.</strong></p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/qos-ch/logback/commit/57759f433000a133088ef0441038963134437fbd"><code>57759f4</code></a>
prepare release 1.6.1</li>
<li><a
href="https://github.com/qos-ch/logback/commit/175f99f2093ae07f4c8d44f93b800f65b03a6e19"><code>175f99f</code></a>
fix imports</li>
<li><a
href="https://github.com/qos-ch/logback/commit/4b8773ed127fdc62b85a7c7ddaef10f25830788c"><code>4b8773e</code></a>
add compressionFailureLeavesOriginalFileIntact test for XZ
compression</li>
<li><a
href="https://github.com/qos-ch/logback/commit/cafaf1115fd20be19b2f7a4a09184446e8bbc04d"><code>cafaf11</code></a>
do not delete original file if compression fails</li>
<li><a
href="https://github.com/qos-ch/logback/commit/ee50125b293f5a731543de9f9f5fb72756a43464"><code>ee50125</code></a>
let the temporary file before compression be target file without the .gz
or ....</li>
<li><a
href="https://github.com/qos-ch/logback/commit/5626acc301f4039537a6c668f0f2d51989472785"><code>5626acc</code></a>
minor refactoring</li>
<li><a
href="https://github.com/qos-ch/logback/commit/d97da4fbc0de00ca901ef78d91b9fc1850ae803f"><code>d97da4f</code></a>
minor refactoring</li>
<li><a
href="https://github.com/qos-ch/logback/commit/159c045d8f045ccf8b382775d81d83919c228cca"><code>159c045</code></a>
more defensive coding in SyslogOutputStream and in
SyslogAppenderBase</li>
<li><a
href="https://github.com/qos-ch/logback/commit/9427d6b23d5a692c8a76a994c076ef68ded7835c"><code>9427d6b</code></a>
slight refactoring for clarity</li>
<li><a
href="https://github.com/qos-ch/logback/commit/79c4179c0b440a9dcf35bc9bda1ead2b2f90966c"><code>79c4179</code></a>
slight refactoring</li>
<li>Additional commits viewable in <a
href="https://github.com/qos-ch/logback/compare/v_1.5.32...v_1.6.1">compare
view</a></li>
</ul>
</details>
<br />
Updates `ch.qos.logback:logback-classic` from 1.5.32 to 1.6.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/qos-ch/logback/releases">ch.qos.logback:logback-classic's
releases</a>.</em></p>
<blockquote>
<h2>Logback 1.6.1</h2>
<p><strong>2026-07-28 Release of logback version 1.6.1</strong></p>
<p>• In TimeBasedRollingPolicy, when the file option is set, the
intermediate file renamed before asynchronous compression now receives
the target archive name without the compression suffix (e.g.
<code>.gz</code>, <code>.zip</code>, <code>.xz</code>). Previously it
used a nanotime-based <code>.tmp</code> suffix. This makes the file
easier to identify if compression fails during rollover. (See also the
following paragraph.)</p>
<p>• On GZ, ZIP, or XZ compression failure, the original (uncompressed)
log file is no longer deleted. Compression strategies now delete the
source file only after successful compression and emit a warning that
the original was left intact.</p>
<p>• ConsoleAppender with <!-- raw HTML omitted --> now probes JLine's
org.jline.jansi.AnsiConsole first and falls back to the legacy
FuseSource org.fusesource.jansi.AnsiConsole class. This keeps ANSI
coloring working after Jansi moved under the JLine project. The optional
org.jline:jansi-core artifact is declared as a dependency alongside the
existing FuseSource jansi dependency. A preferredJansiClassName property
was added for tests. This issue was reported in <a
href="https://redirect.github.com/qos-ch/logback/issues/1043">issues/1043</a>
by <a href="https://github.com/seonwooj0810">seonwoo_jung</a> who also
provided the relevant PR.</p>
<p>• LayoutWrappingEncoder now reports an error at start() when no
layout is set and guards encode() against a null layout. Previously, a
missing layout (for example after an ignored <!-- raw HTML omitted
-->/<!-- raw HTML omitted -->/<!-- raw HTML omitted --> branch) allowed
the encoder to start and then fail with a NullPointerException on every
event, resulting in silent log loss. This issue was reported in <a
href="https://redirect.github.com/qos-ch/logback/issues/1046">issues/1046</a>
by <a href="https://github.com/seonwooj0810">seonwoo_jung</a> who also
provided the relevant PR.</p>
<p>• FileCollisionAnalyser now detects file collisions involving nested
appenders of SiftingAppender. When the nested file or fileNamePattern
does not textually reference the discriminator key (e.g. ${userId}), a
warning is issued at configuration time naming the appender, the key,
and the shared target. This closes a gap where statically declared file
appenders were checked but sifted nested appenders were not. This
enhancement was contributed in [PR <a
href="https://redirect.github.com/qos-ch/logback/issues/1041">#1041</a>](<a
href="https://redirect.github.com/qos-ch/logback/issues/1041">qos-ch/logback#1041</a>)
by <a href="https://github.com/seonwooj0810">seonwoo_jung</a>.</p>
<p>• More defensive handling in SyslogOutputStream and
SyslogAppenderBase: the close() method now ensures that resources are
closed, writes and flushes check that the underlying resources are in a
valid state and fallback to no-op otherwise.</p>
<p>• A bit-wise identical binary of this version can be reproduced by
building from source code at commit
57759f433000a133088ef0441038963134437fbd associated with the tag
v_1.6.1. The release was built using Java "21" 2023-10-17 LTS
build 21.0.1.+12-LTS-29 under Linux Debian 11.6.</p>
<p>• See <a
href="https://logback.qos.ch/news.html#1.6.1">https://logback.qos.ch/news.html#1.6.1</a>
for the original text.</p>
<h2>Logback 1.6.0</h2>
<p><strong>2026-07-23 Release of logback version 1.6.0</strong></p>
<p>• Removed certain deprecated variables, methods, and classes. For the
list of removed members see <a
href="https://logback.qos.ch/notes/release_1.6.0.txt">release_1.6.0.txt.</a></p>
<p>• In <code>AsyncAppenderBase</code>, the
<code>put(ILoggingEvent)</code> method now has the protected modifier to
allow access from derived classes. This change was requested by Thomas
Skjølberg in <a
href="https://redirect.github.com/qos-ch/logback/pull/1053">pr#1053</a>.</p>
<p>• Bump SLF4J dependency to version 2.0.18.</p>
<p>• <strong>See also the overview of the <a
href="https://logback.qos.ch/news.html#latest_stable">1.6.x
series</a>.</strong></p>
<p>• A bit-wise identical binary of this version can be reproduced by
building from source code at commit
b07adf36019b51a10f824fdd94009985c587b1d3 associated with the tag
v_1.6.0. The release was built using Java "21" 2023-10-17 LTS
build 21.0.1.+12-LTS-29 under Linux Debian 11.6.</p>
<h2>Logback 1.5.38</h2>
<p><strong>2026-07-09 Release of logback version 1.5.38</strong></p>
<p>• In <code>HardenedObjectInputStream</code>, fixed a typo preventing
<code>Throwable</code> objects from being white-filtered. This issue was
reported in [PR <a
href="https://redirect.github.com/qos-ch/logback/issues/1045">#1045</a>](<a
href="https://redirect.github.com/qos-ch/logback/pull/1045">qos-ch/logback#1045</a>)
by <a href="https://github.com/t0rchwo0d">t0rchwo0d</a>.</p>
<p>• A bitwise identical binary of this version can be reproduced by
building from source code at commit
d04984a41fce42977466f45a2f076f0ee5cc4207 associated with the tag
v_1.5.38. Release built using Java "21" 2023-10-17 LTS build
21.0.1.+12-LTS-29 under Linux Debian 11.6.</p>
<h2>Logback 1.5.37</h2>
<p><strong>2026-06-26 Release of logback version 1.5.37</strong></p>
<ol>
<li>• Given the numerous vulnerabilities related to conditional
configuration processing based on the evaluation of Java expressions
using the Janino library, support for such expressions has been removed.
Users are offered the an <a
href="https://logback.qos.ch/translator/services/conditionalConfigMigrator.html">online
migration service</a> or the <code><condition></code> element
introduced in version 1.5.20. See the <a
href="https://logback.qos.ch/manual/configuration.html#conditional">relevant
documentation</a> for more details.</li>
</ol>
<p>• A bitwise identical binary of this version can be reproduced by
building from source code at commit
c1df7f522e648eec7b4ef6a12c8758fec0f00048 associated with the tag
v_1.5.37. Release built using Java "21" 2023-10-17 LTS build
21.0.1.+12-LTS-29 under Linux Debian 11.6.</p>
<h2>Logback 1.5.36</h2>
<p><strong>2026-06-25 Release of logback version 1.5.36</strong></p>
<p>• The 'condition' attribute in <code><if></code> elements now
reject certain references that are associated with ACE attacks. This
issue was reported by "yulate" (<a
href="mailto:yulate531@gmail.com.com">yulate531@gmail.com.com</a>) and
registered as <a
href="https://www.cve.org/cverecord?id=CVE-2026-13006">CVE-2026-13006</a>.
<strong>Please note that version 1.5.37 provides the full fix to this
vulnerability.</strong></p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/qos-ch/logback/commit/57759f433000a133088ef0441038963134437fbd"><code>57759f4</code></a>
prepare release 1.6.1</li>
<li><a
href="https://github.com/qos-ch/logback/commit/175f99f2093ae07f4c8d44f93b800f65b03a6e19"><code>175f99f</code></a>
fix imports</li>
<li><a
href="https://github.com/qos-ch/logback/commit/4b8773ed127fdc62b85a7c7ddaef10f25830788c"><code>4b8773e</code></a>
add compressionFailureLeavesOriginalFileIntact test for XZ
compression</li>
<li><a
href="https://github.com/qos-ch/logback/commit/cafaf1115fd20be19b2f7a4a09184446e8bbc04d"><code>cafaf11</code></a>
do not delete original file if compression fails</li>
<li><a
href="https://github.com/qos-ch/logback/commit/ee50125b293f5a731543de9f9f5fb72756a43464"><code>ee50125</code></a>
let the temporary file before compression be target file without the .gz
or ....</li>
<li><a
href="https://github.com/qos-ch/logback/commit/5626acc301f4039537a6c668f0f2d51989472785"><code>5626acc</code></a>
minor refactoring</li>
<li><a
href="https://github.com/qos-ch/logback/commit/d97da4fbc0de00ca901ef78d91b9fc1850ae803f"><code>d97da4f</code></a>
minor refactoring</li>
<li><a
href="https://github.com/qos-ch/logback/commit/159c045d8f045ccf8b382775d81d83919c228cca"><code>159c045</code></a>
more defensive coding in SyslogOutputStream and in
SyslogAppenderBase</li>
<li><a
href="https://github.com/qos-ch/logback/commit/9427d6b23d5a692c8a76a994c076ef68ded7835c"><code>9427d6b</code></a>
slight refactoring for clarity</li>
<li><a
href="https://github.com/qos-ch/logback/commit/79c4179c0b440a9dcf35bc9bda1ead2b2f90966c"><code>79c4179</code></a>
slight refactoring</li>
<li>Additional commits viewable in <a
href="https://github.com/qos-ch/logback/compare/v_1.5.32...v_1.6.1">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 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>
Same PR as #6396, just with the conflicts resolved and some fixes on
top. Original commit by @saul1310 is preserved as-is; everything else is
a follow-up commit.
Refs #6272
## Conflicts
#6396 was written before the frontend was restructured, so all four
files it touched moved (`frontend/src/**` -> `frontend/editor/src/**`)
and `LinkLayer.tsx` had drifted. Cherry-picked with rename detection and
re-resolved against current `main`.
## Fixes on top
- **Reuse the existing platform seam instead of adding a second one.**
`main` already has `@app/platform/*` seams with per-flavour
implementations; #6396 added a parallel `@app/utils/openExternalUrl`
core+desktop pair that re-implemented the Tauri shell call already in
`desktop/platform/openExternal.ts`. Split into a pure sanitiser
(`@app/utils/externalUrl`) and a platform seam
(`@app/platform/openExternalTab`), with the desktop impl delegating to
the existing `openExternal`.
- **Kept PDF links off the `openExternal` seam.** That seam is for
leave-and-return redirects (Stripe) and its saas impl is
`window.location.assign` - routing PDF links through it would navigate
the whole app away from the user's document. `openExternalTab` always
opens alongside the app; desktop shadows it to escape the webview.
- **Fixed the same defect in two sibling call sites** that #6396 didn't
cover: `BookmarkSidebar` (bookmark URI / LaunchAppOrOpenFile actions)
and `useAnnotationMenuHandlers` (annotation menu "go to link"). Both
called `window.open` on an unsanitised PDF-supplied URI, so on desktop
they trapped the link in the webview exactly like the viewer did.
- **Dropped the unguarded fallback.** The old code fell back to
`window.open(uri)` when `new URL()` threw, so an unparseable URI
bypassed the allowlist entirely. It is now blocked.
- Empty/whitespace URIs are blocked rather than silently resolving to
the app's own page via the base URL.
- Tests: sanitiser cases (casing, leading whitespace, `data:`,
`vbscript:`, unparseable), a core seam test asserting
new-tab-not-navigate, and a desktop seam regression test asserting the
URL goes to the OS rather than `window.open`.
- **`openExternalTab` now re-validates its own input.** Every caller
sanitises first, so nothing reached it unvalidated - but it is the sink
that hands a URL to `window.open` (executes `javascript:` in our origin)
or to an OS handler on desktop, and its safety shouldn't depend on
callers remembering. Both impls fail closed, with tests that call them
directly with `javascript:`/`data:`/`file:`/`ftp:`.
## Unrelated fix included (flagged deliberately)
The last commit fixes `frontend/editor/vitest.config.ts`: `testTimeout:
10000` was set on the root `test` block, but tests all run under
`projects`, which do not inherit it - so the whole suite has silently
been running at vitest's 5s default.
This is not cosmetic. It made `task check` fail intermittently on
unrelated portal specs (`demoData`, `ConnectionModal`); the ConsignO
test takes 2966ms with only the portal project running, i.e. 59% of a
budget it was never meant to have, so any CPU contention tips it over.
Proven with an identical 6.5s probe test: times out at 5000ms on the old
config, passes at 6512ms on the fixed one.
Happy to split this into its own PR if preferred - it is here because
the gate could not be trusted without it.
## Validation
Typecheck passes for all 7 build flavours (core, proprietary, saas,
desktop, cloud, prototypes, portal); ESLint, Prettier, dpdm and the full
1662-test vitest suite pass.
Driven live against the dev server + backend with a PDF carrying five
URI annotations (https, `javascript:`, mailto, `file:`, relative). 14/14
behavioural checks pass on this branch; 5 of them fail on `main`:
| check | main | this PR |
| --- | --- | --- |
| safe https link exposes real href (copy-link) | `href="#"` |
`https://example.com/safe-link?a=1` |
| link opens in new tab / tabnabbing-proof | no `target`/`rel` |
`_blank` + `noopener noreferrer` |
| mailto link exposes real href | `href="#"` | `mailto:test@example.com`
|
| relative URI resolved against app origin | `href="#"` | resolved |
| `javascript:` / `file:` never reach href | blocked | blocked |
| clicking blocked link doesn't execute or navigate | ok | ok |
| clicking safe link opens new tab at source URL | - | ok, app not
navigated away |
---
## 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)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Testing (if applicable)
- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally
---------
Co-authored-by: Saul <saulifshin.cs@gmail.com>
Review Flow PR 2 of 5. Editor tool failures now reach the same durable
queue as failures from folders, buckets and webhooks.
## What's added
**A report endpoint** — `POST /api/v1/file-run-events/reports`, open to
any authenticated user. Takes four fields: `operation`, `errorCode`,
`fileIds`, `detail`. No team, no actor, no filename: the first two come
from the session, the third is never a field. Refused with 400 above 200
file ids, and nothing is written when refused.
**Automatic reporting from every tool** — wired into `useToolOperation`,
so no per-tool work is needed. Client-side refusals (an unsupported
format that never reaches the server) are reported too. User
cancellations are not.
**Error codes parsed from Blob bodies as well as JSON** — a
download-typed tool call fails with a Blob, so `errorCodeOf` handles
both shapes.
**Source attribution for unattended runs** — `sourceId` is threaded from
`PolicyRunner` through `PolicyRun` to the recorded row and out to the
wire, so a folder, bucket or webhook failure names what fed it.
Previously it had none.
**Deleting a file closes its failures** — `FileContext.removeFiles`
notifies `POST /removed-files`, which transitions those incidents to
`FILE_REMOVED`. Terminal, so they leave every reviewer's queue. The rows
stay for audit.
**The queue can be emptied** — reads now default to open statuses only;
ask for a status explicitly to see closed rows.
## Behaviour changes
- **Editor failures dedup per person.** `RecordFailure.scopeRef()`
includes the actor for TOOL-origin rows, so two people hitting the same
failure on the same file are two incidents rather than one. Processor
rows are unaffected and their dedup key is byte-identical to before.
- **`UNKNOWN` offers only Dismiss.** Acknowledge is no longer offered on
it.
- **Background reports no longer raise a toast.** Both calls pass
`suppressErrorToast`, so a failed report is silent as intended;
previously a core build showed the user a "Not Found" toast on every
tool failure.
## What is stored
File ids only, never names. The request type has no filename field, and
a `fileNames` value handed to the client reporter is accepted and
ignored.
One caveat to review deliberately: the free-text `detail` is stored
**verbatim**. `RecordFailure` truncates it at 2000 characters and
nothing else; the redaction that used to strip name-shaped text was
reverted in `024899f3f6` because it made an unclassified failure
impossible to act on. A backend message that embeds a filename
(LibreOffice conversion errors, IO errors) will therefore persist that
text and show it to a team leader.
## How to test
Needs a proprietary or SaaS build with login enabled. `task dev:all`
gives you one.
1. **Report a failure from a tool.** Open a PDF, run **Remove Password**
on it with a wrong password. Nothing visible changes for you: reporting
is silent by design.
2. **See it recorded.** Go to `/processor/documents` and scroll to
**Failures** (dev builds only). A row appears titled "Password-protected
document", with `Hit by <your user>`. Press **Show raw JSON** to see
exactly what was stored.
3. **Confirm no filename is stored as data.** In that JSON, `fileId` is
an opaque uuid and there is no name field. Note the `detail` string may
contain a filename if the backend put one in its message, per the caveat
above.
4. **Confirm the request is capped.** In DevTools, POST to
`/api/v1/file-run-events/reports` with 201 entries in `fileIds`. It
returns 400 naming the limit, and no rows are added.
5. **Deleting a file clears its failure.** Back in the editor, delete
the file you just failed on. Refresh the failures list: its row is gone
from the default view. Filter by `FILE_REMOVED` to see it still exists.
6. **Two people, two incidents.** Have a colleague fail the same tool on
their own copy of the same file. Two rows, not one occurrence count.
## Migration
`source_id` is a new column and `FILE_REMOVED` a new status value. Both
are already in the SaaS migration ([Stirling-PDF-SaaS
#322](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/322));
self-hosted picks them up from `ddl-auto`.
# Description of Changes
This fixes the a11y violations that are currently failing in the
nightlies in dark mode. Now that we're down to 0 baseline, we can
require the a11y tests to pass in PRs before they merge, so I've changed
that, and I've also made it so that the nightly will report failures in
both light and dark mode instead of just light mode if that fails.
<img width="2560" height="838" alt="image"
src="https://github.com/user-attachments/assets/b0322182-f6ff-4dea-9a1c-5a6c9c9c5439"
/>
Five unrelated snags in the processor (portal) UI, plus fixes they
turned up. No backend changes.
`84 files changed, +892 / −3364`
## Fat CTA buttons
- New `fat` prop on the SUI `Button`: 2.75rem tall, 1.25rem side
padding, 0.75rem corners, semibold. Composes with all four
variants/accents.
- Applied to the page-header CTA on Sources, Documents, Pipelines, Users
(both), Usage, Integrations, Infrastructure — 8 buttons, all in line
with a page title. Nothing else.
- `LandingActions` migrated onto the prop; `.landing-btn-primary` /
`.landing-btn-secondary` and their four `!important`s deleted. The
editor landing CTAs come down 4px with everything else.
- Infrastructure's header CTA is now primary; its "Create key" dropped
to secondary so they stop competing.
<!--IMG:buttons-->
## Documents empty state
- "Connect a source" opened the Sources *page*; it now opens the
`SourceModal` connect flow in place, no route change.
- No extra cache wiring: `SourceModal` already invalidates the sources
query.
<!--IMG:documents-->
## Infrastructure tabs
- Only API Keys and Audit Logs hit real endpoints. Deployments,
Security, Models and Storage read mock-only `/v1/infrastructure/*` that
no backend serves.
- Those four are now disabled: native `disabled`, out of the keyboard
tab order, `aria-disabled`, with the view refusing non-enabled keys as a
second guard.
- Real tabs moved leftmost; API Keys is the default; `?tab=` deep links
validated against the enabled set (the home flow's audit link still
works).
- Deleted: 4 tab components, their fetch fns and ~25 dead types, MSW
handlers, fixtures (908 → 253 lines), dead CSS, unused formatters, 240
lines of `en-US` strings. Most of the −3364.
- Page subtitle no longer advertises the disabled tabs.
<!--IMG:infrastructure-->
## Surface consolidation
- New `Surface` primitive (`sui-surface`): fill, hairline, radius, no
shadow. Kept separate from `sui-nav-surface` so nav chrome can diverge
later.
- `Card` composes it and no longer draws its own shadow — this changes
editor Card usages too, by design.
- SUI primitives that are surfaces adopt it: `MetricCard`,
`MetricStrip`, `NodeCard`, `Table`, `Collapsible`, `CodeBlock`.
- The portal gets its own `.portal-surface` with the same three
declarations, applied to 19 elements. A `sui-` class belongs to the
component that emits it, so feature markup doesn't wear one.
- `raised` variant = one subtle shadow for a surface in front of another
surface (the flow diagram's tiles). Same fill as its parent, so nesting
never shifts a region's colour. Dark has its own value.
- Floating chrome (modals, drawers, dropdowns, assistant, sidebar) keeps
its elevation; sunken wells stay sunken.
<!--IMG:surfaces-->
## Sources list
- Centred "No sources connected yet" empty state removed — it duplicated
the header CTA and pushed the table down the page. The header's "Connect
source" is the single way in.
## Drive-by fixes
- The connect flow rendered unstyled outside the Sources view:
`.portal-conn-picker__*` / `.portal-sources__connection-*` lived in
`views/Sources.css`, which none of the five components rendering them
imported. Moved to `components/sources/connections.css`.
- Three inert custom properties (`--surface-input`, `--color-border-2`,
`--text-default`) are defined nowhere in the codebase —
`.portal-conn-picker__card` had no fill at all as a result.
- Dead CSS removed from `Sources.css` (grep-verified unused): old
expanded-row panel + its keyframes, type-card block.
## Testing
- `task frontend:check` — typecheck, lint (oxlint + 4 theme-lint passes
+ stylelint), format, 238 files / 2063 tests.
- `frontend:typecheck:all` across all 9 tsconfigs.
- `frontend:storybook:a11y:changed` — 119 stories, light and dark, zero
violations, no regressions vs baseline.
- New tests: `Infrastructure.test.tsx` (tab order, default, disabled
behaviour, deep-link filtering) and a Documents test that the
empty-state CTA opens the modal without navigating.
- Merged `origin/main` (#7438 replaced `PipelineHeader` with the new
Create/Edit headers); full suite green at 240 files / 2072 tests after
the merge.
# Description of Changes
## Harden GitHub Actions secret handling
Moves secrets behind deployment environments, removes the GitHub App
token from
workflows that only comment and label, and moves PR preview images to
GHCR so the
preview path needs no registry credential.
Builds on #6005 by @dagecko — that commit is preserved with original
authorship,
rebased onto current main.
### Extract secrets from `run:` blocks (@dagecko, #6005 rebased)
- Secrets referenced in shell bodies moved to step-level `env:` so
values never
reach a rendered command line
- Two `workflow_dispatch` inputs moved out of shell interpolation
(`multiOSReleases`, `push-docker-base`)
- Dropped the hunks main has since solved — `setup-uv`, `reviewdog`,
`build-push-action` and `github-script` are all pinned newer on main now
- Fixed a bug in the original: `PR-Demo-cleanup.yml` uses a **quoted**
`<< 'ENDSSH'`
heredoc, so rewriting `${{ secrets.DOCKER_HUB_USERNAME }}` to
`${DOCKER_HUB_USERNAME}`
would have sent the literal string to the VPS and expanded to empty,
silently
orphaning preview images behind `|| true`
### Gate secret-bearing jobs behind environments
- `environment:` added to 15 jobs across 10 workflows, mapping to
`release-signing`,
`docker-publish`, `package-publish`, `pr-preview` and `bot-identity`
- Environment branch/tag policies are enforced by GitHub before the job
starts, so
editing the workflow file cannot bypass them
- Four jobs deliberately **not** gated — `tauri-build`,
`frontend-backend-licenses-update`,
`swagger` and `push-docker-base` would fail their own triggers under the
current
policies and need restructuring first
- Removed the `testMain` trigger from `push-docker` — the branch doesn't
exist and
isn't in the environment's policy
### Publish PR previews to GHCR instead of Docker Hub
- Preview images now go to `ghcr.io/stirling-tools/stirling-pdf-test`,
authenticated
with `GITHUB_TOKEN` rather than `DOCKER_HUB_API`
- Docker Hub personal access tokens cannot be scoped to a single
repository, so the
preview path was holding the same credential that publishes `s-pdf` and
`stirling-pdf`
- `DOCKER_HUB_API` no longer appears in any PR-reachable workflow
- Login now precedes every `docker manifest inspect` —
`deploy-on-v2-commit` had them
reversed, which only worked because the Docker Hub repo was public
### Use `GITHUB_TOKEN` for comment and label workflows
- Seven workflows no longer mint a GitHub App token; only
`sync_files_v2`,
`sync-portal-docs` and `frontend-backend-licenses-update` still do, so
unattended
auto-merge is unaffected
- `permissions:` blocks derived per job from the API calls each actually
makes —
these were previously inert, since an App installation token ignores
them, and one
job had no block at all
- Comment-threading matchers updated to `github-actions[bot]` so
workflows still edit
their own previous comment instead of posting duplicates
- Removed the App token from the `refs/pull/N/merge` checkout in
`PR-Demo-Comment-with-react` and set `persist-credentials: false` — it
was written
into `.git/config` of an untrusted tree that the same job then builds
- Fixed a script injection in `check_toml.yml`: a fork-controlled branch
name was
interpolated into `actions/github-script` JS source, with validation
running after
the injected code had already executed. Values now come from
`process.env` and are
validated before use.
---
## 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: dagecko <cnyhuis@vigilantnow.com>
# Description of Changes
This PR refactors our JPA entity classes to replace Lombok's `@Data` and
auto-generated `@EqualsAndHashCode` annotations with explicit Lombok
annotations and custom, JPA-compliant `equals()` and `hashCode()`
implementations.
### Rationale
Lombok's default `@Data` and `@EqualsAndHashCode` annotations are not
recommended for JPA entities. They often lead to:
- Severe performance issues (e.g., loading lazy collections when
evaluating `hashCode` or `toString`).
- Identity mismatches or collection bugs (e.g., when database-generated
IDs transition from `null` to assigned, breaking the entity's lookup in
a `Set` or `Map`).
This change ensures all JPA entities use safe Hibernate proxy checking
and use only the entity's database identifier for equality and hash code
calculations.
<!--
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: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
# Description of Changes
Follow-up to #7314, which fixed the IndexedDB blob rejection itself.
This one fixes the remaining WebKit engine gaps, fixes the ways that
class of failure surfaced to the user, and adds the cross-browser signal
that would have caught them on the PR instead of six weeks later.
## Why this exists
Two total WebKit outages sat on `main` for weeks:
1. pdf.js reads its text stream with `for await (… of readableStream)`,
and WebKit has no `ReadableStream[Symbol.asyncIterator]`. **All** pdf.js
text extraction threw `TypeError: undefined is not a function` —
Compare, read-aloud and the PDF text editor were dead on Safari.
2. IndexedDB in WebKit rejects Blob/File values with `UnknownError:
Error preparing Blob/File data to be stored in object store`, so nothing
persisted and every reload came back empty.
Neither was caught, because the existing specs never did the work. The
Compare specs filled both slots and asserted the button was enabled;
none of them clicked it. The persistence specs asserted a *filename*
reappeared after a reload, which only needs the metadata record, not the
bytes.
Every failure here **looked like success** — empty panes, blank
thumbnails, a `src` that was set but empty. That shapes the tests more
than the fixes.
## WebKit engine gaps
- **`ReadableStream[Symbol.asyncIterator]`**, installed at the entry
point before any PDF work starts. The lock discipline is the subtle
part: releasing is idempotent, is *not* done after a successful read,
and *is* done in the read's error steps — `for await` never calls
`return()` when `next()` rejects, so nothing else would ever unlock an
errored stream.
- **`requestIdleCallback`**, installed once instead of guarded at each
call site. This one wasn't broken, it was mistimed: the local fallbacks
fired at 200ms and 1000ms, landing the pdfium WASM compile on top of the
app's first renders. The shim honours the caller's full timeout, so
`{timeout: 2000}` means 2000ms.
- **`convertToBlob()` does not fail on a format it can't encode.** Per
spec it silently serialises to PNG, so asking for WebP and getting PNG
back looks like success. Canvas output now probes what the engine really
produced (once per realm) and uses the best lossy format it honours. PNG
of a rendered page is several times the size of the equivalent WebP or
JPEG, held as object URLs for every page on screen, on the engine with
the tightest renderer memory budget.
## WebKit storage failures
These read as generic transaction hygiene. They aren't — a refused blob
write **aborts its transaction**, which is the mechanism that turned a
WebKit rejection into a hang.
- **Blob refusal is remembered from any write**, not just the initial
`add`. WebKit reports it when it can't write the blob's *backing file*,
which is per-operation — an engine that accepted the add can still
refuse the rewrite, and every read-modify-write rewrites the record with
its body attached.
- **Aborted transactions no longer hang.** Read-modify-write moves to a
single `updateRecord` helper that owns its transaction, guards it once,
and resolves on **commit** rather than on the put's `onsuccess`. The
previous shape — two promises over one shared transaction, with an
`await` between the get and the put — put the abort guard on the read,
leaving the write with no handler at all. `persistVersionedOutputs`
awaits that, and `.catch` can't rescue a promise that never settles, so
tool outputs could silently stop persisting.
- **Stored blobs are no longer re-wrapped on read.** Since #7175 the
record holds the `File` itself; wrapping it in `new Blob([record.data])`
can cost WebKit the backing handle, giving you an object that looks
valid and reads as empty.
- **The file sidebar reaches a resting state** when the library can't be
read, instead of spinning forever on a rejection nobody observes. It
carries on with the in-memory workbench files: an unreadable library
should cost the user their history, not the file they're working on.
- **Thumbnail failures are logged.** Three `catch {}` blocks returned
`""`, and an empty thumbnail is indistinguishable from "this file has no
preview" — which is how outage #1 hid as a cosmetic nicety.
## CI
`main` now runs the whole stubbed suite once per engine (#7304), so the
new `@engine-capability` specs get chromium, firefox and webkit for
free. They assert the primitives actually work — a **counted**
comparison, a raster thumbnail data URL with real payload, and a page
rendered from a file restored by a reload — rather than that the UI
rendered. Deliberately small: anything added there is paid for three
times per PR, so add depth, not breadth. Run them alone with `task
e2e:cross-browser -- --grep @engine-capability`.
The cross-browser projects now share the stubbed project's viewport. At
the device presets' default 1280x720 a layout difference would fail
these specs on Firefox/WebKit only, which reads as an engine outage.
`vite.config.ts` gains a `worker.plugins` entry so `@app/*` resolves
inside worker bundles. Worker bundles are a separate Rollup pass and
don't inherit `plugins`, so the alias worked in the app and failed in a
worker — previously worked around with a relative import plus a lint
exemption, which silently bypasses the layer cascade.
## Verification
- `task frontend:check` green: typecheck, oxlint, theme lint, stylelint,
prettier, 215 test files / 1841 tests.
- The `@engine-capability` suite passes on Chromium and WebKit locally.
- **Negative control:** with the `ReadableStream` shim removed, the
WebKit comparison spec fails at the Deletions/Additions assertion — the
exact reported Safari symptom. Restored, and it passes. Both the fix and
the test that guards it are load-bearing.
- The worker alias change verified both ways: the build inlines the
encoding probe into the worker chunk, and removing `worker.plugins`
fails with `Rollup failed to resolve import
"@app/utils/canvasImageEncoding"`.
- The abort regression test aborts the transaction mid-write and asserts
`markFileAsProcessed` settles. Before the fix it never settles and the
test times out.
## Split out of this PR
Two things in earlier revisions of this branch were engine-agnostic —
found via the same symptom, not the same cause — and now have their own
PRs:
- **#7416** — blocked IndexedDB upgrades hanging the file library
(multi-tab lifecycle, the concurrent-open race, `onversionchange`).
- **#7417** — the thumbnail TTL rewriting the whole library on every
listing.
`FileSidebar`'s try/catch appears in both this PR and #7416,
identically: a WebKit rejection and a blocked-open rejection both have
to stop stranding the spinner. Whichever merges second is a no-op for
that file.
## Known gaps
- The blob-refused **rewrite** recovery in `updateRecord` isn't
unit-tested. `fake-indexeddb` never returns Blob values from a read, so
the branch that converts to a copy can't be reached there. Noted in the
test file.
- For the same reason, `fileFromRecord`'s "hand the stored File back
untouched" path is only covered on a real engine, by the reload spec.
- Nothing asserts that `src/index.tsx` imports the shims. The unit suite
installs the same module via `setupTests.ts` (jsdom has the same gaps
WebKit does), so a future regression where the entry point drops the
import would still be green under vitest.
- `FileSidebar`'s resting-state fix loses its E2E coverage until #7416
lands — forcing WebKit's blob refusal from a spec isn't practical, which
is why that spec blocks the database instead.
---
## 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)
- [x] My changes generate no new warnings
### Documentation
- [x] 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)
## Description of Changes
- Add `/app/saas` and `/buildSrc` to Dependabot's Gradle update
directories.
- Include `buildSrc/**` in every Gradle `actions/cache` key.
- Ensure changes to shared Gradle build logic invalidate the dependency
cache.
This keeps dependency updates and CI caching aligned with the
repository's current Gradle build structure.
---
## 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 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)
### Testing
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally.
One search bar for the whole app, in both the Editor and the Processor.
**Cmd/Ctrl+K** from anywhere.
- **Editor** — your files, tools, settings, and everything in the
Processor: its pages plus live users, policies, pipelines, sources, and
full-text developer docs.
- **Processor** — the same bar over its own data (users, policies,
pipelines, sources, docs) plus tools and settings. Files stay
editor-only; pages stay out (the sidebar covers them).
- **Filter chips** (and typed prefixes like `policy: invoice`) narrow to
one lane; each group shows a few results with **Show more** to expand.
- **Access-gated**: users only see lanes and settings sections they can
actually open — no Processor chips, results, or entity fetches without
portal access, no admin settings for non-admins, no account-bound
sections for anonymous SaaS sessions. Asserted by unit tests and a
stubbed Playwright suite.
- Selecting a result takes you straight there, across apps when needed —
results group under collapsible sections.
Replaces the separate search boxes that previously lived in the file
sidebar, the tool panel header, the settings modal, and the docs page.
### Editor
<img
src="https://gist.githubusercontent.com/reecebrowne/5879e797c5ab6abc027a7bfd5cd4de17/raw/editor-search.png?v=2"
width="800" alt="Editor super search" />
### Processor
<img
src="https://gist.githubusercontent.com/reecebrowne/5879e797c5ab6abc027a7bfd5cd4de17/raw/portal-search.png?v=2"
width="800" alt="Processor super search" />
---------
Co-authored-by: EthanHealy01 <ethan.healy.21@gmail.com>
# Description of Changes
Refactors automatic text redaction to use JPDFium-based redaction/text
removal instead of PDFBox
Changes:
* The `RedactController` now uses the JPDFium native redaction engine
(`PdfRedactor.redact`) as the primary method for PDF redaction, with
automatic fallback to the manual redaction service if JPDFium fails or
throws an exception. This improves reliability and leverages more robust
native features when available.
* Regex patterns provided by the user are now validated before redaction
begins, ensuring invalid patterns are rejected early with clear error
messages.
* The code now trims and filters out empty or excessively long redaction
terms, preventing unnecessary processing and potential errors.
<!--
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.
Bumps the gradle group with 1 update in the /docker/backend directory:
gradle.
Bumps the gradle group with 1 update in the /docker/embedded directory:
gradle.
Updates `gradle` from `934a520` to `e8aeffb`
Updates `gradle` from `934a520` to `e8aeffb`
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
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps the ubuntu group with 1 update in the /docker/base directory:
ubuntu.
Bumps the ubuntu group with 1 update in the /docker/unoserver directory:
ubuntu.
Updates `ubuntu` from `4fbb8e6` to `561618e`
Updates `ubuntu` from `4fbb8e6` to `561618e`
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
## Summary
- add sample/example file prompts to the bug report and feature request
issue forms
- remind reporters to remove sensitive information before attaching
files
Closes#4480
## Checks
- `git diff --check`
- parsed both updated issue template YAML files with PyYAML and verified
the `sample-files` field is present
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
…:runtime
The Windows branch of jlink:runtime clears the read-only attribute jlink
leaves on the bundled JRE, so Tauri can overwrite the staged copies. It
never worked.
Task does not hand the command to cmd.exe; it runs it through its own
POSIX shell, which expands `$_` and `$false` as shell variables. Neither
is set, so both became empty and PowerShell was asked to run
ForEach-Object { .IsReadOnly = }
which errors on every file. Verified against a directory of read-only
files: the double-quoted form leaves 3 of 3 still read-only and exits
non-zero, the single-quoted form clears all 3 and exits 0.
Single quotes stop the expansion. Also add -File: without it
Get-ChildItem yields directories too, and DirectoryInfo has no
IsReadOnly property, so those iterations would fail even once the
variables survive.
The POSIX branch above is unaffected - chmod needs no variables.
# Description of Changes
<!--
Please provide a summary of the changes, including:
- What was changed
- Why the change was made
- Any challenges encountered
Closes #(issue_number)
-->
---
## 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: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Description of Changes
Remove the hard-coded five-minute client-side timeout from Automate
operations.
- Remove `AUTOMATION_CONSTANTS.OPERATION_TIMEOUT`.
- Let normal Automate requests use the API client's default no-timeout
behavior, matching regular tool execution.
- Preserve an explicitly supplied `AutomationProcessingOptions.timeout`
without applying a finite default.
- Add regression coverage verifying that Automate does not send a
client-side timeout by default.
Large PDF operations such as compression can legitimately take more than
five minutes. Previously, Axios aborted the frontend request after
300,000 ms even when the server and reverse proxy allowed the operation
to continue. The backend could continue processing while the frontend
discarded the result.
No significant implementation challenges were encountered.
Closes#7081
## Testing
The following frontend checks passed:
- Proprietary frontend TypeScript typecheck
- ESLint with zero warnings
- Circular dependency check
- Theme colour lint
- Prettier formatting check
- Focused `automationExecutor.test.ts` regression test
- Complete Vitest suite: 165 test files and 1,354 tests passed
---
## 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)
(not 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/)
(not applicable; no user-facing configuration 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)
(not applicable)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
(not applicable)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(not applicable; no visual changes)
### 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>
# Description of Changes
This PR introduces a broad modernization of the codebase by adopting
newer Java language features and improving code readability and
maintainability.
## What was changed
- Replaced traditional `switch` statements with modern switch
expressions (`case ->`) across multiple classes.
- Replaced usages of `List#get(0)` and `List#get(size - 1)` with
`getFirst()` and `getLast()` respectively.
- Simplified conditional logic using pattern matching (e.g.,
`instanceof` and switch pattern matching).
- Refactored various utility and controller classes to reduce
boilerplate and improve clarity.
- Removed unused or redundant code (e.g., `parseClientFileIds` method in
`MergeController`).
- Improved type safety (e.g., using `Class::isInstance` instead of
`instanceof` checks in streams).
- Cleaned up Spring annotations by removing unnecessary `@Autowired`
where constructor injection is already used.
- Added a new test (`UIDataControllerTest`) to ensure correct handling
of identical JSON configs with different filenames.
- Minor formatting and style fixes (e.g., Spotless formatting
adjustment).
## Why the change was made
- To align the codebase with modern Java standards (Java 17+ features).
- To improve readability and maintainability by reducing verbosity.
- To eliminate common indexing patterns that are more error-prone.
- To standardize coding style across the project.
- To improve test coverage for edge cases discovered during refactoring.
---
## 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.
---------
Signed-off-by: Ludy87 <Ludy87@users.noreply.github.com>
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.12 to
3.4.13.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/cure53/DOMPurify/releases">dompurify's
releases</a>.</em></p>
<blockquote>
<h2>DOMPurify 3.4.13</h2>
<ul>
<li>Fixed an issue with hook removal during <code>IN_PLACE</code>
sanitization, thanks <a
href="https://github.com/koyokr"><code>@koyokr</code></a></li>
<li>Fixed an issue with hooks potentially bypassing the clone guard,
thanks <a
href="https://github.com/AkshayjainG"><code>@AkshayjainG</code></a></li>
<li>Fixed an issue with DOM clobbering via <code>ownerDocument</code>
during <code>IN_PLACE</code>, thanks <a
href="https://github.com/AkshayjainG"><code>@AkshayjainG</code></a></li>
<li>Bumped several dependencies where possible</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/cure53/DOMPurify/commit/3067f774676975de12306effd6db6ad7a9a8c17f"><code>3067f77</code></a>
release: 3.4.13 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1562">#1562</a>)</li>
<li>See full diff in <a
href="https://github.com/cure53/DOMPurify/compare/3.4.12...3.4.13">compare
view</a></li>
</ul>
</details>
<br />
[](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>
### Motivation
- `./gradlew clean` on the default/proprietary flavor did not remove
`app/saas/build` left behind by earlier SaaS builds, causing stale
artifacts to persist across flavors.
### Description
- Add a `clean` hook in `build.gradle` that deletes `app/saas/build`
(`tasks.named('clean') { delete
layout.projectDirectory.dir('app/saas/build') }`) so the root `clean`
always removes SaaS artifacts even when `:saas` is not included.
### Testing
- Created `app/saas/build/clean-regression-marker`, ran `./gradlew
clean`, and verified the `app/saas/build` directory was deleted
(success).
- Ran `./gradlew spotlessCheck test`; Spotless checks passed but the
full test run reported environment-dependent unit test failures
unrelated to this change (8 failures), and `task backend:check` could
not be executed because the `task` CLI is not available in the
environment.
------
[Codex
Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a7b0a9aac888325aa78e05c953355dc)
Auto-generated by stirlingbot[bot]
This PR updates the backend license report based on dependency changes.
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
### Motivation
- Group Storybook packages so Dependabot updates `storybook` and all
`@storybook/*` packages together and reduce churn from many separate
updates.
### Description
- Add a `storybook` group to the `npm` groups in
`.github/dependabot.yml` with patterns `storybook` and `@storybook/*`.
- No other configuration changes were made.
### Testing
- Validated the new group patterns with `ruby -e 'require "yaml"; ...'`,
which confirmed the `storybook` group and patterns and returned success.
- Ran `git diff --check` to ensure there are no whitespace or patch
errors, which succeeded.
- Inspected the updated file with `nl -ba .github/dependabot.yml | sed
-n '100,120p'` to confirm the inserted lines are present.
------
[Codex
Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a7afe74fe7083259900e8d9018f595c)
## Description
Moves the repository-wide VS Code settings out of PR #7386 into a
dedicated pull request.
## Changes
- Configure Ruff for Python files and point it at the engine project
configuration.
- Add repository-root paths for stylelint and CSS/SCSS/Less at-rule
handling.
- Enable Oxc safe fixes on save for JavaScript and TypeScript files.
- Add Even Better TOML formatter settings for the repository's TOML
files.
This keeps PR #7386 focused on the Python tooling migration while
preserving the editor configuration as a separately reviewable change.
## Validation
- Exact file diff matches the settings change from PR #7386.
- `task pre-commit:whitespace` was attempted; it is currently blocked by
pre-existing whitespace issues in
`engine/src/stirling/models/tool_io.py` and
`engine/src/stirling/models/tool_models.py`.
# Description of Changes
- `/` is now a router, not a page: signed-in users go to the processor
or the editor by role. The editor lives at `/editor`.
- `/editor` never routes — always the editor, so processor users have a
URL that won't bounce them.
- Core and desktop keep the editor at `/` (no processor, nothing to
route between).
- `/editor` signed out → `/login` → back to `/editor` after signing in.
- Signed-out visitors aren't redirected: `/` renders the app and Landing
owns it (login page / SaaS inline sign-in / backend-down screen).
- `RootGate` wraps the app instead of being its own route, so nothing
boots on the way to the processor and nothing remounts on the way to the
editor.
- Login resolves its own destination instead of bouncing through `/`.
- Replaces the old once-per-login `LoginLandingRedirect` +
sessionStorage flag. Landing flag and Settings preference unchanged.
- Separate commit: theme-lint crashed on files deleted in the working
tree (`git ls-files` is the index view). Any branch deleting a source
file hit it.
- Sign-out untouched. Tool routes stay top-level, so no deep links or
SEO break.
Future PR to allow users to configure their own routing from / for their
profile
---
## 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.
## What
Empties the light Storybook accessibility baseline — **1,058
grandfathered violations across 846 stories → 0** — so a new violation
fails the gate instead of being silently absorbed. Also burns the dark
baseline **812 → 56**; every entry left is one `main` already
grandfathers.
## The defect, repeated everywhere
A colour picked as a **fill**, chosen to carry a white label at 3:1,
reused as **text**, where the floor is 4.5:1. It recurred through status
accents, filled buttons, form labels, Mantine's light and outline
variants, CSS declarations, inline styles and the generated accent ramp.
Three systemic causes account for most of it:
- **Mantine's semantic slots were never bound.** `-text`, `-outline`,
`-light-color`, `-filled` and `-dimmed` all default to the hue's solid
fill. Both resolvers now pin them to the accessible ink for the active
scheme.
- **The tint ladder was compressed.** `--color-<hue>-50/100/200` pointed
at saturated 400-level primitives, so every "tint" background rendered
as a fill.
- **Text was faded with `opacity`**, pushing already-muted copy below
the floor. Each site now recedes via ink or surface, which is what
conveyed the state anyway.
## Dark mode
The colour resolver's dark half was empty, so dark fell through to
Mantine's stock palette — and fixing the naming violations unmasked the
contrast sitting underneath them. Both schemes now share one slot map,
since most slots are written in tokens that already flip.
The dark-only fixes: `--c-text-subtle` (3.0:1, used in 478 places), the
error and section-label inks, and the accent ramp's text step — which
light reaches by mixing toward black and dark has to reach by mixing
toward white.
## Also
- New `--c-*-solid` tokens for fills that must carry a white label,
distinct from the `--c-<tone>` values used for surfaces, borders and
icons.
- A `data-user-content-preview` opt-out for nodes rendering a facsimile
of the user's own document — WCAG governs the interface, not content
authored through it.
## Verification
- `task frontend:check:all` — green.
- Changed-set gate, both schemes, after the final rebase: **366 stories,
0 regressions**.
- Full sweep at the prior base — light **1,447 stories / 0 violations**,
dark **1,448 / 0 regressions**. The dark re-record was confirmed
key-by-key to be a strict subset of `main`'s, so nothing new is
grandfathered.
Roughly 28% of what this clears is naming and structure (`button-name`,
`label`, `aria-*`) and has no visual signature; the rest is contrast.
# Description of Changes
Scan a QR code in the Sign tool, draw your signature on your phone, and
it appears on your desktop ready to place. Rides the mobile scanner's
existing transfer sessions — no new backend endpoints.
**Desktop:** a **Mobile upload** button above the signature source
selector shows a QR code. When the signature arrives, the modal closes,
it lands in the matching source, and **placement activates
automatically** — click the PDF to place.
**Phone:** a new public `/mobile-sign` page with three tabs (same order
as the desktop sources):
- **Draw** → canvas signature. Touch-first pad (pointer events,
DPR-aware, smoothed strokes, undo/clear, black/blue ink, 3 pen sizes),
exported as a transparent PNG cropped to the ink. Compact layout in
phone landscape.
- **Photo** → image signature. "Take a photo" opens the camera directly;
"From gallery" opens the picker. A preview of the current image
signature now shows in the desktop's Image source (previously arrival
was invisible until placement — also fixes this for saved image
signatures).
- **Type** → text signature. Travels as data (text + font + colour), so
it stays *editable* on the desktop. Fonts are the sign tool's own
text-mode list.
**Security:** the transfer endpoints are unauthenticated by design
(10-min sessions, files deleted after download — same model as the
scanner). The desktop treats every arrival as untrusted: images only,
and the text payload is clamped field by field.
**Config:** new `system.enableMobileSignature` flag (default on),
independent of `enableMobileScanner`; the shared endpoints accept
either. The Tauri desktop app serves a self-contained `mobile-sign.html`
(draw-only), mirroring `mobile-upload.html`.
**Refactor:** the session lifecycle (create/poll/download/expiry) moved
out of `MobileUploadModal` into a shared `useMobileTransferSession`
hook; the scanner modal now uses it, behaviour unchanged.
Also fixes two bugs hit along the way: the signature pad collapsing to
its 150px intrinsic height (indefinite parent height), and a setState
loop in `SignSettings` when text parameters are set programmatically
(draft-sync effects ping-ponging).
## Screenshots
| Desktop: QR entry | Phone: draw | Desktop: received |
|---|---|---|
| 
| 
| 
|
## How to test
1. Open the app on an address your phone can reach (not `localhost`),
Sign tool → **Mobile upload**, scan the QR.
2. Draw → **Send to computer** → it becomes the active canvas signature
and placement is live: click the PDF to place.
3. Photo tab → arrives in the Image source with a preview. Type tab →
arrives editable in the Text source.
4. Flags: `enableMobileSignature: false` hides the button; signature
still works with the scanner disabled.
Verified end-to-end (all three kinds, portrait/landscape/tablet) plus
`task frontend:check` and the touched backend tests.
---
## 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)
- [x] 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.
Removes
`app/saas/src/main/resources/db/migration/saas/V33__api_keys.sql`, the
only file left in that tree.
## Flyway does not run
There is no Flyway dependency in any `build.gradle` and no Flyway
configuration in any properties file. Nothing has executed these for
some time, so `V33` was never applied by anything.
SaaS schema has exactly two writers today:
1. Supabase CLI migrations in the `Stirling-PDF-SaaS` repo, applied by
that repo's PR CI
2. Hibernate `ddl-auto=update` in the app, which only ever adds
## Why delete rather than leave it
A directory of plausible-looking migrations is a trap. The next person
to make a schema change adds a `V34`, assumes it will run, and it
silently does not. I nearly did exactly that while working on
[#7414](https://github.com/Stirling-Tools/Stirling-PDF/pull/7414) before
checking whether Flyway was actually wired.
## Nothing is lost
Both tables it declared, `api_keys` and `api_key_daily_usage`, have JPA
entities (`ApiKey`, `ApiKeyDailyUsage`), so `ddl-auto` creates them.
That is already how they exist on every deployment that has them, since
Flyway was not the thing creating them.
## Checks
No references anywhere: nothing in code, config, gradle or docs mentions
`db/migration`, `flyway` or `V33`. Two code comments mention Flyway
historically, explaining why a column looks the way it does; those are
accurate history and are left alone.
`:saas:compileJava` and `:saas:processResources` green after the
removal.
# Description of Changes
Frontend CI is currently set to run `task frontend:check:all`, which
runs the tests, and then manually runs them a second time with coverage
enabled. This adds about 4 minutes onto the runtime of the frontend
tests for no reason. This PR changes the `frontend:test` rule to respond
to the `COVERAGE` and `CI` env vars to enable coverage analysis of the
frontend tests if the setting tells them to.
# Description of Changes
**PR2 of the encrypt-at-rest initiative — PR1 was #7155** Makes the P1
crypto operable and compliance-credible: admins can see the feature's
state, flip the kill switch over an API instead of raw SQL, encrypt the
pre-existing plaintext backlog, rotate the master key, and every
security-relevant event lands in the audit trail. No frontend — that's
PR3.
**What was changed**
- **Audit events** — new `STORAGE_ENCRYPTION` audit type, emitted
through a small listener interface so the crypto classes stay plain
objects: `encrypt`, `decrypt` (per-read events honour
`storage.encryption.auditReads`, default **on** — HIPAA reviewers expect
read audit; busy installs can disable), `decrypt.denied` (always),
`key.created/disabled/enabled`, `master.rotated`, `migration.completed`,
plus a `plaintextExport` marker whenever a plaintext copy of
encrypted-at-rest content is served (with `inline` flag to distinguish
in-app view from saved download).
- **Admin API** `/api/v1/admin/storage-encryption` (`hasRole('ADMIN')`):
- `GET /status` — write/decrypt state, **master-key fingerprint**
(SHA-256 prefix for backup verification, never key material), encrypted
vs plaintext file counts, full key list with status history.
- `POST /keys/{id}/disable` / `enable` — the kill switch, now with
active cache invalidation so revocation is immediate on the handling
node (cross-node converges within the 60s cache TTL). Enable is
restricted to DISABLED keys so two ACTIVE keys can't exist per scope.
- `POST /migrate` + `GET /migrate/status` — encrypt-existing job.
- `POST /master/rotate` — key material is never accepted over HTTP; keys
come from config/env.
- **Deliberately no delete endpoint** — key material can be disabled but
never destroyed through the API.
- **Encrypt-existing migration job** — new writes are encrypted from the
moment the flag is on; this converts the backlog. Crash-safe per file:
store the encrypted copy under a NEW storage key → compare-and-swap the
DB row → only then delete the old blob. A CAS miss (user replaced the
file mid-run) discards the job's copy — the user's file always wins.
Worst crash outcome is an orphaned blob, never a lost file; re-runs are
idempotent (`encryption_key_id IS NULL` selection, cursor-paged so
failures can't wedge the loop). Handles all three blobs per row
(main/history/audit-log), runs on a throttled virtual thread,
single-flight guarded.
- **Master-key rotation** — cheap by design thanks to the P1 hierarchy:
rotate re-wraps the handful of KEK rows, zero file I/O. New config
`stirling.security.fileEncryptionKeyPrevious` (+env) gives `unwrap` a
fallback during rotation, and
`stirling.security.fileEncryptionKeyVersion` marks which master wrapped
each row. Runbook: set new key primary + old as previous + bump version
→ restart (startup self-check passes via fallback, warns about pending
rows) → `POST /master/rotate` → remove the previous key.
- **Shared state bean** — `StorageEncryptionState` is built once and
shared by the storage decorator and the admin API, so kill-switch cache
invalidation hits the same caches the decorator reads.
**Reviewer notes**
- The revoked→403 mapping promised for PR2 already landed in #7155 after
manual testing; this PR adds the matching `decrypt.denied` audit event.
- 19 new tests: audit emission (encrypt/decrypt/denied, legacy plaintext
emits nothing), kill-switch immediacy (no TTL wait), rotation
(previous-key fallback, re-wrap + cleanup, idempotent second call),
migration (backlog encrypted byte-identical, CAS-miss discards own copy,
per-file failure counting, concurrent-start rejection, write-disabled
rejection), admin controller status/conflict/not-found paths.
- Full proprietary suite: 2246/2247 green (the one failure is the
pre-existing Windows-symlink FolderIdentitiesTest, unrelated).
---
[ENCRYPTION_AT_REST_TEST_REPORT.html](https://github.com/user-attachments/files/30664158/ENCRYPTION_AT_REST_TEST_REPORT.html)
---------
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
# Description of Changes
This PR modernizes the project's Python tooling across GitHub Actions by
migrating CI workflows from pip-based dependency management to `uv` and
aligning Python execution with the engine project's managed environment.
### What was changed
- Replaced `actions/setup-python` and ad-hoc `pip install` steps with
`astral-sh/setup-uv` across CI workflows.
- Configured shared `uv` dependency caching using
`engine/pyproject.toml` and `engine/uv.lock`.
- Updated Python script execution to use `uv run --project engine
--locked` for a consistent runtime environment.
- Replaced package installation steps with `uv sync` for the required
dependency groups (e.g. `tools` and `cucumber`).
- Added Docker image build validation for both production and
development AI engine images.
- Updated workflow cache configuration and Docker build context where
required.
- Removed obsolete Python requirements files that are no longer needed
after the migration.
- Applied minor Python code modernizations, including import cleanup,
modern built-in generic type annotations (`list[...]`, `tuple[...]`,
`float | None`), and small style improvements.
- Removed unnecessary Python formatter/linter extensions from the
development container configuration.
### Why the change was made
- Standardize Python dependency management across the repository.
- Reduce duplicated dependency installation logic in CI.
- Improve workflow performance through shared dependency caching.
- Ensure all Python utilities execute against the same locked dependency
set managed by the engine project.
- Simplify long-term maintenance by eliminating legacy requirements
files and pip-specific workflow steps.
---
## 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.
---------
Signed-off-by: Carsten Drewes <c.drewes@stud.uni-hannover.de>
Co-authored-by: albanobattistella <34811668+albanobattistella@users.noreply.github.com>
Co-authored-by: kastenherri <116314318+kastenherri@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Auto-generated by stirlingbot[bot].
Regenerates `frontend/editor/src/portal/generated/docsManifest.json`
from the Stirling docs repo via `npm run docs:sync`.
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
# Description of Changes
PR 1 of the failure-notification work: a durable, team-scoped record of
**why a policy run failed**, surfaced in the portal with the triage
actions each failure allows.
Today a failed policy run is not quite invisible, but it is unusable:
the ledger marks the file `ERROR`, and the audit aspect keeps the
exception message and status code. Nothing classifies either one,
nothing surfaces them, and neither offers a next step. If the file came
from a folder, bucket or webhook there is also no user watching, so
nobody learns it never made it through. This adds the record and the
read surface; the remediation that acts on documents comes later (see
below).
## What this does
**A failure kind registry as data.** `FailureKind` describes what can go
wrong: a stable wire id, i18n keys, an English fallback, and four facets
the review surface needs (`Stage`, `Severity`, `Remedy`, `Scope`). It is
shaped like the existing `ExceptionUtils.ErrorCode` and *links* to that
vocabulary rather than replacing it.
**Classification off structured codes, not message matching.** Policy
steps dispatch over loopback HTTP, so a tool's 4xx arrives as a
`RestClientResponseException` whose body is the Problem Details document
carrying `errorCode`. `FailureClassifier` reads that. Anything
unrecognised becomes `UNKNOWN`, which is the point: every failed run
gets an addressable record from day one, and which kinds to promote next
is answered by production frequency rather than guesswork.
**Actions declared by a kind, implemented as beans.** A kind lists the
`FailureActionId`s it offers; behaviour lives in `FailureAction` beans
resolved by id — the idiom this codebase already uses for `InputSource`,
`PolicyOutputSink` and `PolicyTrigger`. A kind cannot be sent an action
it never declared (400), so an incoherent pairing is unreachable rather
than merely unrendered. A new kind ships as a registry entry plus copy:
no new endpoint, no UI change.
**Repeat folding.** Recording folds a genuine repeat into the existing
incident instead of inserting again, keyed on `(team_id, dedup_key)`.
That matters for a snapshot-mode source that re-lists every file on each
sweep: the same broken file is one incident, not one per sweep. Distinct
files keep distinct rows. The unique constraint is enforced by the
database, and a writer that loses the insert race folds into the
winner's row.
One granularity caveat worth naming: nothing populates `file_id` in this
PR, so every row has it NULL. A FILE-scoped kind therefore dedups on
`policy + run` rather than `policy + file`. That still yields one row
per document for the sources shipped here, because the folder, S3 and
webhook sources each start one run per file; it stops holding as soon as
a single run carries several documents, which is why editor-origin
reporting (item 3 below) populates `file_id`.
**No document identity is stored.** No file name, no content. `fileId`
is an opaque reference only the owner's own client can resolve locally.
`detail` keeps the raw message (the only diagnostic an `UNKNOWN` failure
has) with anything path- or filename-shaped stripped on the way in,
capped at 2,000 characters. `PolicyExecutor`'s type-mismatch message now
reports the *extension* rather than the filename, since that message
becomes the stored `detail`.
**Access.** Reads and triage are leader-only, gated exactly the way
`PolicyController` gates policy editing, with the single-user carve-out
when login is disabled. Every read and write is scoped to the caller's
own team from the authenticated principal — there is no team parameter
on the API.
Self-hosted needs no migration: the table is created from the entity by
`ddl-auto=update`, as with every other table.
## What this does not do yet
- **Actions are incident dispositions, not document dispositions.**
Acknowledge and Dismiss change how a failure is displayed and touch
nothing else — not the document, not the processed-file ledger, not the
run, not any output destination. That is what makes them safe to offer
against `UNKNOWN`, and why there is no Approve/Release yet.
- **Two kinds only.** `INPUT_PASSWORD_PROTECTED` and `UNKNOWN`.
Everything else classifies as `UNKNOWN` and shows its raw message.
- **Editor-origin failures are not reported.** Every row is `PROCESSOR`.
`FailureOrigin.EDITOR` and `API` exist in the enum but nothing writes
them.
- **The list is dev-only for now.** The section renders behind
`import.meta.env.DEV`, so it ships in no production bundle. The
endpoints are live and gated.
- **No retention or per-team cap** on `file_run_events`. Tracked
separately.
- **No suspend-and-prompt.** `PolicyInputRequiredException` and the
engine's `suspend()` exist but nothing throws it, so a run cannot pause
to ask for a password today.
- **SaaS needs a migration** in `Stirling-PDF-SaaS` (`CREATE TABLE IF
NOT EXISTS stirling_pdf.file_run_events`), per the convention documented
at `app/saas/src/main/resources/application-saas.properties:21`.
## What follows in later PRs
1. **Map the remaining error codes to specific kinds** — corrupted file,
OCR unavailable, output destination unreachable, entitlement refusals,
and so on — each with its own copy and its own action set, replacing
today's `UNKNOWN` catch-all with a named notification in the review UI.
2. **Real remediation actions** attached to those kinds: fix (supply a
password and resume), skip (drop this file, continue the batch), and
decline (reject an incoming file outright), acting on the held document
rather than only on the incident row. This is where the
suspend-and-prompt path gets wired.
3. **Editor-origin reporting**, so a failure a user hits in the editor
lands in the same queue as one from a bucket.
4. **The user-facing review surface**: notifications with a sticky
review section, per-file badges, and an export gate, with the dev-only
list here replaced by the real thing.
## How to test
Needs a SaaS or proprietary build with login enabled, and an account
that leads a team.
1. Create a policy in the Processor with any step (Auto-redact is fine)
and a source you can drop files into.
2. Upload two files that will fail it: **a password-protected PDF**, and
**a corrupted PDF** (truncate a valid one, or rename a `.csv` to
`.pdf`).
3. Let the policy run and fail on both.
4. Go to the portal's **Documents** view and scroll to **Failures** (dev
builds only).
Expect two rows:
- **Password-protected document** — classified from `E004`, with the
kind's own labels **"I'll unlock this"** and **"Skip this file"** rather
than generic wording.
- **Unrecognised failure** — the corrupted file, classified `UNKNOWN`
(`E001` is not claimed by a kind yet), showing its raw message with
generic **Acknowledge** / **Dismiss**.
Neither row contains a file name anywhere, including in the raw message.
Press **Show raw JSON** to read exactly what the server returned. Acting
on a row transitions it and comes back with both buttons disabled and a
reason.
Re-running the same batch increments the occurrence count on the
existing rows rather than adding new ones; two *different*
password-protected files produce two separate rows.
---
## 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
# Description of Changes
> Stacked on #7264, sibling of #7283. Independent of #7283 — the only
overlap is two additive lines in `core/query/keys.ts` and
`core/api/config.ts`. Either can merge first.
## The problem
`useEndpointConfig` kept its own cache: a module-level `globalFetchDone`
boolean, a mutable `globalEndpointCache` object, and a
`resetGlobalCache()` called from the JWT listener. Which consumer
mounted first decided who paid for the request, and nothing invalidated
it except a page reload.
## End state
One shared query for the whole availability map; each of the 12
consumers projects the endpoints it asked for. **251 lines to 101**,
same return shape, no consumer changes.
| | Before | After |
|---|---|---|
| Cross-consumer cache | `globalFetchDone` + mutable module object |
query key |
| Invalidation | `resetGlobalCache()` mutating that object |
`invalidateQueries` |
| Per-endpoint check | own `useState` triple | query keyed by endpoint |
Behaviour kept deliberately:
- **Unknown endpoints and any failure still read as enabled.** This
fires before auth settles, and disabling every tool on a hiccup is worse
than letting one call fail later.
- **`retry` is off for the availability map.** The fallback *is* the
answer, so retrying only doubles a request every logged-out visitor
makes on load.
## Desktop is untouched
`desktop/hooks/useEndpointConfig.ts` shadows this module entirely — no
shared code, so core converting doesn't affect it and there's no
half-migrated state.
It's 482 lines of orchestration rather than fetching: dependency-ready
gating, `tauriBackendService` and `selfHostedServerMonitor`
subscriptions, a 2.5s timeout retry for backend startup, a legacy
`?endpoints=` fallback for old servers, and SaaS-routing optimism that
rewrites disabled endpoints to enabled. It also has no test coverage to
convert against, and it decides whether tools appear at all in the
desktop app.
That's a different job from this one and wants its own review. Next PR.
## Testing
9 new tests: projection onto the requested subset, one request across
consumers, unknown-endpoint fallback, failure fallback with no retry,
empty-list no-fetch, JWT invalidation, and the three single-endpoint
cases.
`task frontend:check` green: 1677 tests across 192 files, typecheck on
all five flavours, eslint, dpdm, prettier.
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
# Description of Changes
Fix automate unrunnable tools
## Problem
- Remove Image failed in Automate with `Tool operation not supported:
removeImage`
- Its registry entry had `operationConfig: undefined` even though the
config existed and was already tested
- The Automate picker only filtered on `supportsAutomate`, never on
`operationConfig` — so broken tools were selectable and failed only at
run time
## Fixes
- Wire up `removeImage` and `pageLayout` operation configs (both already
existed, just never registered)
- Exclude `validateSignature` (report tool, not on the operationConfig
seam) and `scannerEffect` (no frontend implementation) via
`supportsAutomate: false`
- Picker now also filters on `operationConfig`, so this class of bug
can't reach users again
- `overlay-pdfs` returns 400 instead of 500 when overlay files or mode
are missing
- Fix `new URL().pathname` Windows path bug that stopped 2 test suites
from loading
---
## 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.
## What
Makes Storybook render components with the same CSS the app gives them.
- The preview loaded the token primitives but **not the editor's
semantic token layer** (`styles/theme.css`), so components styled on
those variables rendered unthemed — three onboarding stories were
importing it by hand to stop their modal surfaces rendering transparent.
It's now loaded in the preview and the workarounds are gone.
- **Portal stories render inside the `.portal-scope` wrapper** PortalApp
mounts, so the portal's scoped reset and typography apply to them
exactly as in the app — and, deliberately, to nothing else.
- The folder stories invented their own hex colours, two of which aren't
values the app's `FOLDER_COLOR_PALETTE` can produce. They now use the
palette, so they can't drift from what a user can actually pick.
Deliberately does **not** load `tailwind.css` — tailwind is on its way
out of the editor, so matching the token layer alone is the target
state.
## Story colours route through the tokens, enforced
Stories were exempt from the `code-colors` lint, and it showed:
hardcoded hexes for surfaces the tokens already name (chat bubbles,
borders, demo backgrounds), `var(--x, #hex)` fallbacks that mask a
renamed token by silently painting the stale colour, and mocked category
accents for which real `--color-cat-*` tokens exist.
- Styling literals now use tokens; the dead fallbacks are stripped.
- The stories exemption is removed from `theme-lint`, so this can't
regress.
- Colours that are **the datum itself** — `ColorInput` values, signature
ink, per-policy accents, brand-mark swatches — stay literal via
`theme-allow-color`, hoisted to named consts so the exemption and its
reason sit together.
A practical side effect: stories styled on tokens actually respond to
the dark-mode toolbar toggle, which is what makes a dark-theme a11y pass
meaningful later.
## The a11y gate now runs dark as well as light
Contrast is most of what axe reports and it is theme-dependent, so a
light-only gate left half the surface unmeasured — and it only becomes
measurable at all once the tokens above actually flip. `SCAN_THEME=dark`
pins the theme for a whole scan run, every a11y task runs both themes,
and each theme has its own baseline:
- **light** re-recorded against the themed rendering (the old baseline
measured colours the app never shows): 831 stories with violations
- **dark** recorded for the first time: 798 stories with violations, 980
story-rule pairs, zero render failures across the full sweep
Verified end to end: dark scans measure against dark surfaces (`#18181b`
vs `#ffffff`), both baselines self-check clean, and a live scan of
stories that changed on main after recording passes both gates.
Nightly's timeout doubles for the second sweep.
## Testing
Typecheck (all variants), ESLint and Prettier pass. Onboarding, folder,
portal and control stories render in the browser scan (39/39) with the
per-story CSS imports removed; every story touched by the colour sweep
renders too (58/58). `task frontend:lint:colors` passes with stories
included.
# Description of Changes
Currently when calculating the output file type for some tools, the
system will get it wrong because it doesn't know about what the default
parameters in tools are, so if it doesn't have a value for some key,
it'll just bail out and say "it might not be compatible". This PR adds
logic to `ToolIO` to read the default values set for the parameters if
the tool has `ToolIOCase`s and takes them into account when figuring out
the output type. I've built it with horrible Java reflection magic to
avoid having to specify the default for params twice, which will make it
impossible for the defaults to disagree with each other. This just runs
once at startup so there's negligible performance impact.
The change is easily tested with Change Parameters, which is just
`add-password` behind the scenes but with the password params omitted
(so Change Password is always PDF->PDF, never encrypted like Add
Password).
Also (somewhat hackily) fixes a bug I noticed where saving a Change
Permissions step then leaving and returning to the pipeline will cause
the step to be reloaded as Add Password. I've added a system to
disambiguate tools which share the same endpoint (which is only these
two currently).
## Currently
<img width="455" height="135" alt="image"
src="https://github.com/user-attachments/assets/c8867d6a-599b-4f21-a2db-4a1b6ac22d73"
/>
## Now
<img width="415" height="122" alt="image"
src="https://github.com/user-attachments/assets/bd8d1bab-f00a-421b-8c91-5af0d2ad5335"
/>
# Description of Changes
Nightlies keep failing because the Playwright tests only run on Chrome
in PRs. This PR changes it so that we run all 3 browsers in all
(frontend) PRs so we catch these things before they merge in. They run
in parallel so it won't take any more time for the CI to finish.
# Description of Changes
Continued effort towards removing all uses of the any type in our
frontend code (last PR was #7326). This PR fixes 7 more folders and
removes them from the exclude list. All of them were localised within
the folder in the exclude list so again were pretty easy to fix.
# Description of Changes
This PR replaces multiple hardcoded English UI strings with translation
keys to improve localization consistency throughout the editor.
### What was changed
- Added new translation entries for:
- AI chat panel header and empty state
- Generic dropdown placeholders and empty states
- Generic input placeholder (`Enter value`)
- Tool renderer "tool not found" message
- Signature pen size placeholder
- Updated shared components to use translated fallback placeholders
instead of hardcoded English text:
- `DropdownListWithFooter`
- `EditableSecretField`
- `GroupedFormatDropdown`
- `LanguagePicker`
- `PenSizeSelector`
- Localized the AI chat panel:
- Assistant title
- Empty state message
- Input placeholder
- Localized the fallback error message displayed when a tool cannot be
resolved.
### Why the change was made
Several shared UI components and the AI assistant interface contained
hardcoded English strings, preventing proper localization and creating
an inconsistent multilingual experience. Moving these strings into the
translation system ensures they can be translated alongside the rest of
the application and provides reusable defaults for shared components.
---
## 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.
Auto-generated by stirlingbot[bot]
This PR updates the frontend license report based on changes to
package.json dependencies.
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Bumps the react group with 2 updates in the /frontend directory:
[@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react)
and
[@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom).
Updates `@types/react` from 19.2.17 to 19.2.18
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react">compare
view</a></li>
</ul>
</details>
<br />
Updates `@types/react-dom` from 19.2.3 to 19.2.4
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom">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
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
# Description of Changes
Smaller scope than #6689 to try and get this finished.
Replace ESLint and dpdm with Oxlint, a TS linter written in Rust so its
performance is dramatically better than the existing tools we use.
## Speed improvement
- Current ESLint run: 13.76s
- Current dpdm run: 3.59s
- Total time: 17.35s
- New Oxlint run: 0.90s
So Oxlint is about a 20x speed improvement.
## Differences
When I last tried to do this, we could recreate our rules identically
with Oxlint, but that's not true any more. Oxlint has no current
equivalent for ESLint's `no-restricted-syntax` rule, which we were using
to ban usages of `<button>` and stuff in specific components to try and
encourage them to use our shared UI. This is a very recent addition to
our linting config, and personally I'm willing to drop it for now at
least. We can still ban specific imports in files, so the files which we
were trying to enforce shared UI will still ban directly importing
Mantine, so that'll probably be most of the cases still caught, but I
think there are other ways we can encourage using the shared UI beyond
just using the linter for it.
I did try building a custom TS rule for it and it only slowed it down a
tiny bit (it took 1.1s) but it had to be built on an unreleased alpha
API which just sounds like a maintenance headache we don't need to deal
with for a rule that we don't really need.
# Description of Changes
Continued effort towards removing all uses of the `any` type in our
frontend code. This PR fixes 10 more folders and removes them from the
exclude list. All of them were really simple fixes.
# Description of Changes
Fixes the WebKit nightly failures ([run
31067620195](https://github.com/Stirling-Tools/Stirling-PDF/actions/runs/31067620195/attempts/1)):
8 tests failed on `stubbed-webkit` only, and every one of them logs the
same thing in its trace:
```
IndexedDB add error: UnknownError: Error preparing Blob/File data to be stored in object store
```
## What broke
`storeStirlingFile` stores the `File` itself in IndexedDB, so multi-GB
uploads are persisted by reference and never materialize in JS memory.
That came in with #7175 (`data: stirlingFile` replacing `data: await
stirlingFile.arrayBuffer()`), which is a real memory win and worth
keeping.
WebKit refuses blob values whenever it can't write the blob's backing
file, and rejects the request with the error above. The rejection was
only `console.error`d, so on WebKit **no upload ever persisted**, and
everything that reads the bytes back behaved as if the upload never
happened:
- `file-state-across-tools` — file gone after navigating; the sidebar
shows "No files yet"
- `compare` — `FileSelectorPicker: upload failed`, so the slot stays
`data-slot-state="empty"`
- `classification-grouping` / `classification-heuristic-upload` — the
label backfill and thumbnails read from IDB (`not in IndexedDB (likely
remote-only stub)`), so files land in "Recent" with no category headers
Chromium and Firefox store blobs fine, and PR CI only runs the `stubbed`
(chromium) project, so nightly was the only gate that could catch it.
## The fix
Try the blob first, keep a fallback:
- `storeStirlingFile`'s `add` is extracted into `addFileRecord` so it
can run twice
- if the value was a Blob and the failure is `UnknownError` /
`DataCloneError`, re-add the record with an `ArrayBuffer` copy and set
`blobValuesSupported = false`, so later files in that session go
straight to the copy path instead of losing the blob attempt every time
- deliberately narrow: `QuotaExceededError` and `ConstraintError` still
propagate, because a copy would fail the same way and retrying would
hide the real cause
- dropped two internal `console.error`s: every caller already reports
(`addFiles`, `FileSelectorPicker`, `zipFileService` collects into
`result.errors`), so they were duplicate noise
Every writer goes through `storeStirlingFile` (uploads, the file picker,
zip extraction, folder automation, `IndexedDBContext`), so this one seam
covers all of them. The read paths already accept either shape (`new
Blob([record.data], ...)`).
Net effect: Chromium and Firefox keep the no-copy path; engines that
refuse blobs degrade to the pre-#7175 behaviour instead of silently
losing files. On such an engine a very large file can still exhaust
renderer memory — the fallback warns about exactly that. Fixing that
properly means chunked storage, which is out of scope here.
## Verification
Reproduced and confirmed the cause by A/B on a branch that predates
#7175: as-is 8/8 pass on WebKit, and applying only #7175's `data:
stirlingFile` line reproduces the exact CI failure set.
| Check | Result |
|---|---|
| `stubbed-webkit`: the 8 nightly failures +
`classification-heuristic-upload` | 9 passed |
| `stubbed-webkit`: `files-page`, `page-editor-rotation`,
`encrypted-pdf-unlock` | 32 passed, 1 skipped |
| `stubbed` (chromium): the same specs + `files-page` | 35 passed, 1
skipped |
| Frontend unit suite | 210 files, 1797 passed |
| `typecheck:core`, `typecheck:proprietary`, eslint, prettier | clean |
New unit coverage in `fileStorage.blobFallback.test.ts` pins the
contract over `fake-indexeddb` with `add` instrumented to count blob vs
copy attempts: blob path when accepted, blob-then-copy when refused (and
readable back), one attempt only for later files, and quota not retried.
---
## 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)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Testing (if applicable)
- [x] Frontend typecheck (core + proprietary), eslint, prettier, the
unit suite, and the affected Playwright specs on chromium and webkit all
pass
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Fix#6121
# Description of Changes
<!--
Please provide a summary of the changes, including:
- What was changed
- Why the change was made
- Any challenges encountered
Closes (#6121 )
-->
This PR expands the viewer ruler/measurement tool with real-world scale
support. Users can now apply preset scales, define custom scales,
calibrate a scale by drawing a reference measurement and entering its
known real-world distance, and view measurements with scaled real-world
values.
It also refactors PDF `/Measure` and `/VP` scale extraction out of
`EmbedPdfViewer` into a dedicated utility, centralizes ruler state
management in a dedicated hook, persists ruler measurements and selected
scales per file during the browser session, remembers the last
calibration unit locally, and updates the ruler overlay so measurements
remain aligned with the PDF page during rotation and scrolling.
**New Files**
- `RulerMeasurementLayer.tsx` - Renders ruler measurements in the SVG
overlay, including lines, points, labels, page/scaled values, delete
controls, live previews, clear controls, and label visibility modes.
- `RulerScaleSettingsButton.tsx` - Adds the scale settings
button/popover to the viewer toolbar.
- `ScaleCalibrationDialog.tsx` - Provides the calibration modal where
users enter a known real-world distance to calculate the scale
automatically.
- `ScaleSettingsPanel.tsx` - Provides preset scales, custom scale input,
calibration entry point, active scale display, and reset controls.
- `useMeasurementManager.ts` - Centralizes ruler state, custom scale
state, calibration flow, per-file measurements, session persistence, and
loading of PDF-derived scale data.
- `measurementPreferences.ts` - Persists the last calibration unit in
`localStorage`.
- `measurementTypes.ts` - Defines shared measurement, point, scale, page
scale, and viewport scale types.
- `measurementUtils.ts` - Provides unit conversion, scale calculation,
validation, formatting, calibration helpers, and session storage
helpers.
- `measurementUtils.test.ts` - Adds unit tests for scale calculations,
unit conversion, preset parsing, ratio derivation, and calibration.
- `pdfMeasurementExtraction.ts` - Moves PDF `/Measure` and `/VP` scale
extraction into a dedicated utility.
**Changed Files**
- `EmbedPdfViewer.tsx` - Removes inline PDF scale extraction and
delegates ruler/measurement state to `useMeasurementManager`; integrates
the ruler overlay, custom scale support, restored measurements, and
calibration dialog.
- `LocalEmbedPDF.tsx` - Adds page-level metadata used by the ruler
overlay, including page width, height, and native page rotation.
- `RotateAPIBridge.tsx` - Adds immediate rotation update propagation so
ruler measurements can update their page-anchored positions during
rotation changes.
- `RulerOverlay.tsx` - Refactors the ruler overlay to use shared
measurement types/utilities, support custom scales, calibration
measurements, restored measurements, measurement change listeners,
rotation-aware positioning, and scroll compensation, also holding Alt
key will activate pass-through behavior so labels do not block ruler
interactions.
- `useViewerWorkbenchBarButtons.tsx` - Adds the ruler scale settings
action and coordinates ruler, pan mode, and calibration behavior.
- `ViewerContext.tsx` - Adds immediate rotation notification support
used by ruler measurements while viewer rotation changes are applied.
- `en-GB/translation.toml` and `en-US/translation.toml` - Add UI text
for scale settings, calibration actions, ruler measurement values, and
ruler label controls.
---
## 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)
- [x] 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)
- [x] 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)
Current scale panel :
<img width="1316" height="515" alt="Captura de tela de 2026-05-31
21-57-53"
src="https://github.com/user-attachments/assets/df0ffb7d-4546-450c-8380-409658852a87"
/>
Current calibration input :
<img width="1021" height="522" alt="Captura de tela de 2026-05-31
21-59-52"
src="https://github.com/user-attachments/assets/e17c14e7-fec7-42e9-af48-33ef825acb6c"
/>
Example of usage :
<img width="1316" height="760" alt="Captura de tela de 2026-05-31
22-31-01"
src="https://github.com/user-attachments/assets/fcd3578e-1eaa-42d3-b30b-3fc87c796f90"
/>
### 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: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
# Description of Changes
The restructuring of the frontend PR (#7062) has highlighted a couple of
issues with the source which are currently hidden due to the structure
of the code. This PR fixes the issues in Watched Folders:
- There's a circular dependency in TS code
- The `.gitignore` file excludes the watched folders dir by accident
# Description of Changes
> Stacked on #7264 — review that first. This diff is against its branch.
## The problem
`AppConfigContext` hand-rolled a query client: a `fetchCountRef` dedupe
guard, an exponential-backoff retry loop with its own `sleep()`, a
`hasResolvedConfig` flag, and manual 401/5xx branching. All to fetch one
endpoint that 80 files read.
## End state
Same provider, same public contract, React Query underneath. **250 lines
to 142**, and no consumer file changes.
| | Before | After |
|---|---|---|
| Dedupe | `fetchCountRef` guard | query key |
| Retry | `for` loop + `sleep()` + backoff maths | `retry` +
`retryDelay` |
| 401 | caught in the component, sets default config | `fetchAppConfig`
returns the default — the retry predicate and error state only see real
failures |
| Auth pages | early return inside the fetch | `enabled` |
| Resolved-yet tracking | `hasResolvedConfig` state | derived from the
query |
`fetchAppConfig` moves to `core/api/config.ts` with the simulation hook
and request options, so the context no longer knows how config is
fetched.
**Behaviour change:** config survives a provider remount instead of
refetching. That matters on desktop, where a connection-mode switch
remounts the tree — and #7264's cache reset already clears it on exactly
that transition.
## Testing
The existing 12-case contract test passes unchanged apart from the query
wrapper. It caught a real mistake: `failureCount` is 0-based in v5, so
`<= maxRetries` gave one attempt too many.
Four cases added — cached remount, `maxRetries` honoured, 4xx not
retried, `autoFetch` off.
`task frontend:check` green: 1672 tests across 191 files, typecheck on
all five flavours, eslint, dpdm, prettier.
## Coming next
| PR | Scope |
|---|---|
| 3 | `useEndpointConfig` — core (251 lines) plus a 482-line desktop
override with its own dependency polling. Split out of this PR;
different risk profile, and it deserves its own review. |
| 4 | `useAdminSettings` (20 consumers) and the config sections |
| 5 | Polling loops → `refetchInterval` |
| 6 | Finish the Processor, collapse to one client |
| 7 | Tool execution — mutation state only |
---------
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
# 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.
## 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)
```
Bumps com.sun.xml.bind:jaxb-core from 4.0.7 to 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>
Bumps org.sonarqube from 7.2.3.7755 to 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>
# 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>
# 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.
# 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.
# 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.
## 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.
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.
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.
# 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.
# 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.
# Description of Changes
Fixes a startup failure introduced by #7155 and reported against `task
backend:dev:saas`.
**What goes wrong**
`StorageProviderConfig.storageEncryptionState(...)` is created on every
startup, in every profile. When `storage.encryption.enabled` is false —
the default, and what SaaS ships — the `||` short-circuit evaluates
`fileEncryptionKeyRepository.count()`, a live query against
`file_encryption_keys`:
```java
if (writeEnabled || fileEncryptionKeyRepository.count() > 0) { // <- always runs when the flag is off
```
That table only exists if `ddl-auto=update` managed to create it. When
it cannot — permissions on a shared Supabase branch DB, concurrent DDL
from several developers, schema ordering — **ddl-auto logs and
continues**, so the situation used to be a warning nobody noticed. Now
it is a query that throws during bean creation and takes the whole
context down.
Two things make this sting in SaaS specifically: `storage.enabled` is
false there, so before this feature nothing ever touched the table; and
`hibernate.default_schema=stirling_pdf` means the table has to exist in
a schema the app may not be able to create in.
There is a second exposure on the request path:
`suppressDirectDownloads()` also counts (60s cached), so even a
surviving boot could 500 on downloads.
**Fix**
- The boot probe runs only when `storage.enabled` is true, so a
deployment that does not use storage never touches the table.
- Registry reads are wrapped. The boot probe degrades to "no keys"
rather than propagating; `suppressDirectDownloads()` **fails safe by
suppressing** rather than issuing a presigned URL it cannot vouch for.
Losing the direct-download fast path is recoverable; serving ciphertext
is not.
**Safety is unchanged, and that is the important part.** The decorator
is still installed unconditionally, so any blob carrying the `SPDFEAR1`
magic is still decrypted via lazy materialisation or fails loudly — the
eager probe only ever bought *earlier* master-key verification. A node
that can actually serve stored files has `storage.enabled` on by
definition, which is exactly the node the drifted-node protection is
for; that test now configures it that way, and a new test pins that the
decorator remains installed even with storage off.
**Tests** — storage-disabled never calls `count()`; an unreadable
registry still boots *and* still suppresses direct downloads; the
decorator stays installed with storage off; storage-enabled still
probes. Full proprietary suite green apart from the pre-existing
Windows-symlink `FolderIdentitiesTest` failure, which is environmental
and unrelated.
**Note on scope:** deliberately minimal so it can land quickly. The
Aikido `findAll()` code-quality finding lives in #7173 only
(`rotateMasterKey` does not exist on main), so it is fixed there rather
than here. #7173 will be rebased once this merges.
---
## 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.
## Overview
First part of the move over to the new designs. This lays the groundwork
(shared brand components, button/nav styling, theme tokens) and applies
it across the editor and the processor. Later parts will build on top of
it.
## What's changed
**Branding**
- Shared `Logo` and `BrandMark` components used everywhere, so the mark
and wordmark are identical across the editor, processor, auth pages and
the chat FAB.
- The sidebar logo doubles as the editor to processor switcher, morphing
into a chevron on hover. It only appears for users who can actually
reach the processor.
**Navigation and layout**
- Both sidebars restructured onto the floating nav surface treatment,
with rounded panels sitting on the app canvas.
- The editor file sidebar is now three sections (controls, PDF Library,
settings) and the workbench top bar and tools panel match.
- Added a collapse toggle to both sidebars, with an animated expand and
collapse and a tidy icon rail when collapsed. The processor did not have
a desktop collapse before.
**Components**
- Buttons and action icons now share one styling system, so both react
to the same tokens.
- Secondary buttons in dark mode use a neutral fill and border instead
of inheriting the primary colour.
- Status badges default to a clean dot with no background, with a filled
pill as the alternative.
- Metric strips gained a row layout with an optional leading icon.
**Theme**
- Colour tokens consolidated. Literal colours live only in the palette
file, everything else references the semantic `--c-*` tokens.
- `saas-theme.css` removed and the parts that were genuinely needed
moved into the shared theme, so all builds get them.
- The colour linter enforces this across the app and runs in CI.
## Notes
- Nothing functional should change here, it is styling plus the sidebar
collapse feature.
- Main has been merged in. The Sources and billing pages picked up
changes from main during that merge and are worth a look alongside the
new styling.
---------
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
# Description of Changes
Mac builds in PRs are not currently signed, which means you can't run
them when downloaded. This restores the functionality so that Mac builds
are always signed.
# Description of Changes
Fixes the `playwright-e2e-live` failure seen on [run
30694073128](https://github.com/Stirling-Tools/Stirling-PDF/actions/runs/30694073128).
The backend never started:
```
> Could not resolve org.springframework.boot:spring-boot-buildpack-platform:4.0.6.
> Could not GET 'https://repo.maven.apache.org/maven2/.../spring-boot-buildpack-platform-4.0.6.pom'.
Received status code 429 from server: Too Many Requests
> There are 14 more failures with identical causes.
BUILD FAILED in 21s
```
Gradle was throttled by Maven Central while resolving the buildscript
classpath, `:stirling-pdf:bootRun` died, and the runner's "backend
exited before becoming ready" guard aborted the suite before a single
test ran.
`e2e-live.yml` was the only Java-running workflow with no Gradle
dependency cache, no `setup-gradle`, and no Maven mirror env - every
sibling (`backend-build.yml`, `db-migration-test.yml`,
`coverage-aggregate.yml`) has all three. So it downloaded the Gradle
distribution and resolved the entire classpath cold from Maven Central
on every single run, and eventually got throttled.
Added:
- the same `Cache Gradle dependency artifacts` + `Setup Gradle` pair
used by `backend-build.yml`
- `MAVEN_USER` / `MAVEN_PASSWORD` / `MAVEN_PUBLIC_URL` on the two
Gradle-invoking steps, so runs that have the secrets use the internal
mirror instead of hitting Central
- a `Prime Gradle dependencies` step that retries 3x with backoff.
Gradle does not retry 429s, and doing the cold resolve up front means a
rate-limit failure retries cheaply instead of killing a backgrounded
`bootRun` twenty minutes in
Side benefit: the job gets faster once the cache is warm.
## Notes for reviewers
- The 429 itself is transient infrastructure behaviour - a re-run would
likely have gone green. The defect being fixed is that this job had no
cache to fall back on, so it was exposed to it on every run.
- `:stirling-pdf:classes` does not trigger a frontend build
(`buildWithFrontend` defaults off, `app/core/build.gradle:147`), so
priming before the Vite build step is safe. It is not wasted work either
- `bootRun` compiles the same classes.
- This PR originally also carried a fix for the `tauri-build`
updater-key failure on that same run. #7181 fixes that more simply and
has been merged, so that half has been dropped here.
- Workflow changes cannot be fully verified locally; a CI run on this
branch is the real check.
---
## 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.
# Description of Changes
Unused translations test takes ~8 seconds to run on my computer with no
contention, but when my CPU is under heavy contention, it often takes
>20 seconds and occasionally goes over the 30 second timeout. This PR
changes the test to use [a trie](https://en.wikipedia.org/wiki/Trie) to
more efficiently search for the strings, taking the test down to ~1.8
seconds. I've also increased the timeout for the missing & unused
translations tests for belt-and-braces.
# Description of Changes
The `task backend:test` command automatically spawns new Java processes
in the dock on Mac as it runs, which takes the focus away from whatever
the developer is doing at the time. This is because there's missing a
missing `headless` tag in the `build.gradle` file (the tests don't spawn
or require any windows, so they run fine headless).
Also adds a `task backend:test:force` rule to run the tests without
cache because the cache was getting in the way of testing this.
<img width="175" height="98" alt="image"
src="https://github.com/user-attachments/assets/d4524959-9c25-4513-bf34-9fd48310c4d3"
/>
Bumps org.apache.pdfbox:jbig2-imageio from 3.0.4 to 3.0.5.
[](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>
Bumps org.apache.pdfbox:jbig2-imageio from 3.0.4 to 3.0.5.
[](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>
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to
3.4.12.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/cure53/DOMPurify/releases">dompurify's
releases</a>.</em></p>
<blockquote>
<h2>DOMPurify 3.4.12</h2>
<ul>
<li>Fixed an issue where a hook would not get called for custom
elements, thanks <a
href="https://github.com/Rikuxx0"><code>@Rikuxx0</code></a></li>
<li>Hardened the handling of hooks removing elements, <a
href="https://github.com/mkrause-bee360"><code>@mkrause-bee360</code></a></li>
<li>Added support for a few new SVG attributes, thanks <a
href="https://github.com/cbn-falias"><code>@cbn-falias</code></a> &
<a
href="https://github.com/Develop-KIM"><code>@Develop-KIM</code></a></li>
<li>Hardened the handling of declarative partial updates</li>
<li>Updated the documentation is several spots, README, wiki, etc.</li>
<li>Bumped several dependencies where possible</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/cure53/DOMPurify/commit/a9ca1e537422319a557a9a2aa61f003b23b4a197"><code>a9ca1e5</code></a>
release: 3.4.12 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1537">#1537</a>)</li>
<li>See full diff in <a
href="https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12">compare
view</a></li>
</ul>
</details>
<br />
[](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>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
[//]: # (dependabot-start)
⚠️ **Dependabot is rebasing this PR** ⚠️
Rebasing might not happen immediately, so don't worry if this takes some
time.
Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.
---
[//]: # (dependabot-end)
Bumps `pdfboxVersion` from 3.0.7 to 3.0.8.
Updates `org.apache.pdfbox:preflight` from 3.0.7 to 3.0.8
Updates `org.apache.pdfbox:xmpbox` from 3.0.7 to 3.0.8
Updates `org.apache.pdfbox:pdfbox` from 3.0.7 to 3.0.8
Updates `org.apache.pdfbox:pdfbox-io` from 3.0.7 to 3.0.8
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>
Bumps eclipse-temurin from `b27ca47` to `f9bd881`.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
You can trigger a rebase of this PR 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>
> **Note**
> Automatic rebases have been disabled on this pull request as it has
been open for over 30 days.
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Bumps `bouncycastleVersion` from 1.84 to 1.85.
Updates `org.bouncycastle:bcprov-jdk18on` from 1.84 to 1.85
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/bcgit/bc-java/blob/main/docs/releasenotes.html">org.bouncycastle:bcprov-jdk18on's
changelog</a>.</em></p>
<blockquote>
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<p><!-- raw HTML omitted --><!-- raw HTML omitted -->2.2.1 Version<!--
raw HTML omitted --><!-- raw HTML omitted -->
Release: 1.85, 1.85.1<!-- raw HTML omitted -->
Date: 2026, July 12th</p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/bcgit/bc-java/commits">compare view</a></li>
</ul>
</details>
<br />
Updates `org.bouncycastle:bcpkix-jdk18on` from 1.84 to 1.85
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/bcgit/bc-java/blob/main/docs/releasenotes.html">org.bouncycastle:bcpkix-jdk18on's
changelog</a>.</em></p>
<blockquote>
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<p><!-- raw HTML omitted --><!-- raw HTML omitted -->2.2.1 Version<!--
raw HTML omitted --><!-- raw HTML omitted -->
Release: 1.85, 1.85.1<!-- raw HTML omitted -->
Date: 2026, July 12th</p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/bcgit/bc-java/commits">compare view</a></li>
</ul>
</details>
<br />
Updates `org.bouncycastle:bcutil-jdk18on` from 1.84 to 1.85
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/bcgit/bc-java/blob/main/docs/releasenotes.html">org.bouncycastle:bcutil-jdk18on's
changelog</a>.</em></p>
<blockquote>
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->
<p><!-- raw HTML omitted --><!-- raw HTML omitted -->2.2.1 Version<!--
raw HTML omitted --><!-- raw HTML omitted -->
Release: 1.85, 1.85.1<!-- raw HTML omitted -->
Date: 2026, July 12th</p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/bcgit/bc-java/commits">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 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>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Bumps [windows](https://github.com/microsoft/windows-rs) from 0.61.3 to
0.62.2.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/microsoft/windows-rs/commits">compare
view</a></li>
</ul>
</details>
<br />
[](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>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
# Description of Changes
- The multi-node compose stack + behave suite (11 features)
- The nightly multinode-e2e job in build-enterprise.yml
cuke features are
cluster_health - both nodes boot healthy and join the Valkey backplane
load_balancing - traffic spreads across nodes; no spurious 401 when
bounced
cross_node_auth - a token from one node validates on all nodes (shared
DB keys)
shared_state - teams/sources/org visible from every node
policy_management - create/rename/delete a policy on any node, reflected
everywhere
source_management - source CRUD cross-node; referenced source can't be
deleted anywhere
connections - S3 connection resolves (secret masked) and deletes
cluster-wide
processor_ledger - files processed exactly once even when both nodes
trigger together
policy_run_coordination - a run on one node is visible from every node
rate_limiting - rate-limit counters shared via Valkey, not per node
failover - LB keeps serving when a node dies; recovered node accepts
existing tokens
can now start a full node system with
export PREMIUM_KEY=<your licence key> ./start-multinode-test.sh
starts a 40 person org DB install with multi node and database
(--no-seed to have without DB on startup)
4 teams
1 s3 connection
1 policy
---
## 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.
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.12 to
8.5.25.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/releases">postcss's
releases</a>.</em></p>
<blockquote>
<h2>8.5.25</h2>
<ul>
<li>Fixed 8.5.17 visitor regression.</li>
<li>Fixed <code>list.split()</code> for non-string values (by <a
href="https://github.com/amir-rezaei"><code>@amir-rezaei</code></a>).</li>
</ul>
<h2>8.5.24</h2>
<ul>
<li>Preserve the BOM after the processing (by <a
href="https://github.com/hdimer"><code>@hdimer</code></a>).</li>
</ul>
<h2>8.5.23</h2>
<ul>
<li>Do not load source map without <code>opts.from</code> for security
reasons.</li>
</ul>
<h2>8.5.22</h2>
<ul>
<li>Fixed custom property losing semicolon before a comment (by <a
href="https://github.com/sarathfrancis90"><code>@sarathfrancis90</code></a>).</li>
</ul>
<h2>8.5.21</h2>
<ul>
<li>Fixed childless at-rule losing semicolon before comment (by <a
href="https://github.com/sarathfrancis90"><code>@sarathfrancis90</code></a>).</li>
<li>Fixed docs (by <a
href="https://github.com/isker"><code>@isker</code></a>).</li>
</ul>
<h2>8.5.20</h2>
<ul>
<li>Fixed missing space if <code>AtRule#params</code> is set after (by
<a
href="https://github.com/sarathfrancis90"><code>@sarathfrancis90</code></a>).</li>
<li>Fixed mixing AST error on warnings (by <a
href="https://github.com/MahinAnowar"><code>@MahinAnowar</code></a>).</li>
</ul>
<h2>8.5.19</h2>
<ul>
<li>Fixed cleaning <code>before</code> for new nodes inserted to
<code>Root</code> (by <a
href="https://github.com/MahinAnowar"><code>@MahinAnowar</code></a>).</li>
</ul>
<h2>8.5.18</h2>
<ul>
<li>Restricted loading previous source maps file to the
<code>opts.from</code> folder for security reasons (use <code>unsafeMap:
true</code> to disable the check).</li>
</ul>
<h2>8.5.17</h2>
<ul>
<li>Fixed <code>Maximum call stack size exceeded</code> error.</li>
<li>Fixed Prototype hijacking for <code>postcss.fromJSON()</code>.</li>
<li>Fixed <code>Input#origin()</code> for unmapped end position (by <a
href="https://github.com/chatman-media"><code>@chatman-media</code></a>).</li>
</ul>
<h2>8.5.16</h2>
<ul>
<li>Fixed <code>Input#origin()</code> position (by <a
href="https://github.com/mizdra"><code>@mizdra</code></a>).</li>
<li>Fixed <code>raws</code> after rehydrating a JSON AST (by <a
href="https://github.com/sarathfrancis90"><code>@sarathfrancis90</code></a>).</li>
<li>Fixed putting parent-less node in <code>nodes</code> of new node (by
<a
href="https://github.com/MahinAnowar"><code>@MahinAnowar</code></a>).</li>
<li>Fixed computing <code>offset</code> in <code>positionBy()</code> (by
<a
href="https://github.com/greymoth-jp"><code>@greymoth-jp</code></a>).</li>
<li>Fixed <code>rangeBy()</code> on <code>index: 0</code> (by <a
href="https://github.com/sarathfrancis90"><code>@sarathfrancis90</code></a>).</li>
</ul>
<h2>8.5.15</h2>
<ul>
<li>Fixed declaration parsing performance (by <a
href="https://github.com/homanp"><code>@homanp</code></a>).</li>
</ul>
<h2>8.5.14</h2>
<ul>
<li>Fixed custom syntax regression (by <a
href="https://github.com/43081j"><code>@43081j</code></a>).</li>
</ul>
<h2>8.5.13</h2>
<ul>
<li>Fixed <code>postcss-scss</code> commend regression.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/blob/main/CHANGELOG.md">postcss's
changelog</a>.</em></p>
<blockquote>
<h2>8.5.25</h2>
<ul>
<li>Fixed 8.5.17 visitor regression.</li>
<li>Fixed <code>list.split()</code> for non-string values (by <a
href="https://github.com/amir-rezaei"><code>@amir-rezaei</code></a>).</li>
</ul>
<h2>8.5.24</h2>
<ul>
<li>Preserve the BOM after the processing (by <a
href="https://github.com/hdimer"><code>@hdimer</code></a>).</li>
</ul>
<h2>8.5.23</h2>
<ul>
<li>Do not load source map without <code>opts.from</code> for security
reasons.</li>
</ul>
<h2>8.5.22</h2>
<ul>
<li>Fixed custom property losing semicolon before a comment (by <a
href="https://github.com/sarathfrancis90"><code>@sarathfrancis90</code></a>).</li>
</ul>
<h2>8.5.21</h2>
<ul>
<li>Fixed childless at-rule losing semicolon before comment (by <a
href="https://github.com/sarathfrancis90"><code>@sarathfrancis90</code></a>).</li>
<li>Fixed docs (by <a
href="https://github.com/isker"><code>@isker</code></a>).</li>
</ul>
<h2>8.5.20</h2>
<ul>
<li>Fixed missing space if <code>AtRule#params</code> is set after (by
<a
href="https://github.com/sarathfrancis90"><code>@sarathfrancis90</code></a>).</li>
<li>Fixed mixing AST error on warnings (by <a
href="https://github.com/MahinAnowar"><code>@MahinAnowar</code></a>).</li>
</ul>
<h2>8.5.19</h2>
<ul>
<li>Fixed cleaning <code>before</code> for new nodes inserted to
<code>Root</code> (by <a
href="https://github.com/MahinAnowar"><code>@MahinAnowar</code></a>).</li>
</ul>
<h2>8.5.18</h2>
<ul>
<li>Restricted loading previous source maps file to the
<code>opts.from</code> folder for security reasons (use <code>unsafeMap:
true</code> to disable the check).</li>
</ul>
<h2>8.5.17</h2>
<ul>
<li>Fixed <code>Maximum call stack size exceeded</code> error.</li>
<li>Fixed Prototype hijacking for <code>postcss.fromJSON()</code>.</li>
<li>Fixed <code>Input#origin()</code> for unmapped end position (by <a
href="https://github.com/chatman-media"><code>@chatman-media</code></a>).</li>
</ul>
<h2>8.5.16</h2>
<ul>
<li>Fixed <code>Input#origin()</code> position (by <a
href="https://github.com/mizdra"><code>@mizdra</code></a>).</li>
<li>Fixed <code>raws</code> after rehydrating a JSON AST (by <a
href="https://github.com/sarathfrancis90"><code>@sarathfrancis90</code></a>).</li>
<li>Fixed putting parent-less node in <code>nodes</code> of new node (by
<a
href="https://github.com/MahinAnowar"><code>@MahinAnowar</code></a>).</li>
<li>Fixed computing <code>offset</code> in <code>positionBy()</code> (by
<a
href="https://github.com/greymoth-jp"><code>@greymoth-jp</code></a>).</li>
<li>Fixed <code>rangeBy()</code> on <code>index: 0</code> (by <a
href="https://github.com/sarathfrancis90"><code>@sarathfrancis90</code></a>).</li>
</ul>
<h2>8.5.15</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/postcss/postcss/commit/08c989c43cc87edb1ed71408c2f5164c54fc21df"><code>08c989c</code></a>
Release 8.5.25 version</li>
<li><a
href="https://github.com/postcss/postcss/commit/24f681471645cd960ee760ab7f9e348fbabfd42c"><code>24f6814</code></a>
Fix 8.5.17 visitor regression</li>
<li><a
href="https://github.com/postcss/postcss/commit/f2fa53f11daab3a16c7eb8bcaf5a945142341df3"><code>f2fa53f</code></a>
Add supply chain security requirement to PostCSS plugin guide</li>
<li><a
href="https://github.com/postcss/postcss/commit/10edf0b0606f97b1510e040c27bfd078c48d6ea7"><code>10edf0b</code></a>
fix: return empty array for empty string in list.split (<a
href="https://redirect.github.com/postcss/postcss/issues/2121">#2121</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/0ebe8ad591621ab4e48311da47a76974617571f9"><code>0ebe8ad</code></a>
Release 8.5.24 version</li>
<li><a
href="https://github.com/postcss/postcss/commit/73218c64245be53e25d58150e0cc7e984f1d162d"><code>73218c6</code></a>
Update dependencies</li>
<li><a
href="https://github.com/postcss/postcss/commit/9a114f62b0deb37be859102f93b414b49385805a"><code>9a114f6</code></a>
Preserve the BOM when stringifying (<a
href="https://redirect.github.com/postcss/postcss/issues/2119">#2119</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/90692619125cb9424f5eafd8c64bc76b2da23db1"><code>9069261</code></a>
Fix types check</li>
<li><a
href="https://github.com/postcss/postcss/commit/eb9e1fe793740bb3280bdf5bf98147f857f011bd"><code>eb9e1fe</code></a>
Release 8.5.23 version</li>
<li><a
href="https://github.com/postcss/postcss/commit/9d19c78ac91108b3f7d7130e55c6fa806c0efb84"><code>9d19c78</code></a>
Update dependencies</li>
<li>Additional commits viewable in <a
href="https://github.com/postcss/postcss/compare/8.5.12...8.5.25">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 postcss since your current version.</p>
</details>
<br />
[](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>
Bumps ubuntu from `c4a8d55` to `4fbb8e6`.
> **Note**
> Automatic rebases have been disabled on this pull request as it has
been open for over 30 days.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
# Description of Changes
Fixes the desktop default-PDF banner that kept returning every launch
after users dismissed it or already set Stirling as default
([#6743](https://github.com/Stirling-Tools/Stirling-PDF/issues/6743),
also tracked in
[#5772](https://github.com/Stirling-Tools/Stirling-PDF/issues/5772) /
[#6270](https://github.com/Stirling-Tools/Stirling-PDF/issues/6270)).
### What changed
- **Persist dismiss:** X closes the banner for the current session only;
**Don't remind me again** (muted secondary action) permanently opts out
via the existing `localStorage` helpers that were never wired.
- **Settings:** General → Default PDF editor includes a **Remind me to
set as default** toggle (on by default), shown only when Stirling is not
already the default, so users can undo a permanent dismiss.
- **Linux detection:** Treat `Stirling-PDF.desktop` / case-insensitive
`*stirling*.desktop` as default, and resolve the real desktop file when
setting the association (fixes “already default but banner still
shows”).
- **InfoBanner:** optional secondary button support for the muted “Don't
remind me again” action.
### macOS quirk (Gatekeeper false positive)
One-click **Set Default** on macOS still uses
`LSSetDefaultRoleHandlerForContentType` — Apple has no public
replacement for document UTIs, so this remains the only single-button
path.
After setting Stirling as default, if a user later switches away with
Finder **Open With → Always Open With** on a *quarantined* (typically
downloaded) PDF, macOS can show:
> Apple could not verify “…pdf” is free of malware…
That is a known Gatekeeper/`LSRiskCategoryHasRedirectedBinding`
behaviour ([Apple Developer Forums
thread](https://developer.apple.com/forums/thread/795994)), not malware
and not something we can suppress from the app. **Safe way to switch
away:** select a PDF → File → Get Info → Open With → choose the app →
**Change All** (avoid Open With → Always on downloaded PDFs).
Closes#6743
---
## 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)
- [x] 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)
- New strings added in **en-US only** (`defaultApp.prompt.dontRemind`,
`settings.general.defaultPdfEditorRemind` / `RemindDescription`); other
locales handled separately.
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
- Banner: session dismiss (X) + muted **Don't remind me again**;
settings toggle only when not already default.
### 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.
#### Test runs
- `task pre-commit:fix` (including toml-sort / locale hygiene)
- `task backend:test` — passed (incl. JaCoCo coverage targets)
- `task frontend:check` — lint, typecheck, format, tests
- `task frontend:test` — **166 files / 1353 tests passed**
- `task engine:check` — typecheck, lint, format; **335 pytest tests
passed**
- Re-ran `unusedTranslations` / `missingTranslations` after new en-US
keys — passed
- Manual: permanent dismiss persists across relaunch; settings remind
toggle restores banner; Linux desktop-file name mismatch addressed in
Rust
Co-authored-by: Wesley <wesley@awka.dev>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Bumps the uv group with 1 update in the /engine directory:
[pyasn1](https://github.com/pyasn1/pyasn1).
Updates `pyasn1` from 0.6.3 to 0.6.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/pyasn1/pyasn1/releases">pyasn1's
releases</a>.</em></p>
<blockquote>
<h2>Release 0.6.4</h2>
<p>This is a security release.</p>
<ul>
<li>CVE-2026-59885 (GHSA-8ppf-4f7h-5ppj): Fixed quadratic time
complexity in the OBJECT IDENTIFIER and RELATIVE-OID decoders. A small
crafted substrate encoding many arcs could consume excessive CPU.</li>
<li>CVE-2026-59884 (GHSA-m4p7-r5rc-7g4j): Limited BER long-form tag IDs
to 20 octets (140 bits). Unbounded tag IDs allowed a crafted substrate
to consume excessive CPU and memory.</li>
<li>CVE-2026-59886 (GHSA-hm4w-wwcw-mr6r): Fixed excessive memory and CPU
consumption in <code>Real.__float__()</code> for values with large
base-10 exponents.</li>
<li>Pinned PyPI publish GitHub Action to an immutable commit.</li>
</ul>
<p>All changes are noted in the <a
href="https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst">CHANGELOG</a>.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst">pyasn1's
changelog</a>.</em></p>
<blockquote>
<h2>Revision 0.6.4, released 08-07-2026</h2>
<ul>
<li>CVE-2026-59885 (GHSA-8ppf-4f7h-5ppj): Fixed quadratic time
complexity in the OBJECT IDENTIFIER and RELATIVE-OID decoders.
A small crafted substrate encoding many arcs could consume
excessive CPU. Arcs are now accumulated in linear time; decoded
values are unchanged (thanks for reporting, tynus2)</li>
<li>CVE-2026-59884 (GHSA-m4p7-r5rc-7g4j): Limited BER long-form tag
IDs to 20 octets (140 bits), matching the OID arc limit introduced
in 0.6.2. Unbounded tag IDs allowed a crafted substrate to consume
excessive CPU and memory; longer tag IDs are now rejected with
PyAsn1Error. Also fixed Tag and TagSet repr() failing on huge tag
(thanks for reporting, mikeappsec)
IDs due to the integer-to-string conversion limit (Python 3.11+)</li>
<li>CVE-2026-59886 (GHSA-hm4w-wwcw-mr6r): Fixed excessive memory and
CPU consumption in Real.<strong>float</strong>() for values with large
base-10
exponents. Conversion no longer materializes huge intermediate
integers; values too large to represent as a Python float raise
OverflowError promptly, and prettyPrint() renders them as
'<!-- raw HTML omitted -->' as before. Also fixed base-10 mantissa
normalization
to use exact integer arithmetic; mantissas larger than 2**53
could previously lose precision through float division
(thanks for reporting, gvozdila)</li>
<li>Pinned PyPI publish GitHub Action to an immutable commit
[pr <a
href="https://redirect.github.com/pyasn1/pyasn1/issues/113">#113</a>](<a
href="https://redirect.github.com/pyasn1/pyasn1/pull/113">pyasn1/pyasn1#113</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pyasn1/pyasn1/commit/72e4803405816c371ed3b2cb4be181c47f038406"><code>72e4803</code></a>
Prepare release 0.6.4</li>
<li><a
href="https://github.com/pyasn1/pyasn1/commit/0c19eeb853731db1c717ff125ea001a1e558332d"><code>0c19eeb</code></a>
Pin PyPI publish action to immutable commit (<a
href="https://redirect.github.com/pyasn1/pyasn1/issues/113">#113</a>)</li>
<li><a
href="https://github.com/pyasn1/pyasn1/commit/45bdb19eb7df4b3780fe9c912c63e99bffc39dd9"><code>45bdb19</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/pyasn1/pyasn1/commit/628e36ecbb5277a3f01572ce418ef54271b165a5"><code>628e36e</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/pyasn1/pyasn1/commit/e60c691cb91addb8fcefa2f537e85ede6fb1e886"><code>e60c691</code></a>
Merge commit from fork</li>
<li>See full diff in <a
href="https://github.com/pyasn1/pyasn1/compare/v0.6.3...v0.6.4">compare
view</a></li>
</ul>
</details>
<br />
[](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 <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>
Bumps eclipse-temurin from `b27ca47` to `2f1da10`.
> **Note**
> Automatic rebases have been disabled on this pull request as it has
been open for over 30 days.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Fixes#7189
Consolidates all 29 `no-duplicate-selectors` violations across 18
stylesheets by merging later duplicate rule blocks into the first
occurrence. Where duplicates had conflicting values, the cascade-winning
(later) value was kept, so computed styles are unchanged.
Also:
- Adds `frontend/stylelint.config.mjs` with only
`no-duplicate-selectors` enabled (Prettier and the theme linter own
everything else).
- Adds a `frontend:lint:css` task, wired into `task frontend:lint` as a
blocking check so regressions can't creep back in.
- Lints all first-party CSS (`editor/**/*.css`, so `public/css` and any
future non-`src` stylesheets are covered too), excluding only the
vendored `cookieconsent.css` and build output via `ignoreFiles`. This
surfaced and fixed 5 additional duplicate selectors in
`cookieconsentCustomisation.css` that weren't in the original issue
report.
Note: `portal/views/Sources.css` goes beyond dedup — the whole
`.portal-sources__connections*` block is deleted as dead code
(unreferenced since the S3 connections redesign in #6965; only
`-actions` in it was an actual duplicate).
🤖 Generated with [Claude Code](https://claude.ai/code)
# Description of Changes
Move the logic for validating params into global exported functions and
attach them to the operation config so that we can access them elsewhere
so things like Pipelines know whether the tool has been configured and
provide a warning if not.
<img width="1264" height="503" alt="image"
src="https://github.com/user-attachments/assets/f320579c-6c08-4c66-a999-015d46df1b33"
/>
# Description of Changes
PR1 of the encrypt-at-rest initiative: user files stored by Stirling (My
Files, workflow files) are now AES-256 encrypted at rest across all
three storage backends, with keys that never leave the deployment.
**What was changed**
- New `EncryptingStorageProvider` decorator wraps whichever
`StorageProvider` backend is configured (local / database / S3). It
encrypts on `store` (Tink AES-256-GCM streaming AEAD, 1 MiB segments)
and transparently decrypts on `load`; legacy plaintext blobs are
detected by magic sniff and pass through untouched, so mixed state is
safe and no migration is required to enable.
- Envelope-encryption key hierarchy: each blob gets a random per-file
DEK, wrapped by a per-team KEK stored (master-key-wrapped) in a new
`file_encryption_keys` registry table; the master key resolves like the
existing credential key — `stirling.security.fileEncryptionKey`
property, `STIRLING_FILE_ENCRYPTION_KEY` env var, or an auto-generated
owner-only `file-encryption.key` in the config dir (cluster mode
requires an explicit shared key, fail-fast).
- Self-describing blob format (`SPDFEAR1` header) carrying the key id,
plaintext length, and the wrapped DEK; the header prefix is bound as GCM
associated data to both the DEK wrap and the payload, so headers cannot
be transplanted between blobs.
- Enabled via `storage.encryption.enabled=true`, gated on a
Pro/Enterprise licence — **write side only**: decryption activates
whenever key rows exist, so switching the flag off or a lapsed licence
can never make previously encrypted files unreadable.
- Key status lifecycle (`ACTIVE`/`RETIRED`/`DISABLED`): `DISABLED` is a
reversible per-team kill switch that fails closed on read; no API path
deletes key material. A revoked download surfaces as **403 Forbidden**
("access revoked"), not a 500, since it is a deliberate policy state
rather than a fault.
- Startup self-check: a master key that cannot unwrap existing key rows
refuses to boot rather than silently starting a second key hierarchy.
- S3 presigned download URLs are suppressed for decorated storage (they
would serve ciphertext); the controller already falls back to
app-streamed downloads.
- `StoredFile`/`StoredObject` gain a nullable `encryption_key_id`
(ddl-auto, no migration); persisted sizes remain plaintext sizes so
quotas and UI are unchanged.
**Why**
Enterprise security questionnaires (and HIPAA/GDPR/CMMC buyers) require
encryption at rest with documented key management; files were previously
plaintext in every backend. Design doc and vendor/standards research
(Purview, Box KeySafe, Google CSE, ISO 32000-2) informed the approach.
## Manually tested end-to-end
Beyond the automated suite, the full flow was exercised against a
running backend (local provider, `storage.encryption.enabled=true`,
login enabled) via the storage API:
1. **Startup** — master key auto-generated with the "back this up"
warning; logs `master key initialised (AES-256-GCM, fingerprint …)` and
`Storage encryption at rest active (writes encrypted)`.
2. **Encrypted at rest** — uploaded a PDF containing a known marker
string; the blob on disk (371 B vs 219 B plaintext) began with the
`SPDFEAR1` header + key id + ciphertext, contained **no `%PDF` signature
and no marker** — not openable as a PDF straight off disk.
3. **Transparent access** — downloading the file through the API
returned it **byte-identical** to the original, marker intact; stored
`sizeBytes` stayed the plaintext size.
4. **Kill switch + reversibility** — set the team key's status directly
in the DB and restarted:
- `DISABLED` → download **failed closed** (`403`, "access to this
content is revoked"), zero plaintext served.
- `ACTIVE` again → file **fully recovered, byte-identical**. Disabling
is a reversible switch on a preserved key row, not destruction.
(The 403 mapping in step 4 was added in this PR after the manual run
first surfaced it as a generic 500.)
## Coming in later PRs
- **PR2 — ops & lifecycle:** audit events for
encrypt/decrypt/key-lifecycle; admin endpoints for the kill switch
(disable/enable) and key status; a background "encrypt existing files"
migration job for turning the feature on over pre-existing plaintext;
master-key rotation (re-wrap KEK rows). Also plans a
key-backup/fingerprint verification command.
- **PR3 — admin UI:** settings section (status, per-team key list with
disable/enable), encrypted-file badge in My Files, i18n.
- **Later:** per-**source** encryption for the Processor pipeline (the
`SOURCE` key scope is already reserved in the schema); pluggable
external KMS / BYOK master-key backends (Vault, AWS/Azure/GCP KMS);
optional FIPS-validated crypto module build for CMMC; and encrypted
egress (PDF-native AES-256) for files leaving the platform.
**Reviewer notes**
- New dependency: `com.google.crypto.tink:tink:1.23.0` (Apache-2.0, pure
Java — bundled in the boot jar, no Docker changes). Pulls protobuf-java
4.33.6, which clears the Aikido-flagged CVE-2024-7254. `./gradlew
checkLicense --no-parallel` passes.
- The `file-encryption.key` file is generated in the config dir on first
use and must be backed up; losing it makes encrypted files unrecoverable
(loud log warning + fingerprint exposed for backup verification).
- Tests cover round-trips on re-openable and one-shot (S3-style)
backends, multi-segment files, legacy passthrough, decrypt-only mode,
disabled-key fail-closed (now asserting the 403 mapping), header/payload
tamper rejection, key-creation races, and presigned-URL suppression.
---
## 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.
## Why
Spotted while working on #7222. `PIPELINE_OPERATIONS` reads like the
list that decides which tools the pipeline composer offers — its doc
comment literally said *"the operation catalogue the composer builds a
pipeline from"* and *"adding an operation needs no translation work"*.
Nothing has imported it since #6905 replaced it with
`getExecutableTools` over the tool registry.
That makes it worse than dead weight: I added an entry to it to expose
Auto Rotate, and the entry did nothing. The next person will do the
same.
So I swept the editor frontend for the same shape — exported values with
no reference anywhere, including inside their own file — and removed the
confirmed ones.
## What went
**6 wholly dead files**
- `core/components/tooltips/useCertSignTooltips.ts`
- `core/components/tooltips/useCertificateChoiceTips.ts`
- `core/components/tooltips/useSessionManagementTips.ts`
- `core/components/tooltips/useWetSignatureTips.ts`
- `desktop/config/planFeatures.ts`
- `core/hooks/tools/shared/useOperationResults.ts`
**Dead consts and helpers** across core, portal and desktop:
`PIPELINE_OPERATIONS` + `PipelineOperationDef`, `FILE_ORIGINS`,
`DATE_GROUP_ORDER`, `toRgba`, `isSplitMethod`, `useSignatureMode`,
`getCategoryLabel`, `useCommonTranslations`, `useSignatureDetection`,
two `Z_INDEX_*` tokens, `getFontStatusIcon`, `buildUpdatedDocument`,
`INFO_PDF_FILENAME`, `useCustomMetadataTips`, `PORTAL_ACCESS_TONE`,
`MEMBER_STATUS_TONE`, `ASSIGNABLE_ROLES`, `TIER_INFO`, `VIEW_LABELS`,
`lookupVertical`, `lookupEndpoint`, `lookupAgent`, `lookupSource`,
`lookupDestination`, `docsSource`.
`fileProcessingService.ts` keeps only `ProcessedFileMetadata`, which
four files still import. Removing the dead service instance orphaned the
`FileProcessingService` class and its result type, so those went with
it.
**103 en-US translation keys** that only the deleted tooltip hooks and
helpers referenced — the unused-translation audit flags these as soon as
their callers go, so the cleanup isn't complete without them. Only
`en-US` is touched, since that is what the audit gates.
## Kept deliberately
Three things look unused in `core` but are not, and a naive sweep would
have deleted them:
- `Z_ANALYTICS_MODAL` and `DEV_TESTING_ENABLED` — the `saas` and
`proprietary` layers shadow those modules and reference them
- `CLOUD_LAYER_PROBE` — exists to prove layer resolution
## Testing
Deletions only, so the type checker and the suites are the proof:
- `typecheck:all` + `typecheck:portal` — all eight build variants clean
- full `eslint` (zero warnings) and `theme-lint`
- `prettier --check` across the frontend
- full editor suite: **1651 tests, 186 files, all passing**
Three incomplete removals and two orphaned imports were caught by
exactly those gates and fixed before pushing.
## Note
The same 103 keys still exist in the other ~40 locale files. The audit
only gates `en-US`, and translations are usually synced separately, so I
left them — worth a follow-up if you want them pruned too.
Follow-up to #7152.
## Why
Auto Rotate was already selectable as a pipeline step —
`getExecutableTools` lists any registry tool with an `operationConfig`
and an endpoint resolvable from defaults, which it has. But once added,
the settings pane showed *"Displaying these tool params for editing is
not supported yet"* and the step card read *"Runs with default
settings"*.
Both come from the same cause. `classifyToolStepSupport` keys off the
operation config's mappers:
```js
const hasMappers = Boolean(config?.toApiParams && config?.fromApiParams);
if (!hasMappers) return "unsupported";
return entry.automationSettings ? "editable" : "noSettings";
```
Auto Rotate had `automationSettings` but no mappers, because its custom
processor builds its own FormData and never needed them — so it
classified as `unsupported`.
The more consequential half is `serializeToolStep`, which emits
`parameters: {}` for a mapper-less tool. A composed pipeline therefore
ran Auto Rotate on server defaults with no way to change that. "Runs
with default settings" was literal.
## What
Declares `toApiParams` / `fromApiParams` on `autoRotateOperationConfig`,
mapping `detectionMode`, `confidenceThreshold` and `inferUndetected`.
That flips the step to `editable`, so the composer reuses the existing
`AutoRotateAutomationSettings` panel, and the chosen settings now reach
the backend.
**No backend change.** Policy steps already serialise `parameters` as
form fields and the endpoint already accepts these three — verified
against a running server while working on #7152.
## Testing
- New round-trip test in `toolAutomation.test.ts`: the step serialises
to `{detectionMode, confidenceThreshold, inferUndetected}` and
deserialises back with `support: "editable"`, using the real operation
config.
- `core/hooks/tools/shared` + `portal/components/pipelines` suites: 63
tests pass.
- Typecheck across build variants, ESLint, Prettier.
The composer UI itself was not clicked: the portal is not served by the
editor dev server locally, so the verification is the unit test plus the
render path (`PipelineStepSettings` renders `entry.automationSettings`
when support is `editable`, and the registry entry supplies it). Worth a
click-through on the preview deploy.
## Note for maintainers
While tracing this I found `PIPELINE_OPERATIONS` in
`portal/components/pipelines/pipelineOperations.ts` is exported but
never imported — the composer builds its list from the tool registry
instead. `humanizeOperation` in the same file *is* still used as a label
fallback. Left alone here as it is out of scope, but it looks like dead
code worth deleting separately.
Generated with [Claude Code](https://claude.com/claude-code)
# Description of Changes
Storybook files aren't currently being type-checked, but they should be.
A bunch of them had a dodgy import in them (which didn't affect anything
because it was just an `import type` but still worth fixing).
## What
New **Auto Rotate** tool: give it any PDF and it detects each page's
correct orientation and sets `/Rotate` so every page displays upright.
Lossless — only the page rotation metadata changes, content is never
re-rendered.
New endpoint: `POST /api/v1/misc/auto-rotate-pdf`, plus an editor tool
registered next to Rotate.
## How it works
Two-tier detection, per page, both expressed as an additive clockwise
`/Rotate` correction:
1. **Embedded-text fast path** (`AutoRotateDetection`): dominant glyph
direction via `PDFTextStripper`/`TextPosition.getDir()`. Trusted only
with >= 30 glyphs at >= 95% agreement. Near-instant for born-digital
PDFs and needs no external tools. Correction = `(glyphDir -
pageRotation) mod 360` — the sign conventions are pinned by
parameterized fixture tests covering all text-angle x `/Rotate`
combinations.
2. **Tesseract OSD fallback**: pages the text path can't decide are
rendered at 300 DPI grayscale and run through `tesseract --psm 0`.
Corrections apply only above a confidence threshold (default 14.0,
matching OCRmyPDF's `--rotate-pages-threshold`). Rendering honours the
existing `/Rotate`, so the verdict is always additive.
**Conservative by default**: blank pages, mixed-direction pages, and
low-confidence verdicts are skipped, never guessed — the failure mode to
avoid is making a correct page wrong.
### API surface
- `detectionMode`: `auto` (default) | `text` | `osd` — forcing one
method is useful for testing
- `confidenceThreshold`: minimum OSD confidence to apply a correction
- `dryRun=true`: returns a JSON per-page report instead of the PDF
- `pageRotations={"1":90,...}`: applies precomputed corrections without
detection
The frontend uses analyze-then-apply (dryRun, then pageRotations) so
detection runs exactly once per file, and the analysis report can be
shown in the UI.
### UI
The tool's results panel shows a **detection report** for
debugging/tuning: per page — method badge (Text / OCR / Skipped),
confidence score (glyph-dominance % for text, raw OSD score for OCR),
applied rotation, and a skip reason (too little text, mixed directions,
below threshold, OCR not installed...). Settings expose detection mode
and the OSD threshold.
### Dependency handling
Registered in `PageOps` only — deliberately **not** gated on the
`tesseract` group, because the text path works without Tesseract. The
controller checks `isGroupEnabled("tesseract")` at runtime; when it's
missing, scanned pages are skipped with a visible `tesseractUnavailable`
note instead of the whole tool disappearing.
## Testing
- 14 detection unit tests: all text-angle x `/Rotate` fixture
combinations (pins the direction conventions), dominance/glyph-count
guards, OSD output parsing
- 6 controller tests: dryRun report, correction application, explicit
pageRotations, tesseract-unavailable reporting, input validation
- Frontend: typecheck (core/desktop/proprietary), ESLint, Prettier, all
i18n audit tests
- **Live, text path**: fixture with pages at `/Rotate` 0/90/180/270 ->
all pages return upright; report UI verified in the browser
- **Live, OSD path**: image-only "scan" fixture (no text layer) with
pages upright/180/90 -> all detected by OSD at conf ~15-17 and
corrected; closed-loop re-analysis of the output reports 0 pages to
rotate with *higher* confidence than the input
Out of scope: skew correction (that's the OCR tool's `--deskew`); this
fixes 90-degree-multiple orientation only.
# Description of Changes
Change pipelines so that sources and triggers are grouped into a list of
inputs, so you can have a different trigger for each source in the list.
This is necessary because triggers are not universally supported by all
source types. If you wanted to have a pipeline pull from both a folder
and an S3 bucket, the current system allows you to choose "Folder Watch"
as the trigger, which will either do nothing or crash when it's paired
with the S3 bucket.
I've got reservations about actually allowing different triggers for
every source because it allows for user workflows that I don't believe
exist, like "I want this folder to be polled every minute and this other
one to be polled every hour, but they should run the same tools and
should output to the same place". Because of this (with agreement from
Connor, Anthony and Matt) I've changed this PR to artificially limit
pipelines to having 1 input & output at this stage. The backend is still
shaped to support multiple inputs & outputs so it should be trivial to
re-add support for them in the future if we decide we want to, but the
UI can be much simpler and easier to understand with just 1 input and
output.
<img width="1262" height="521" alt="image"
src="https://github.com/user-attachments/assets/809e6803-9f99-436d-9aeb-52dddf0906ff"
/>
# Description of Changes
Currently, desktop PRs only build on Linux, which none of the core
maintainers currently use. Change it so that desktop PRs build Mac and
Windows, so core maintainers can test the built version.
## What
Two related bugs found while looking at why #7187's a11y check behaves
differently on CI than locally.
### 21 stories were never being scanned on CI
The scan tasks only depended on `install`, not `prepare`. On a fresh
checkout that means the generated icon set
(`editor/src/assets/material-symbols-icons.json`, gitignored) doesn't
exist, so every story that reaches `LocalIcon` fails to import:
```
Failed to resolve import "../../../assets/material-symbols-icons.json"
from "editor/src/core/components/shared/LocalIcon.tsx"
```
On CI that was four story files / 21 stories, every run. It works
locally only because our trees already have the file from a previous
build. The scan tasks now depend on `prepare`, like the `build:*` tasks
do.
### The gate reported those runs as clean
Worse than the missing stories: a file that fails to import produces a
**failed suite with no assertions**. Every check in `a11y-check.mjs`
reads assertions, so the file satisfied the manifest, contributed
nothing to compare, and the run printed `✓ no a11y regressions`.
An assertion-less failed suite now fails the gate and points at the scan
log for the underlying resolve error. `--record` refuses in the same
situation, so a baseline can't be written that quietly drops those
stories.
Also switched the affected-story emptiness test to single quotes, since
that list now carries its own per-path quoting (it was producing `[ -z
""a" "b"" ]`).
## Testing
- Deleted the generated asset to reproduce a fresh checkout: the gate
**fails** with the file named and the cause explained, where before it
printed `✓ no a11y regressions` and exited 0.
- With the `prepare` dependency the task regenerates the asset itself
and the previously-invisible files scan: 21 stories, 35 story-rule
pairs, all already baselined.
## What
Fixes the a11y check failing with `permission denied` on any PR that
touches more than one story (currently hitting #7163).
The script that lists which stories to scan printed one path per line.
That list gets pasted into a shell command, so everything after the
first line fell out of the command — the shell treated the second path
as a command of its own and failed.
One-line fix: print the list on a single line.
## Testing
Changed two components and ran the task from both git-bash and
PowerShell — both stories scanned, check passes. #7163's red check
should go green on re-run once this is in.
New SaaS accounts were landing with `team_id = null`. That state is
unrecoverable: portal access derives from leading a team, and signup is
the only place one is assigned.
Five things had to be fixed, all on the signup path. Only the last is a
behaviour change you'd notice.
### 1. Shared-PK entity was routed to `merge()`
`SaasUserExtensions` pre-sets its `@MapsId` id in the constructor, so
Spring Data's id-nullness check treated a brand-new row as existing and
`save()` failed with `AssertionFailure: null identifier`. Now implements
`Persistable` and decides on the creation timestamp — the idiom already
used by `ProcessedFileEntity` and `SourceDocCountEntity`.
This was the blocker. It threw on every signup, and because the failure
was swallowed (see 3) every new account was stranded.
### 2. User and team were committed separately
`createUser()` is annotated `@Transactional` but is called as
`this.createUser(...)`, and self-invocation bypasses the proxy — so the
annotation did nothing. `saveUser()` and `ensurePersonalTeam()` each
committed in their own transaction, leaving a window where a **committed
user was visible with `team_id = null`**. Parallel requests entering
that window each provisioned a team, producing duplicates (observed:
teams 160/161 and 162/163 for one user).
Both writes now happen in one transaction via
`SaasTeamService.saveUserWithPersonalTeam()`. The window is gone, so
there is nothing left to race over.
### 3. A failed team create was swallowed
The old code logged at WARN and committed the user anyway. It now
propagates: the shared transaction rolls the user back, the request
401s, and a retry starts clean. Nothing half-built is committed.
This is the deliberate trade — a transient failure now surfaces instead
of silently producing an account that can never reach the portal.
### 4. Per-request healing removed
`recoverMissingTeam` (added in #7180) ran on **every authenticated
request** whose user had no team, with no mutual exclusion. Under a
burst of parallel requests it was itself a source of concurrent
provisioning. Provisioning belongs to signup alone.
### 5. Policy seeding could not run
`@TransactionalEventListener(AFTER_COMMIT)` leaves the *completed*
transaction bound to the thread, so `JpaPolicyStore.save`'s
`@Transactional` joined it instead of opening a live one — and its `FOR
UPDATE` lock threw `TransactionRequiredException`. Now seeded in
`BEFORE_COMMIT`: the lock has a live transaction, rollback safety is
unchanged (a rolled-back team still leaves no policy), and it stays on a
single pooled connection.
## Verified
`:saas:test` green, both spotless gates green, on top of current `main`.
Manually on a live signup: **one** team per user, and the
concurrent-signup race resolves correctly through the pre-existing
unique-constraint catch (`users_supabase_auth_id_key` violation →
refetch the winner).
12 filter tests needed updating. Two of them asserted behaviour this PR
deliberately removes (`personalTeamFailureSwallowed`,
`assignsTeamWhenMissing`), so they were rewritten to assert the new
contract rather than re-stubbed into passing.
## Not in scope
- **Existing stranded accounts** are not repaired — with the healer
gone, nothing fixes them on the request path. They need a one-off
backfill or deletion.
- **A DB-level invariant.** A partial unique index
(`UNIQUE(created_by_user_id) WHERE is_personal`) would make duplicate
personal teams impossible rather than merely unreachable. Wanted, but it
is a Supabase migration in the SaaS repo, so it is deliberately
separate.
- **Per-request auth cost.** The filter still does two remote-Postgres
round-trips per authenticated request; a frontend request storm makes
that expensive. Being handled separately.
# Description of Changes
The `pre-commit` tool to sort the translations is really slow. It took
~40 seconds to run because it's using a parser which attempts to save
all of the formatting data from the Toml. Our translations toml is
pretty much entirely formatted anyway, so there's no point in trying to
preserve any of that data. The only thing we lose is 5 comments, none of
which are needed anyway and only appear in the US translation file. By
switching to Python stdlib `tomllib` reading and `tomli-w` for writing,
we can make the Toml formatting job take 2.11 seconds, where it used to
take 39.78s. The whole pre-commit job now takes 4.58 seconds.
## What
Two fixes to the pull-request a11y job (#7086 follow-up), both found on
its first day live.
### It now scans a component's stories when the component changes
The job picked its scan set from changed **story files** alone. But a
story renders the live component — editing `Button.tsx` changes what
every Button story shows without touching a story file, and the job
scanned nothing. That's the common way a11y regressions arrive, and it
was exactly the case the job missed.
The scan set now also includes stories whose **same-named sibling source
file changed**: edit `Button.tsx` or `Button.css` and
`Button.stories.tsx` is scanned. Changes that ripple further than a
component's own stories (shared UI, theme tokens) remain the nightly
sweep's job.
### It no longer fails on cold-start infrastructure noise
The job's first real run (#7163) flagged a story as "failed to render".
The story was fine — on a cold dependency cache (**every** CI run), Vite
discovered the preview's own dependency graph mid-run and reloaded the
page, killing whichever story happened to be loading with `Failed to
fetch dynamically imported module`. Reproduced on a cold cache, passes
on a warm one.
- The preview's deps are named in `optimizeDeps.include`, which removes
the mid-run reload (verified cold).
- A batch whose report contains crash-class failures (failures carrying
no axe rule) is retried once — a one-off infrastructure death passes the
retry, a story that genuinely can't render fails both attempts and is
still reported.
Also: the scan-report artifacts were never actually uploading — they
live in a dot-directory, which `upload-artifact` silently skips as
hidden by default. `include-hidden-files: true` fixes that for the PR
job and the nightly, so a red run finally has its evidence attached.
### The glue is Node now, so tasks work from any shell
Raised in review: the pipeline leaned on `bash`, `sed`, `grep`, `sort`
and `tr`. Task runs its commands in an embedded POSIX interpreter, but
those are external binaries it has to find on PATH — and a Windows dev
calling tasks from **PowerShell** has none of them (`sed`/`tr` missing
outright, `sort` resolves to Windows' own, and `bash` resolves to
*WSL's*). Confirmed broken by running the task from PowerShell before
the change.
The batch runner and affected-story detection are now small Node scripts
(`a11y-scan.mjs`, `a11y-changed.mjs`) — the repo already requires Node,
so one implementation serves PowerShell, git-bash and CI alike, instead
of maintaining `.sh`/`.ps1` twins.
## Testing
- Sibling detection: editing `Tabs.tsx` (component only) pulls
`Tabs.stories.tsx` into the scan set; editing a `.css` sibling does the
same; nothing unrelated leaks in.
- **From PowerShell**: `task frontend:storybook:a11y:changed`
early-exits cleanly with no changes, and with a component edit it
detects the sibling, runs the browser scan and passes the gate — same
result from git-bash.
- Cold cache end-to-end: cleared both Vite caches, ran the scan — no
re-optimize, no reload, stories fail only on their (baselined) axe
results.
- Crash classifier: 1 on a synthetic crash report, 0 on axe-only
failures, 0 on a real report — so the retry can't be triggered by
legitimate violations.
- Full scan + gate run green end-to-end; taskfile parses, workflows are
valid YAML, Prettier/ESLint pass.
#7163's red check needs no action from that PR's author — it should go
green on re-run once this lands.
## What
Follow-up to #7073. Turns the story scan into an accessibility gate:
stories run axe in a real browser, and CI flags a change that adds a
**new** violation.
The app has plenty of existing a11y problems (mostly theme-level colour
contrast), so rather than block everything on those, they're recorded in
`.storybook/a11y-baseline.json` and grandfathered. The gate cares about
three things:
- a story breaking a rule it wasn't already breaking
- a story that fails to render at all
- a scan that didn't cover everything it was asked to
Starting point: 839 stories carry a known violation, 1058 story-rule
pairs.
## Where it runs
- **Pull requests** scan only the stories the branch touches — usually
seconds. A full sweep is ~30 minutes, too slow to sit in front of every
merge, and the `frontend` path filter is broad enough that unrelated
changes would pay for it.
- **Nightly** scans every story, so a violation introduced somewhere
other than the story itself — a shared component, a theme token — still
surfaces within a day.
- Both upload their scan reports as artifacts; the reports carry the
offending selector and help text, without which a red run can only be
understood by reproducing it locally.
- **Advisory to start with.** It is deliberately not in
`all-checks-passed`, so it reports without blocking. Worth promoting
once a few weeks of runs show the pass/fail is stable.
## Using it
- **Fixed some violations?** `task frontend:storybook:a11y:record`
re-records so the gate locks the improvement in.
- **Locally:** `task frontend:storybook:a11y:changed` for your branch,
`task frontend:storybook:a11y` for everything.
- **New component?** Its story is picked up automatically.
## Testing
- Every story — 526 files, ~1,450 stories — runs in a real browser with
no render failures, and the gate reports no regressions against the
baseline.
- Running the gate over a single changed story takes seconds, which is
the pull-request path.
- The gate's own behaviour is covered against synthetic scan reports: a
new rule fails, the same rule on more nodes does not, a crashed story
fails, an incomplete scan refuses to report, and re-recording refuses
while anything is crashing.
- Typecheck (all build variants), ESLint and Prettier pass.
## Notes for reviewers
Some of this PR is making the mechanism trustworthy rather than adding
features, so it's worth knowing what changed and why:
- Rule ids come from the axe docs URL in each violation, not a
hand-maintained list of rule names — the old list silently ignored 39 of
axe's 104 rules, including `object-alt`, `target-size` and the table
rules.
- The baseline records **which** rules a story breaks, not how many
nodes break them. Node counts drift between runs because stories fetch
asynchronously and axe samples whatever has rendered, which made
unrelated changes look like regressions. For the same reason the
baseline is the union of repeated scans, so a run can only be a subset
of it.
- A story that fails for a non-a11y reason used to yield no rule id and
was recorded as clean, which hid crashes and could mask real violations.
Those now fail, and re-recording refuses to run while any story is
crashing.
- The scan writes a manifest of every story file it intends to cover and
the check fails unless all of them reported, so a dropped batch can't
read as "no violations".
- Vite was pre-bundling the JSX runtime mid-run and reloading the page,
which crashed whichever stories were loading; those deps are now named
up front and the per-story timeout is above the 5s default.
Colour contrast dominates the baseline and is theme-level, tracked
separately from this.
# Description of Changes
<img width="1270" height="487" alt="image"
src="https://github.com/user-attachments/assets/64894e2b-aab9-42ab-96c2-2c11ba427b52"
/>
Change policies to point towards a source for its output instead of a
dynamically defined output location for the pipeline. This allows for
easy reuse of outputs in different pipelines and makes it impossible to
break complex pipelines by accidentally updating the source but not the
output and vice versa. Also makes outputs a list to match the inputs, so
it's possible for a pipeline to output to multiple locations.
We should consider whether we want to continue calling these Sources
since they're now being used as both inputs and outputs, but that
decision is beyond the scope of this PR.
Also updates the existing S3 DB migration script and adds a new one to
migrate to the new schema. Neither of these scripts are possible with
SQL since it involves parsing and restructuring JSON. I've updated them
so that they only ever run once on startup and mark themselves as
completed.
## The bug
Every SaaS signup after the very first one is created with `team_id =
NULL`, no team membership and no `home_team_id`. A brand-new account:
```
user_id | username | team_id | authenticationtype | home_team_id | memberships
952 | hedewot627@candaba.com | null | web | null | null
```
Since #7070 derives Processor access from leading a team, these accounts
are silently redirected out of the Processor and back to the editor.
## Cause
`SaasTeamService.createPersonalTeam` names every personal team the
literal `"My Team"`, and `stirling_pdf.teams.name` was unique — so the
insert throws a duplicate-key error for the second account onwards. Team
creation is best-effort (caught, logged at WARN), so the account is
created anyway, permanently team-less.
Migration `20251211000000` had already dropped that constraint for
exactly this reason, but it dropped it **by name** while the entity
still declared `@Column(unique = true)`. With Flyway retired for `:saas`
(#7100), `ddl-auto=update` reconciles the schema — so Hibernate
re-created the constraint on the next boot under a generated name the
old `DROP` could never match.
The data bug predates #7070; that PR only made it visible.
## Changes
- **`Team.name` no longer unique.** `TeamController` already enforces
uniqueness for admin-created teams (`existsByNameIgnoreCase` on create
and rename, 409), so nothing user-facing changes. `findByName` is only
used for the `Default`/`Internal` system teams.
- **Existing team-less accounts recover on authentication.** Signup is
the only other place a team is assigned and nothing back-fills
`team_id`, so without this they stay locked out. Guests excluded by
design; healthy accounts short-circuit on a null check (`team` is
`EAGER`).
- **Tests:** team recovered, existing team untouched, guest stays
team-less.
## Deploy order
Needs `20260806000000_teams_name_drop_unique` (SaaS repo, `v3`) to drop
the constraint from the live schema — **deployed after this**, or
Hibernate re-adds it on the next boot.
## Verification
`:saas:compileJava`, `:proprietary:compileJava`, spotless on both, and
the `:saas` team/auth-filter tests (`TeamRecovery`: 3 tests, 0
failures).
## What
Gets most of the app's components into Storybook and adds a scan that
runs every story in a real browser, so we have a base to build
accessibility testing on next.
- **~380 new stories**, taking story files from 144 to 526. Components
with a story:
| Layer | Before | After |
|---|---|---|
| core | 41 / 309 (13%) | **183 / 309 (59%)** |
| portal | 91 / 161 (57%) | **127 / 161 (79%)** |
| proprietary | 1 / 105 (1%) | **39 / 105 (37%)** |
| cloud / desktop / saas / portal-saas / prototypes | 0 / 84 | 0 / 84
(unchanged) |
| **Total** | **133 / 659 (20%)** | **349 / 659 (53%)** |
Both columns are counted the same way — every `.tsx` exporting a
component, so the denominator includes things that aren't really visual
units (contexts, providers, barrels). Excluding those it's 22% → 58%.
Either way it's reproducible from the tree rather than a number you have
to take on trust.
- **Scan harness** — the Storybook Vitest addon runs each story in
headless Chromium as a **render/smoke check** (a story must mount
without throwing). New task: `task frontend:storybook:test` (pass a
filter, e.g. `-- Button`). Separate Vitest config so it doesn't touch
the existing jsdom unit tests.
## Scope
- **Stories and Storybook config only, with one exception:** a one-line
fix to `ProviderCard`, which re-rendered forever whenever its optional
`settings` prop was omitted. Called out because it's the only component
source change here.
- The preview gains a `QueryClientProvider` (the portal app has one, so
stories reaching a query hook threw without it), and the scan task now
installs the browser it drives.
- **a11y is report-only** and **nothing runs the scan in CI yet** —
enforcing a11y and wiring it into CI is the follow-up, #7086.
- Components that can't render as an isolated unit are **not** included:
anything needing the full editor runtime (ToolWorkflow / FileManager /
AppConfig / a live PDF engine) or that's headless (providers, gates, API
bridges, config factories). Stories that only rendered by mounting the
whole `AppProviders` tree were dropped for the same reason — that isn't
isolation, and the tree's ErrorBoundary swallowed render failures so
those stories could never fail. A few that need assets the headless
browser can't serve are tagged `!test`, so they still show in the UI but
sit out the scan.
## Testing
Typecheck (all build variants), ESLint, Prettier and the unit suite
pass. Every story in the scanned set mounts without throwing.
## Notes for reviewers
- Stories use the `@app`/`@core`/`@portal`/`@proprietary` aliases (no
deep relative imports) and mock data-fetching components with MSW.
- Running the full suite in one go can flake on the Vite dep-optimizer;
scan in small batches (or by filter) for a stable local run.
## What
Follow-up to #7032. Makes the prepaid-bundle price
**server-authoritative** and removes the percent-coupon rounding drift,
by switching the 12-for-10 discount from a pre-made `percent_off` Stripe
coupon to an **edge-function-computed inline `amount_off` coupon**. Also
folds in Ethan's #7032 review nits.
This is a money-mechanism change, so it was verified against the Deno
tests and is ready for a V2-preview check before rollout.
## SaaS side — already on `v3` (purely additive)
The edge fn + migration were pushed **directly to `v3`** (commit
`4534ff1c1`), since the DB change is purely additive (a
backward-compatible function replacement — no table/column/data
changes):
- `create-payg-bundle-quote`: retrieves the Stripe Price for the bundle,
computes `subtotal = unit_amount x pool_credits` (falls back to
`round(unit_amount_decimal x pool_credits)`), `discount = round(subtotal
x 2 / 12)`, `total = subtotal - discount`; mints a single-use
fixed-amount coupon (`amount_off`, `duration: once`, `max_redemptions:
1`, `redeem_by = valid_until`) and applies it instead of the stored
percent coupon; persists `total` via `p_price_minor`.
- Migration `20260803000000_payg_bundle_quote_stripe_price_minor.sql`:
`payg_set_bundle_quote_stripe` gains `p_price_minor BIGINT DEFAULT NULL`
→ `price_minor = COALESCE(p_price_minor, price_minor)`.
**Deploy choreography (important):** the migration must apply **before**
the edge fn is deployed — the fn now calls the 4-arg
`payg_set_bundle_quote_stripe`. #7032's own Supabase migration is
already on `main`/`v3`.
## This PR (FE)
- **Server-authoritative price:** `bundlePriceMinor` now computes
`subtotal - round(subtotal x (granted-paid)/granted)` (round the
discount, then subtract) — identical to the edge fn — so the pre-mint
estimate matches the `amount_off` charged, and the persisted/frozen
total, to the penny (they previously diverged by a minor unit on
exact-half ties). Tie-case test added.
### Ethan's #7032 review nits
- **1** — comments in `ActivationChoiceModal` / `FreePlanView` no longer
assert the metered subscription is auto-provisioned off the saved card;
they describe it as a known, not-yet-wired follow-up.
- **2** — corrected the price-authority narrative (`stripe.ts`,
`BundleCheckoutModal`): the client-sent `p_price_minor` is a pre-mint
**display estimate only**; the edge fn overwrites `price_minor` with the
server total once the quote is minted. **Verified** the edge fn builds
the Stripe line from `bundle_price_id x pool_credits` with `amount_off`
from the retrieved Price — it never uses the client price.
- **4** — `ensureStripeQuote`'s reuse key now includes the
posture/size/pipeline ids (`buildStripeQuoteSig`), not just pool+PO, so
a same-pool sizing edit re-mints and re-persists instead of leaving
stale sizing on the row.
- **5** — `SpendLimitPicker`: a cleared field (maps to `0`) can no
longer proceed as a `$0` cap — the cap-step Continue is disabled and
`handleContinue` guards on it (empty = incomplete, distinct from the
explicit `null` "No limit").
- **6** — `"prepaid PDFs"` code fallbacks aligned to the `"prepaid
credits"` TOML (`usageMeters`, `PrepaidCapacityCard`).
## Testing
- SaaS Deno: **25/25** (coupon `amount_off == round(subtotal*2/12)`,
`p_price_minor == total` persisted, `unit_amount_decimal` fallback,
exact-half tie, zero-discount path, price/coupon failure paths).
- FE vitest: **50** billing/format tests pass; prettier + eslint clean;
tsc clean for all changed files.
- Pending: manual V2-preview check that the invoice shows a concrete
`-$X.00` discount line (labelled "12 months for the price of 10") equal
to the in-app total.
Closes the residual half of #7032 review finding #2 — once
merged/deployed, the in-app total, the persisted value, and the Stripe
invoice all agree.
---------
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
## Problem
The `build (25, saas)` CI job intermittently fails with:
```
The Daemon will expire immediately since the JVM garbage collector is thrashing.
The currently configured max heap space is '512 MiB' and the configured max metaspace is '384 MiB'.
FAILURE: Build failed with an exception.
* What went wrong:
Gradle build daemon has been stopped: since the JVM garbage collector is thrashing
```
`gradle.properties` never set `org.gradle.jvmargs`, so the daemon runs
on Gradle's 512 MiB default heap. The larger builds — the `saas` flavor
in particular, which compiles core + proprietary + saas — exhaust it
under `org.gradle.parallel=true`, and the daemon dies mid-build. It's
flaky (passes on re-run), which makes it a recurring, noisy CI failure.
## Fix
```properties
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g
```
2 GiB heap + 1 GiB metaspace gives comfortable headroom on GitHub-hosted
runners and typical dev machines, well clear of the thrash point.
One-line, repo-wide config change.
## Verification
- `./gradlew help` starts the daemon cleanly with the new args (no
malformed-arg failure).
- The real signal is CI: this branch's `build (25, saas)` should stop
OOM-ing.
Split out from #7048 (Plan & Usage) since it's unrelated build
infrastructure.
# Prepaid usage bundles
Teams on pay‑as‑you‑go can **buy a year of PDF processing up front, at a
discount** — *"12 months for the price of 10."* You pre‑buy a pool of
credits; they're spent **before** any metered billing and sit
**outside** the monthly spend limit; unused capacity expires after 12
months.
---
## What this PR delivers
**Buy → quote → invoice → pay (Stripe‑Quotes‑native).**
- A team lead sizes the pool in the calculator (persisted as a quote
row), which doubles as the quote page with a **"Download quote (PDF)"**
— the PDF is **Stripe's own rendered quote** (same mechanism procurement
uses), not an app‑generated document.
- **Finalise** turns the accepted quote into an invoice; the lead can
**download the invoice** or **pay online** (Stripe hosted invoice).
**Card and bank‑transfer / PO** are both supported (payment‑method
fork), on net terms.
- The billing page loads the in‑flight quote/invoice on open, so the CTA
resumes the right step (**View quote** / **Pay invoice to complete**)
and offers **Cancel purchase** (voids the invoice + quote and restarts).
**Prepaid is usable on its own — no subscription required.** The
entitlement gate honours a live prepaid pool in both cases:
- *Unsubscribed*: once the one‑time free grant is spent, a live pool
keeps the team **fully entitled** (all feature gates) rather than
degraded.
- *Subscribed*: a team **at/over its metered cap** but holding a live
pool stays fully entitled — prepaid draws are netted out of metered
spend, so the pool genuinely sits outside the cap.
Only when the free grant **and** the prepaid pool are both empty do
billable categories stop.
**Coordinated SaaS change (ships with this — `Stirling-PDF-SaaS` `v3`
branch):** the `invoice.paid` webhook credits the pool idempotently
(keyed on the invoice id) and settles the quote. Metered‑subscription
provisioning is best‑effort and **classified** — a permanent Stripe 4xx
(the single‑use hosted‑invoice card can't be attached) is a claimed
no‑op (HTTP 200, no retry) so Stripe doesn't redeliver forever; only
transient errors (5xx / connection / rate‑limit) retry. A failed credit
now retries rather than silently dropping a paid bundle.
### Flow
1. Lead sizes the pool and agrees to the terms → the browser sends team
+ capacity + consent, **never a price**.
2. A leader‑gated server function looks up the price and creates a
Stripe **quote** (line quantity = capacity).
3. Lead **finalises** → the quote becomes a Stripe **invoice**; download
it or pay online (card or bank transfer).
4. On `invoice.paid`, the webhook **credits the prepaid pool**
(idempotent) and settles the quote.
5. Usage then draws **free grant → prepaid pool → meter**; the pool is
usable with no subscription.
<img width="1280" height="920" alt="01-activation-fork"
src="https://github.com/user-attachments/assets/e8981dc7-809d-4fc5-bfde-71e619096b7b"
/>
<img width="1280" height="920" alt="02-calculator"
src="https://github.com/user-attachments/assets/81f6b886-1797-4c54-ba18-97d126be78e2"
/>
<img width="1120" height="600" alt="03-free-plan"
src="https://github.com/user-attachments/assets/c4057fde-d965-47dd-93e8-f2f0f612aa73"
/>
<img width="1105" height="1285" alt="04-subscribed-prepaid"
src="https://github.com/user-attachments/assets/9d1d0ef1-29d0-4bd4-9c17-ac5f68f88ca8"
/>
---
## In a follow‑up (not this PR)
1. **Authoritative price via an inline fixed‑amount coupon** *(in
progress in a separate PR).* Replace the percentage 12‑for‑10 coupon
with an edge‑function‑computed **`amount_off`** coupon: the invoice
shows a concrete "−$X.00" discount line, the total is deterministic (no
percentage‑rounding drift), and the persisted price becomes
**server‑authoritative**. Money‑mechanism change — needs validation
against the Stripe test env, so it warrants its own testable PR.
2. **Metered auto‑resume when the pool empties.** Save the paying card
at invoice time (`setup_future_usage`) for card payers → real
`charge_automatically`; a cardless `send_invoice` subscription for
bank‑transfer / PO. This makes the "processing continues at the metered
rate" promise true for everyone.
3. **Provisioning idempotency hardening** (SaaS repo). Idempotency key
on subscription creation + a conditional link RPC, so a webhook
redelivery or link‑RPC failure can't create duplicate or orphaned
subscriptions.
4. **Repo‑wide "credits" copy** across *all* of usage & billing (this PR
only makes its own additions consistent).
---
## Known edges (current state)
- **Cardless teams degrade when the pool empties.** An unsubscribed
bundle team that runs the pool dry hits DEGRADED (metered paused), not
automatic metered continuation — because no metered subscription gets
provisioned off a hosted‑invoice card. The consent copy states
processing "continues at the metered rate"; that promise is
intentionally **ahead of the mechanism** (follow‑up 2), and the 12‑month
term is the runway to deliver it. The prepaid capacity itself stays
fully usable in the meantime.
- **In‑app total vs charge can differ by ≤1¢** until follow‑up 1 lands.
The **shared approval document (the Stripe quote PDF) and the actual
invoice are already Stripe‑authoritative**; the persisted price shown
in‑app is still a front‑end estimate (percentage‑coupon rounding), so it
can differ from Stripe by a rounding cent. Resume‑time drift is fixed
(frozen to persisted); exact‑to‑the‑penny parity arrives with the
authoritative‑price follow‑up.
- **Provisioning idempotency is latent, not live.** The
duplicate/orphan‑subscription window only becomes reachable once
card‑linking (follow‑up 2) makes provisioning actually run; hardening is
tracked as follow‑up 3.
- **One job can overshoot the spend cap via a near‑empty pool.** A
subscribed team that has hit its metered cap but still holds a
*nearly‑exhausted* pool is let through (the pool overrides the cap
gate); if a job needs more than the pool has left, the pool drains to
zero and the **remainder meters**, so that single job's remainder can
bill just past the "never past your spend limit" ceiling. Bounded to one
job's overshoot and only at the pool's tail; the alternative — blocking
the job — would strand paid‑for capacity, so this is a deliberate trade.
---
## Testing
- **Java** — `EntitlementServiceTest` (18) incl.
unsubscribed‑live‑pool‑stays‑FULL,
subscribed‑over‑cap‑with‑pool‑stays‑FULL, and lazy‑read guards.
- **Frontend** — `useBundleFlowState` + `Usage` render tests; portal &
SaaS `tsc`; i18n audit; `lint:colors`; toml‑sort; prettier.
- **SaaS webhook** (`v3`) — Deno tests for terminal‑vs‑transient
provisioning classification (rate‑limit treated as retryable),
credit‑error‑retries, and an end‑to‑end no‑storm assertion on the
unusable‑card path.
*Preview:* the checkout runs in a Supabase function in
`Stirling-PDF-SaaS` (`v3`); a live V2 preview is linked in the
auto‑deploy comment below. Screenshots to be refreshed — the checkout
modal changed since the originals.
## Why
The processor/portal loads slowly because every view fetches its data on
mount with no client-side cache — navigating away and back refetches
everything, and shared data (policies, sources, roster, fleet stats) is
fetched repeatedly. This adopts **TanStack Query** so the portal caches,
dedupes, and revalidates instead.
Follows the Users-page proof-of-concept (kept as the reference A/B
example behind a dev flag); DevTools before/after confirmed revisiting a
cached view now costs zero network calls.
## What
**Shared per-resource query layer** (`portal/queries/`) — the mechanism
for both in-view and cross-view sharing:
- `keys.ts` (flavor-agnostic queryKey factory), `adapters.ts`
(`toAsyncState` → the existing `AsyncState` shape, so view bodies barely
change)
- One **base hook per endpoint**; **derived hooks**
(`usePoliciesOverview`, `useProcessorFlow`, `useOnboardingProgress`)
compose them
- The bundle functions (`fetchPolicies`, `fetchProcessorFlow`,
`useOnboardingProgress`) are decomposed into base queries — otherwise
the caches wouldn't dedupe against each other
**Migrated:** Documents, Policies, Pipelines, Sources + all of Home's
fetching cards. Mutations use `invalidateQueries` (Policies' `version`
bump removed; Source/Pipeline builders invalidate-then-navigate;
ConnectionsTab + S3 picker share one cache).
**SaaS `/team/my` collapse:** `resolveTeam()` reads through the shared
cache (`ensureQueryData`), so roster + teams resolve it once (2→1), with
a direct-fetch fallback when no provider is mounted.
`QueryClientProvider` is mounted once at the portal root (`PortalApp`),
above the router, so the cache survives navigation.
## Impact on duplicate fetches
- **In-view:** Home `/policies` ×3, `/policies/runs` ×3, `/sources` ×2,
`/v1/editor/deployment` ×2 → **1× each** per mount
- **Cross-view:** Policies / Sources / Users / EditorAdmin /
Infrastructure reuse Home's warmed cache within `staleTime` (no refetch
on navigation)
- **SaaS Users:** `/team/my` 2× → **1×**
## Testing
- Portal typecheck + SaaS typecheck, ESLint (`--max-warnings=0`),
Prettier — all green
- **224 portal tests pass** (existing component tests wrapped in a
shared `QueryClient` test provider)
- New: `queries/sharing.test.tsx` (in-view: 3 consumers → 1 fetch each;
cross-view: remount → 0 refetch) and a `/team/my` collapse assertion in
`UsersReactQuery.test.tsx`
## Notes for reviewers
- Keys are intentionally flavor-agnostic (local vs SaaS routing lives
inside the api fns), so one key addresses whichever backend the flavor
build resolves.
- `staleTime` 30s / `gcTime` 5m defaults; tier-dependent resources key
on tier.
- Users view keeps its dev flag/legacy path deliberately as the
documented reference.
# Description of Changes
Adds implicitly defined folders to the list of locations that folder
sources can look in, including the legacy watchedFolder folders, and the
server storage location (if enabled). Also adds a settings UI for
defining the list of allowed folders instead of having to manually edit
`settings.yml` (please excuse the styling, that's the standard styling
of the Processor, hoping it gets fixed by one of the styling PRs).
<img width="888" height="786" alt="image"
src="https://github.com/user-attachments/assets/cf6d0705-adcf-463c-8e80-6901a652068b"
/>
<img width="1103" height="713" alt="image"
src="https://github.com/user-attachments/assets/b7244592-249a-4149-994f-3a2b750f25f9"
/>
Follow-up to #7009, which built the theme token layer (`primitives` →
`colors` → `compat`). This PR moves the whole app onto it, removes
hardcoded colours, turns on enforcement so they can't come back, and
adds a user-selectable accent.
## What this does
- **Semantic tokens everywhere** — legacy colour aliases and raw hex are
rewritten to `--c-*` tokens (`--c-surface*`, `--c-text*`, `--c-primary`,
…). Straight rename, no visual change. Genuine literals (brand/OAuth,
colour pickers, data-viz) are left as-is.
- **Fixes missing colours** — some tokens the migration referenced were
never defined, so a few surfaces (login button, auth banners, badges,
procurement view) silently lost their colour. All defined now, and they
adapt to light/dark and the accent automatically.
- **Blocking colour lint** — CI now fails on hardcoded colours,
undefined tokens, or unreadable low-contrast status colours.
- **User-selectable accent** — light and dark each get their own accent
from Settings → Appearance, contrast-clamped so text stays legible.
"Default" keeps the standard blue.
## Still to come
Remaining inline-style hex, the legacy token-definition files
(`theme.css`, `tokens.css`), and folding `zIndex.ts` onto the dimension
tokens.
## Testing
`task frontend:check:all` green; light/dark and accent switching
spot-checked.
# Description of Changes
Multiple named **personal** API keys per user, replacing the single
opaque per-user key.
- Create (name + one-time secret), list, and revoke named keys from the
portal Infrastructure → API Keys tab. Works self-hosted and SaaS
(`X-API-KEY`).
- Per-key usage stats (today / trailing 30 days / lifetime);
API-processed documents are attributed to the specific key in the
processor's Documents feed.
- The legacy single per-user key keeps working and is lazily represented
as a named key. Rotating it revokes its migrated shadow row so the old
secret stops authenticating.
- Per-user (not per-key) rate limiting plus a per-user active-key cap,
so minting keys can't multiply the daily quota. Name-length cap;
race-safe migration and usage recording.
Keys are strictly personal: one owner, full access, no sharing.
Team-shared / scoped keys and per-key access levels were intentionally
left out of this PR to keep it small and easy to review; they can follow
as a separate, focused change.
> Note: the screenshots from the original revision showed an earlier
team-scoped design and need refreshing.
---
## 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)
- [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: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Simplifies the processor home page:
- Removes everything below the processor flow (processing status strip,
recent activity, quick actions, policy summary), leaving just the
onboarding hero and processor flow.
- Changes the policy "Set up" buttons (Security/Classification) from
primary to secondary variant.
<img width="2056" height="1047" alt="Screenshot 2026-07-20 at 7 27
56 PM"
src="https://github.com/user-attachments/assets/8625b274-b52a-47be-9052-80ac3d32dd93"
/>
## What was wrong
Opening the procurement **Schedule a call** modal had two problems:
1. **Blank the first time, works the second time.** The first open
showed nothing; closing and reopening eventually loaded Calendly.
2. **Slow to load** even when it did work.
## Why
1. Our script loader treated a script as "ready" the moment its
`<script>` tag was added to the page — not when it had actually finished
downloading. On the first open, two loads overlap (React re-runs the
effect in dev), and the second one returned "ready" too early, before
Calendly's code existed, so nothing rendered. Reopening worked because
by then the script had finished.
2. Nothing was loaded until you clicked, so the first open waited on a
cold download of Calendly's script and then its booking page.
## The fix
- Make the script loader wait for the script to **actually finish
loading**, and have overlapping loads share the same wait. This fixes
the blank-first-open (and helps every other lazy-loaded script too).
- **Warm up Calendly early**: open the connection and start fetching its
script as soon as the "Schedule a call" button appears, so the modal
opens quickly instead of downloading everything on click.
- If Calendly still can't load (e.g. blocked by an extension), show the
existing "open in a new tab" link instead of an empty modal.
## Testing
Added a unit test proving the loader only reports "ready" after the
script truly loads. Type-check, lint, and formatting all pass.
Note: I couldn't click through the live modal here (needs a linked
procurement deal running locally) — happy to do a manual open/close/open
pass before merge if you'd like.
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
# Description of Changes
This change centralizes the Java toolchain language version into a
single `buildJavaLanguageVersion` variable and reuses it across all Java
compilation tasks to ensure consistent toolchain selection.
### What was changed
- Introduced a shared `buildJavaLanguageVersion` variable derived from
the optional `javaVersion` project property, defaulting to Java 25.
- Updated the root project's Java toolchain configuration to use the
shared variable.
- Updated all subproject Java toolchain configurations to reference the
same shared variable instead of a hardcoded language version.
- Explicitly configured the `compileRestartHelper` task to use a
`javaCompiler` resolved from the same shared toolchain version.
### Why the change was made
- Eliminate duplicated Java language version definitions.
- Ensure all compilation tasks use the same Java toolchain
configuration.
- Allow the `javaVersion` project property to consistently affect the
root project, subprojects, and the restart helper compilation task.
- Simplify future Java version upgrades by requiring changes in only one
location.
---
## 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.
# Description of Changes
This PR resolves deprecation warnings and addresses compiler errors
resulting from the transition to Spring Security 7.x., as well Jackson 3
and general Java.
* Replaced all usages of `asText()`/`isTextual()` with
`asString()`/`isString()` in JSON parsing logic across
`FormPayloadParser.java`, `ApiEndpoint.java`, and
`KeygenLicenseVerifier.java` to ensure consistent and type-safe string
* Updated `CustomSaml2AuthenticatedPrincipal` to implement
`Saml2ResponseAssertionAccessor`, added a `responseValue` field, and
provided additional getter methods and type-safe attribute accessors.
* Switched from constructing `URL` objects directly from strings to
using `URI.create(...).toURL()` in `UIDataTessdataController.java` for
improved URL safety and parsing.
<!--
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
- [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.
# Description of Changes
Paired with https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/320
Fixes the following bugs we found when testing the SaaS release:
- Existing users couldn't join teams - this was because they were the
last leader of their team, so it'd be left orphaned). Users now have a
'home team', which can have no members if they join another team, but
they can then go back to it later.
- Existing leaders didn't have unlimited seats - `saas_teams_extensions`
had no row for them, so the app fell back to `max_seats=1`. The
migration script fixes it.
- Members without Processor access could still access the Processor - It
was just checking "Are you the leader of **any** team", instead of the
user's active team.
## What this PR does
Removes the **Flyway** migration system from the SaaS build:
- drops `flyway-core` + `flyway-database-postgresql` from
`app/saas/build.gradle`
- deletes all `Vxx__*.sql` files under
`app/saas/src/main/resources/db/migration/`
- removes the `spring.flyway.*` config from
`application-saas.properties`
- clears the now-inert `SPRING_FLYWAY_ENABLED` override and stale Flyway
comments in `testing/compose/docker-compose-saas.yml`
`ddl-auto` is **left on `update`** (unchanged) — that's a separate
decision (see "Not in this PR").
## Why — Flyway never actually ran anywhere
From a full schema-management review
(`notes/FLYWAY_MIGRATION_REVIEW.md`), verified against the live
databases:
- **Prod & dev (v3):** no `flyway_schema_history` table exists in any
schema → Flyway has never executed. Schema is authored by the Supabase
migrations in `Stirling-PDF-SaaS` and applied by that repo's **GitHub
integration** (merge to `main` → prod).
- **Tests:** the saas module has zero `@SpringBootTest`; the only
real-DB integration tests (in `proprietary`) use `ddl-auto=create-drop`
and don't have Flyway on the classpath.
- **The mock-DB harness** (`testing/compose/docker-compose-saas.yml`,
PAYG cucumber) *explicitly disabled* Flyway, because the `Vxx`
migrations can't run against a clean Postgres — they assume Supabase has
already provisioned `users`/`teams` (`V2` ALTERs `users`, `V5`
references `teams`).
So Flyway was dead weight, and its 4 duplicate versions
(`V25/V26/V30/V31`) were a latent trap: re-enabling it would crash boot
on the collision. Everything it contained (schema + seeds like the
default pricing policy) is already mirrored by the Supabase migrations,
and by `saas-seed.sql` for the cucumber stack.
## ⚠️ Required follow-up (item #1) — capture the Flyway-only tables into
Supabase migrations
**This is documentation of the next step, not done in this PR.**
Seven tables were defined in Flyway with **no matching Supabase
migration**. They exist in prod today only because `ddl-auto=update`
created them from their entities. Before `ddl-auto` is ever tightened to
`validate` (see #3 below), and so any fresh Supabase branch is complete,
they must be added as Supabase migrations in
`Stirling-PDF-SaaS/supabase/migrations/`.
**Capture (CREATE) — 6 live tables** (definitions are visible in the
deleted files in this PR's diff):
| Table | Source (deleted here) | Backing entity |
|---|---|---|
| `resource_grants` | `V25__resource_grants.sql` | `ResourceGrant` |
| `integration_configs` | `V26__integration_configs.sql` |
`IntegrationConfig` |
| `policy_sources` | `V22__policy_engine_tables.sql` | `SourceEntity` |
| `policy_source_doc_counts` | `V23__policy_source_doc_counts.sql` |
`SourceDocCountEntity` |
| `policy_source_doc_totals` | `V23__policy_source_doc_counts.sql` |
`SourceDocTotalEntity` |
| `saas_user_extensions` | `V9__saas_user_team_extensions.sql` |
`SaasUserExtensions` |
Write them as `CREATE TABLE IF NOT EXISTS stirling_pdf.<name> (...)`
(idempotent — no-op against the existing prod/v3 tables). Preserve
column types/defaults/constraints from the deleted `Vxx` files.
**Drop (do NOT recreate) — 1 orphaned table:**
- `classification_labels` — created by `V30` and dropped by `V39` within
Flyway; its `ClassificationLabel` is now a plain `record`, not a JPA
entity. It lingers in prod only because Flyway's `V39` drop never ran.
The follow-up should emit a `DROP TABLE IF EXISTS
stirling_pdf.classification_labels` (mirroring `V39`'s intent), after
confirming nothing reads it.
## Not in this PR (deliberately)
- **#3 — flip saas `ddl-auto` `update` → `validate`.** Held pending team
confirmation; it has boot-risk and should be gated by a CI
"validate-boot against a fresh Supabase branch" first. Self-hosted stays
on `update` regardless.
- **#4 — `billing_subscriptions` split-brain** (prod `public` has 23,164
rows, `stirling_pdf` has 0, Java reads the empty one). Tracked
separately.
## Verification
- `:saas:compileJava` succeeds with Flyway removed (no code imports
`org.flywaydb.*`).
- No test or ArchUnit rule references Flyway or the migration files.
- No runtime/data impact: Flyway never ran against any live database.
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Bumps com.diffplug.spotless from 8.5.0 to 8.8.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)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
# Description of Changes
This change updates multiple frontend dependencies to their latest
compatible releases by refreshing the `package-lock.json`. The update
includes dependency version bumps across the frontend toolchain and
runtime libraries while removing obsolete transitive dependencies
introduced by newer package versions.
### What was changed
- Updated Babel packages to the latest 7.29.x releases.
- Upgraded Vite from 7.3.2 to 7.3.6.
- Upgraded Vitest packages from 3.2.4 to 3.2.6.
- Updated React Router and React Router DOM from 7.13.2 to 7.18.1.
- Updated Axios from 1.15.0 to 1.18.1.
- Updated PostHog packages to newer releases.
- Updated additional frontend dependencies including Preact, Web Vitals,
FormData, HasOwn, Brace Expansion, and other transitive packages.
- Removed obsolete OpenTelemetry and Protobuf-related transitive
dependencies that are no longer required by the updated dependency
graph.
- Refreshed the lockfile to reflect the new dependency tree.
### Why the change was made
- Keep frontend dependencies up to date.
- Incorporate upstream bug fixes, performance improvements, and security
updates.
- Reduce unnecessary transitive dependencies where newer package
versions no longer require them.
- Maintain compatibility with the current frontend toolchain.
---
## 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.
Bumps ubuntu from `c4a8d55` to `4fbb8e6`.
> **Note**
> Automatic rebases have been disabled on this pull request as it has
been open for over 30 days.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
## Summary
Follow-up polish to the home **PDF Processor** flow visualiser (base
landed in #7014). Tunes the particle animation to react to real volume
and adds a Storybook playground to tune it live.
## Changes
### Particle emission — `useFlowParticles.ts`, `flowTypes.ts`
- Emission rate now scales ~linearly with a source's 24h volume (**2×
volume ≈ 2× dots**) instead of the flat `rate / 86400 × SPEED`, capped
at **one dot / 250ms** (`MAX_EMIT_PER_SEC`) for busy sources (~≥
800/24h).
- Bounded the spread so the busiest source emits at most **5×** the
quietest (`EMIT_SPREAD_CAP`) — a dominant source can't starve the
others.
- Wider departure jitter (`[0.4×–1.7×]` the mean, still floored at the
per-source min-gap) and ~**2× faster travel** so the flow reads
livelier.
- Replaced the single `SPEED` constant with `EMIT_DIVISOR` /
`MAX_EMIT_PER_SEC` / `EMIT_SPREAD_CAP`. Weighted round-robin outcome
split is unchanged (e.g. 3 failed / 30 delivered → ~1 red dot in 11).
### Storybook Playground — `ProcessorFlow.stories.tsx`,
`ProcessorFlow.tsx`
- New **Playground** story with live controls: per-input rate sliders,
the delivered/failed split (drives the red-dot ratio), and a
Classification-active toggle.
- Added an optional `dataOverride` prop (prod-inert testing seam) so the
story renders a supplied flow model instead of fetching — changes apply
instantly.
### Housekeeping
- Condensed authored comments across the feature to ≤ 2 lines.
## Testing
- `task frontend:check` green — lint, typecheck, 1353 tests.
- Verified emission numerically (proportionality, 250ms ceiling, 5×
spread cap) and confirmed live animation in a focused Storybook tab.
# Description of Changes
There's currently a column size inconsistency between the SaaS v3 DB and
the main Java code which causes the backend to fail to start up when
connected to a fresh DB. This is because the column previously was width
255, but now it's officially width 50, but the Java type is still
implicitly `varchar(255)` because there's no length attribute. If it's a
fresh DB, Postgres throws an error that it can't expand the column (this
doesn't error on an existing DB because the column is already wide
enough behind the scenes).
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Small follow-up to #6957 addressing the three non-blocker findings from
its review. **Draft / stacked on #6957** — the diff shows #6957's
changes until it merges, then auto-narrows to just these 5 files. Mark
ready + rebase onto `main` once #6957 lands.
### 1. avg-per-PDF no longer blends unsynced units over synced-only docs
`avgCostMinor` now divides **synced** units (`spendUnitsThisPeriod`) by
synced docs, so numerator and denominator cover the same population.
Combined-billing `pendingUnits` (units-only, no doc count) previously
inflated the average for linked-instance teams. The "meter units" figure
still shows synced+pending (total current usage) — only the *average* is
synced-only.
### 2. Empty-state: unsynced-only reads cleanly
When `docs == 0` but there are pending meter units (combined-billing,
nothing synced yet), the card showed a bare **"0 PDFs"** headline with a
count-less summary and no split. It now shows a **"{n} meter units
pending sync from linked instances"** note instead. New `unitsPending`
i18n key + a `UnsyncedOnly` story. (Only reachable on the
combined-billing path; pure-SaaS teams are unaffected.)
### 3. uniquePdfs wording is now accurate
`document_fingerprint` is a hash of a charge's whole **input set**, so
the same file reused across *different* groupings (standalone, then
later in a merge `{A,B}`) counts per grouping — a close approximation of
"unique PDFs", exact for the single-input common case. Softened the FE
type doc + the `WalletLedgerEntry.document_fingerprint` javadoc to say
so (no behaviour change; counting model unchanged).
### Verification
FE typecheck / test / lint / format all clean; `:saas:compileJava`
green. No behaviour change beyond #1 (avg) and #2 (empty-state copy); #3
is doc-only.
# Description of Changes
- Moved the signature status-to-color mapping from `signatureStatus.ts`
to `pdfPalette.ts`.
- Removed the PDF palette dependency from the pure signature status
calculation module.
- Updated the PDF signature report to import the color mapping from the
palette module.
- Prevented signature status unit tests from initializing
browser-dependent CSS colors unnecessarily.
- Eliminated fallback warnings caused by unavailable theme CSS variables
in the Vitest environment.
- Preserved the existing signature status calculation and PDF report
color behavior.
```sh
[frontend:test:editor] stderr | src/core/hooks/tools/validateSignature/utils/signatureStatus.test.ts
[frontend:test:editor] CSS variable --pdf-light-header-bg not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-accent not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-text-primary not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-text-muted not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-box-bg not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-box-border not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-warning not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-danger not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-success not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-neutral not found, using fallback
```
---
## 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.
### Motivation
- Automatically detect and mark pull requests that have merge conflicts
so maintainers can triage them quickly.
- Ensure both new and existing open PRs are covered by running on PR
events, a schedule, and manual dispatch.
### Description
- Add workflow `: .github/workflows/pr-conflict-labeler.yml` that
triggers on `pull_request_target`, a recurring `schedule`, and
`workflow_dispatch` for manual runs.
- The job uses the repository `stirling-bot`
(`.github/actions/setup-bot`) and `actions/github-script` to poll
`pull.mergeable` until GitHub computes mergeability and then add or
remove the `has conflicts` label when `mergeable === false &&
mergeable_state === 'dirty'`.
- The workflow idempotently ensures the `has conflicts` label exists
(creates it if missing) and the repository label config `
.github/labels.yml` is updated to include `has conflicts` with an
appropriate color and description.
### Testing
- Parsed both ` .github/workflows/pr-conflict-labeler.yml` and `
.github/labels.yml` with Ruby `YAML.load_file`, which succeeded.
- Installed and ran `actionlint` via `go install
github.com/rhysd/actionlint/cmd/actionlint@latest` and validated the new
workflow file with `actionlint`, which succeeded.
------
[Codex
Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a58b7061c2c8325b024980a4af8f632)
# Description of Changes
This change upgrades the project from Gradle **9.6.0** to **9.6.1**
across all build environments to keep the toolchain consistent and
aligned.
### What was changed
- Updated the Gradle Wrapper to **9.6.1**.
- Updated all GitHub Actions workflows using
`gradle/actions/setup-gradle` to install Gradle **9.6.1**.
- Updated all Docker build stages to use the `gradle:9.6.1-jdk25` image
with the corresponding pinned image digest.
- Regenerated the Windows Gradle wrapper script, resulting in minor
comment updates (`Gradle` → `gradlew`).
### Why the change was made
- Keep the project up to date with the latest Gradle patch release.
- Ensure all local, CI, and Docker build environments use the same
Gradle version.
- Benefit from the latest bug fixes and maintenance improvements
included in Gradle 9.6.1.
---
## 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.
# Description of Changes
- Upgraded google-java-format from 1.28.0 to 1.35.0.
- Removed the broad `suppressLintsFor` workaround for the
`google-java-format` step.
- Ensured the shared `gradle/spotless.gradle` configuration is
recognized by the relevant CI path filters and repository automation.
- Kept the shared formatter configuration available to all backend
modules.
- Verified that google-java-format 1.35.0 runs successfully on JDK 25
for the Common, Core, and SaaS modules.
- Confirmed that the previous claim about a general Guava 32.x crash on
JDK 24/25 no longer justifies suppressing all formatter lint failures.
### Verification
Verified with Temurin JDK 25.0.3 and google-java-format 1.35.0. The
formatter still depends on Guava 32.1.3-jre, and no `suppressLintsFor`
configuration is present.
```bash
./gradlew \
:common:spotlessJavaCheck \
:stirling-pdf:spotlessJavaCheck \
--rerun-tasks
```
Result:
```text
> Task :common:spotlessJava
> Task :common:spotlessJavaCheck
> Task :stirling-pdf:spotlessJava
> Task :stirling-pdf:spotlessJavaCheck
BUILD SUCCESSFUL in 26s
4 actionable tasks: 4 executed
```
Using `--rerun-tasks` ensured that the formatter was executed and that
the result did not come from the Gradle task cache.
---
## 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: Copilot <223556219+Copilot@users.noreply.github.com>
# Description of Changes
This PR fixes PNG signature application issues in the PDF signing
workflow.
## What was changed
- Reworked signature application to create locked and printable PDFium
stamp annotations with dedicated appearance streams.
- Removed the use of `FPDFPage_GenerateContent()` from the signature
workflow.
- Preserved the signature's original position and dimensions when
converting from the viewer's top-left coordinate system to PDF
coordinates.
- Added CropBox-aware coordinate conversion for PDFs whose visible page
origin differs from the MediaBox origin.
- Improved signature image extraction to handle internal EmbedPDF asset
references and nested image data.
- Refactored PDFium bitmap creation so image objects can safely be
transferred to annotations.
- Corrected PDFium bitmap ownership and cleanup to prevent duplicate
destruction.
- Added a PDFium WASM integration test covering:
- Existing page-content preservation
- Stamp appearance generation
- Signature coordinates and dimensions
- Printable, read-only, and locked annotation flags
- Persisted image data taking precedence over internal asset references
## Why the change was made
Applying a PNG signature previously regenerated the complete page
content through PDFium. This could corrupt existing vector or font-based
page elements, including the university logo reported in the linked
issue.
The previous coordinate conversion also relied only on the page height
and did not account for CropBox offsets, allowing the applied signature
to move from its preview position.
Creating a PDFium stamp annotation with its own appearance stream avoids
regenerating existing page content while retaining the selected
signature position and size.
Closes#7083
---
## 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.
# Description of Changes
### What was changed
- Updated `BASE_VERSION` references in:
- `docker/backend/Dockerfile`
- `docker/embedded/Dockerfile`
- `docker/embedded/Dockerfile.fat`
- Pinned `stirlingtools/stirling-pdf-base:1.0.2` to a specific SHA256
digest.
- Pinned the `eclipse-temurin:25-jre-noble` image used in the
`jar-extract` stage to a specific SHA256 digest.
- Pinned the `ghcr.io/astral-sh/uv:python3.13-bookworm-slim` image in
`engine/Dockerfile.dev` to a specific SHA256 digest.
- Removed reliance on mutable image tags alone for these build stages.
### Why the change was made
- Ensure deterministic and reproducible Docker builds.
- Prevent unexpected changes caused by upstream image tag updates.
- Improve supply chain integrity by explicitly defining the exact image
artifacts used during builds.
- Align container build practices with security and compliance
recommendations.
---
## 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.
# Description of Changes
- What was changed
- Fixed an invalid HTML nesting issue in `AdminGeneralSection` by
changing the affected Mantine `Text` wrapper from the default `<p>`
element to `component="div"`.
- This prevents a `<div>` from being rendered inside a `<p>` when the
`Group` for the "Logo Style" label is displayed.
- Why the change was made
- Firefox reported a hydration error in the settings modal because the
rendered DOM was invalid.
- The warning was triggered in the Admin General settings section and
affected the settings modal experience.
Firefox 152.0.3 (64-Bit)
```
In HTML, <div> cannot be a descendant of <p>.
This will cause a hydration error.
...
<AdminGeneralSection>
<div className="settings-s...">
<@mantine/core/Stack gap="lg" className="settings-s...">
<@mantine/core/Box ref={null} className="settings-s..." style={{...}} variant={undefined}>
<div ref={null} style={{...}} className="settings-s..." data-variant={undefined} data-size={undefined} ...>
<LoginRequiredBanner>
<div>
<@mantine/core/Paper withBorder={true} p="md" radius="md">
<@mantine/core/Box ref={null} mod={[...]} className="m_1b7284a3..." style={{...}} variant={undefined} ...>
<div ref={null} style={{...}} className="m_1b7284a3..." data-variant={undefined} data-size={undefined} ...>
<@mantine/core/Stack gap="md">
<@mantine/core/Box ref={null} className="m_6d731127..." style={{...}} variant={undefined}>
<div ref={null} style={{...}} className="m_6d731127..." data-variant={undefined} ...>
<@mantine/core/Text>
<div>
<div>
<@mantine/core/Text size="sm" fw={500} mb={4}>
<@mantine/core/Box className="mantine-fo..." style={{...}} ref={null} component="p" ...>
> <p
> ref={null}
> style={{--text-fz:"var(--mant...",--text-lh:"var(--mant...",marginBottom:"calc(0.25r...", ...}}
> className="mantine-focus-auto m_b6d8b162 mantine-Text-root"
> data-variant={undefined}
> data-size="sm"
> size={undefined}
> >
<@mantine/core/Group gap="xs">
<@mantine/core/Box className="m_4081bf90..." style={{...}} ref={null} ...>
> <div
> ref={null}
> style={{--group-gap:"var(--mant...",--group-align:"center",--group-justify:"flex-start", ...}}
> className="m_4081bf90 mantine-Group-root"
> data-variant={undefined}
> data-size={undefined}
> size={undefined}
> >
...
...
...
...
react-dom-client.development.js:2605:19
```
---
## 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.
## What
Adds a globe toolbar to Storybook so any story can be previewed in all
42 supported languages (the i18n init already on `main` was
English-only).
## How
Bundles every locale's `translation.toml` via a `?raw` glob into i18next
resources, and switches language on toolbar change. RTL locales (`ar`,
`fa`) flip `document.dir`.
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
### Motivation
* The repository uses per-layer licensing and the
`frontend/editor/src/portal-saas/` layer existed without an explicit
license entry in the root `LICENSE`, so the layered licensing reference
needed to be added for clarity.
### Description
* Add a new `frontend/editor/src/portal-saas/LICENSE` containing the
same "Stirling PDF User License" used by adjacent non-MIT layers and
update the top-level `LICENSE` to list
`frontend/editor/src/portal-saas/` as covered by that file.
### Testing
* Verified with `diff -u frontend/editor/src/portal/LICENSE
frontend/editor/src/portal-saas/LICENSE` and `test -f
frontend/editor/src/portal-saas/LICENSE`, and attempted `task
frontend:check` which could not run in this environment because `task`
is not installed.
------
[Codex
Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a59f78f2f8483258cbe914b84126346)
### Motivation
- Prevent concurrent GitHub Actions jobs from attempting to reserve the
same `setup-uv` cache key and failing with "Unable to reserve cache ...
another job may be creating this cache" when multiple workflows run at
once.
- Target workflows that enable `setup-uv` caching and have run
concurrently in CI: pre-commit, check-generated-models, ai-engine, and
sync_files_v2.
### Description
- Added a `cache-suffix` value to the `astral-sh/setup-uv` step in
`.github/workflows/pre_commit.yml`,
`.github/workflows/check-generated-models.yml`,
`.github/workflows/ai-engine.yml`, and
`.github/workflows/sync_files_v2.yml` to create unique cache keys
(`pre-commit`, `generated-models`, `ai-engine`, `sync-files`).
- No behavior changes beyond isolating the uv cache keys per-workflow
and no other workflow steps were modified.
### Testing
- Ran `git diff --check` which completed with no reported whitespace or
index issues.
- Verified each modified workflow is valid YAML by loading them with a
Ruby YAML parser which succeeded for all four files.
- Attempted to run `task --list` to exercise the Taskfile locally but
`task` is not installed in this environment so that check could not be
executed.
------
[Codex
Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a58b399a33083259d65e0614fcc35d2)
# Description of Changes
Collapses the portal admin-roster endpoint (`getAdminSettingsData`,
`/api/v1/proprietary/ui-data/admin-settings`) from a per-user N+1 into a
constant set of queries, and adds the missing
session/user/team-membership indexes.
**Verified on H2 and real Postgres 16, 2,000-user roster:** 10,601 → 7
SQL statements, 600 → 0 writes-during-a-GET, O(N) → O(1). Portal-access
resolution is proven equivalent to the per-user check (parity test), and
a scaling guard fails the build if the endpoint ever regresses.
Also in scope (same controller / session subsystem): `getLoginData`
counts instead of loading the whole user table; `getTeamDetailsData`
fetch-joins authorities; `SessionScheduled` uses one bulk expire + a
bounded purge.
Behaviour note: the roster "active" flag now reflects *any* live session
(a strict superset of the old "newest session only") — no user who was
active is ever shown inactive.
---
## Checklist
### General
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Testing
- [x] I have run backend `task check` (spotless + full backend test
suite) — all green
- [x] I have tested my changes locally (before/after benchmark on H2 +
Postgres)
## Description
The processor home-hero and download-editor modal "Open in browser"
buttons were meant to open the editor in a new tab, but they used
`window.location.href = EDITOR_URL`, which replaces the current tab.
This switches both to `window.open(EDITOR_URL, "_blank",
"noopener,noreferrer")`, matching the existing behaviour of the "Open"
button in `EditorStatusCard.tsx`. The `noopener,noreferrer` flags mirror
that same call and prevent the new tab from getting a `window.opener`
reference.
## Changes
- `WelcomeBanner.tsx` — home-hero "Open in browser" CTA
- `DownloadEditorModal.tsx` — download modal "Open in browser" CTA
## Notes
`EDITOR_URL` can resolve to a same-origin path when the editor is the
same SPA, so opening in a new tab triggers a full page load of the
editor app. This is the expected behaviour for "Open in browser".
# Description of Changes
- Patch CVEs in engine Python dependencies (43 alerts): `starlette`
1.3.1, `cryptography` 49.0.0, `pyjwt` 2.13.0, `urllib3` 2.7.0, `aiohttp`
3.14.1, `python-multipart` 0.0.32, `langchain-core` 1.4.8, `langsmith`
0.9.1, `authlib` 1.7.2, `requests` 2.34.2, `idna` 3.18, `pytest` 9.1.1,
`pygments` 2.20.0, `pydantic-settings` 2.14.2
- Cap `pydantic-ai` `<2.0.0` and bump to 1.107.0 (1.99.0 patches
CVE-2026-46678; 2.0 is a separate major migration)
---
## Checklist
### General
- [x] 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)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs (if functionality has heavily
changed)
- [ ] I have read the section Add New Translation Tags (for new
translation tags only)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally
## What
CI cost/routing cleanup. Four changes, each reversible with no code
deleted.
### 1. Disable Depot repo-wide (reversible)
Depot ran on trusted (non-fork) triggers via the `is_fork` output of
`_runner-pick.yml`, driving both the `depot-*` runner selection and the
Depot docker build actions. It's now disabled everywhere behind a single
kill-switch:
- `_runner-pick.yml` gains a dedicated `use_depot` output, forced
`false` via `DEPOT_ENABLED=false`. `is_fork` stays truthful for trust
gating (e.g. `build-enterprise` skipping on forks).
- All `runs-on:` and `USE_DEPOT:` expressions now key off `use_depot`,
so every job falls back to `ubuntu-latest` + buildx.
- `settings.gradle` Depot remote build cache (`cache.depot.dev`) gated
behind `depotCacheEnabled = false`.
**Switch back on:** set `DEPOT_ENABLED=true` in `_runner-pick.yml` (and
`depotCacheEnabled = true` in `settings.gradle`). Depot then reactivates
on trusted triggers exactly as before.
### 2. arm64 PR docker build only on Dockerfile changes
`test-build-docker.yml` was building `linux/amd64,linux/arm64/v8` on
every PR matching the broad `project` filter. With Depot off, the arm64
leg runs under slow QEMU emulation on every code PR. New `dockerfiles`
path filter (`docker/**/Dockerfile*`) gates the arm64 leg: normal code
PRs build amd64 only; PRs that touch a Dockerfile still build amd64 +
arm64. arm64 is still fully exercised on the base-image publish and on
release.
### 3. Tauri PR build -> Linux only, unsigned, deb-only
The PR path built the full 3-OS matrix (Windows + macOS-universal +
Linux), plus the flaky Linux AppImage pass (#6127). PRs now build Linux
only (fastest + cheapest to compile) via a new `minimal` input on
`tauri-build.yml`: Linux deb only, no rpm, no AppImage. The full signed
multi-OS matrix still runs on release, and nightly still warms the Rust
cache with all-OS defaults (unchanged).
Tradeoff: Windows/macOS desktop build breaks are caught by nightly
(all-OS) rather than the introducing PR.
### 4. CI self-testing routing
Editing `build.yml` only matched the `project` filter, so a change to
how e2e / enterprise / tauri / engine jobs are dispatched didn't
actually run those jobs. Added a `ci` anchor (`build.yml` +
`.github/config/.files.yaml`) that every job-gating area filter now
includes, so editing the router or the filter config runs every job.
Also added the orphaned reusable workflows (`e2e-*`,
`frontend-validation`, `docker-compose-tests`, `test-build-docker`,
`check-openapi`, `check-licence`) to their area filters so editing a
reusable workflow self-tests.
## Validation
- All workflow YAML + `.files.yaml` parse; anchor resolution verified
(every job-gating filter resolves to include the `ci` paths).
- Gradle evaluates `settings.gradle` cleanly; `spotlessGradleCheck`
passes.
# Description of Changes
Removes the feature flags for enabling policies on both the backend and
frontend. We shouldn't be releasing another self-hosted release that
doesn't include policies, so it makes sense to do this now. Builds that
don't have the Processor will just not run policies because they won't
have any. Beyond that, the API should always be available, but checks
whether the user actually has the entitlements to run policies (whether
they have credits/a payment method available)
# Description of Changes
The policies stored in the DB are currently encrypted at rest, because
one version in the past included S3 keys. These are now stored properly
in the credentials system and I've manually removed the only policy that
used S3 (it was very recently released). Since there's no S3 (or other)
credentials in the policies stored JSON now, we might as well just
decrypt them. This PR pairs with #7034 to fix the issues - #7034 makes
it resilient to crashing when attempting to load encrypted JSON that's
been encrypted with the wrong key, and this makes it so if it does load
any encrypted policies, they'll be re-saved decrypted, so we should have
a vanishingly small number of encrypted policies over time.
## What this does
Consolidates the frontend's colour/theme system into a small,
well-defined token layer and reworks the theme picker. The goal was a
minimal, scalable set of semantic tokens that the editor **and** the
Processor/portal (and Storybook) all share, plus a theme model that's
easy to reason about.
## Token architecture (`core/theme/`)
A four-file layer, imported once via `index.css`:
| File | Role |
|---|---|
| `primitives.css` | The raw palette — the **only** place literal
colours live (neutral ramps `--p-gray-*`/`--p-zinc-*` + status hues). |
| `colors.css` | ~21 semantic `--c-*` tokens (surfaces, text, borders,
primary, status) mapped from primitives per theme. **Reference these.**
|
| `compat.css` | Legacy names (`--bg-*`, `--text-*`, `--color-*`)
aliased onto `--c-*` via `:root:root` so ~200 existing files keep
working. |
| `dimensions.css` | All non-colour tokens (spacing, radius, z-index,
type, motion) — single source, resolving prior collisions. |
A blocking linter (`scripts/lint/theme-lint.mjs`, run in
`frontend:lint`) enforces "literals only in `primitives.css`" within
`core/theme/`, and has a non-blocking WCAG contrast report. See
`core/theme/README.md`.
## Theme model
- **Mode** (`light` / `dark` / `system`) and **accent** are independent.
Each mode has its own accent (`lightPrimary` / `darkPrimary`).
- The editor is always `data-app-theme="custom"`; `ThemeProvider`
injects the accent as `--user-primary` and sets `data-accent`.
- **Two accent states:**
- A **colour** (preset or custom hex) → tints every surface that hue
(whole-app theming).
- The **`default`** sentinel → neutral surfaces (white/grey light, zinc
black/grey dark) with blue buttons, no tint. (`data-accent="default"`
opts surfaces out of the tint.)
- Accent contrast guardrails (`utils/customPrimary.ts`): lightness
clamps so an accent can't collapse into the base, a contrast-picked
on-primary foreground, and an accent-as-foreground variant so accent
text is never dark-on-dark.
## Theme picker (Settings → General)
- 3×5 grid: a distinct **Default** icon chip (not a colour) + 14 curated
accents, in a dropdown per mode.
- **Custom** colour via the shared `ColorInput`, with a live gamut clamp
(`clampValue`) that refuses white/grey/black — the picker handle sticks
at the boundary and preserves the working hue at achromatic extremes.
- "Restore theme to default" resets both modes.
## Other
- Dark mode is a true neutral zinc (no navy "midnight" tint); the
Mantine dark ramp and Tailwind dark channels were neutralised to match.
- Pre-paint inline script in `index.html` applies theme + accent before
first paint (no FOUC); portal and editor now share the same
`preferences.theme` source of truth.
- High-visibility surfaces migrated to tokens (FAB, landing upload
buttons, portal hero banners); scattered per-component colour swaps were
intentionally **left for a follow-up** to keep this PR focused.
## Testing
- `task frontend:check:all` (typecheck all variants + eslint + prettier
+ colour-lint) green.
- Verified light/dark, default vs tinted accents, and the custom clamp
via computed styles in the dev preview.
> Note: the `prerender-og` build step failing in the e2e/deploy jobs is
unrelated to this diff — it's in `vite.config.ts` (untouched here) and
builds cleanly locally.
## Overview
Adds a **Classification policy** to the processor's policy catalogue,
set up the same way as the Security policy. This moves classifier
configuration out of the editor (where the labels UI landed in #6898 and
was then removed with the rest of the editor's policy-management surface
in #6932) and into the processor, which is now the single place policies
are configured.
## What it does
- **Classification card** in the processor policy catalogue. Always
shown, but **setup is locked until the backend reports the AI engine is
on** — so admins can see the capability they're missing rather than it
being hidden entirely.
- **Setup wizard** mirrors Security: the workflow step shows the team's
**classification label editor** (reused
`LabelsEditor`/`LabelsEditorModal` — add box, chip grid, per-label icon
picker, import/export, reset) instead of tool toggles, since classify is
a single non-configurable step.
- On enable, the team's label vocabulary is **seeded with the 268
built-in defaults** (clobber-safe: only when the team has none). On
upload the document is classified against the team's labels and tagged;
on SaaS with the engine on, files group by category in the editor
sidebar.
## Reuse & consolidation
- Reuses the existing labels table, `labelsFile` helpers, and default
vocabulary. Labels read/write through the processor's own
`apiClient.local` (not the editor's axios client) so auth/base routing
stays explicit; the wire shape is shared.
- Consolidates policy-category icons into a shared, **id-keyed**
`policyCategoryIcon` util (outline glyphs) used by both the editor and
the processor, replacing the processor's emoji-glyph map (and the stray
`schedule` key that rendered a bare dot).
## Testing
- `task frontend:typecheck:{core,proprietary,portal}`,
`frontend:lint:eslint`, `frontend:test` (156 files / 1305 tests) — all
green.
- Verified in Storybook: the Classification card renders, the setup
wizard shows the label editor (268 defaults), and the full labels editor
opens with icons/import/export/reset. Added an MSW handler for the
app-config + labels endpoints and a `Classification` wizard story.
## Notes for reviewers
- The AI-engine gate reads the public `/api/v1/config/app-config`;
classification labels use `/api/v1/classification/labels` (team-scoped,
team-lead/admin-gated, `policies.enabled`); the classify step hits
`/api/v1/ai/tools/classify-and-label` — all pre-existing backend from
#6898.
- Known parity behavior (matches the editor hook): a transient failure
loading team labels falls back to showing the defaults; not changed here
to avoid diverging the two hooks.
# Description of Changes
The Policies page and all the frontend logic for running Policies is not
making use of the bidirectional type mappings that we now have to safely
convert from frontend to backend param models and vice versa. This
changes the way we track the types throughout so we use the mappings
properly.
Because of this, the Add Watermark settings in Policies now actually
pre-populate with the defaults instead of with nothing like they
previously did.
<img width="791" height="725" alt="image"
src="https://github.com/user-attachments/assets/cbdf4ae0-35af-4792-bf64-89216e48d304"
/>
# Description of Changes
Some of the tool settings make use of editor preferences indirectly, but
the Processor never gets that provider, so it crashes when trying to
load them.
Merges the `hotfix/v2.14.2` branch into `main`.
on the hotfix branch:
### What this actually changes on `main`
- **Version bump 2.14.1 → 2.14.2** `build.gradle`, `tauri.conf.json`,
both AUR `PKGBUILD`s, and the two `serverExperienceSimulations.ts`
test-config files.
- **Fix Postgres user settings for some users** removes `@Lob` from
`User.java that broke settings for some Postgres users.
- **Release workflow: stop msiexec hang in Windows signature verify**
---------
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: LFdev <146497073+LFd3v@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
# Description of Changes
Redesign S3 connections based on feedback from #6948. Also redesigns the
UI for Sources to make them more like the Pipelines page which improves
UX quite a bit. There's still plenty more UI/UX work for Sources and S3
but moving in the right direction.
# Description of Changes
- Replaced the Tauri application icon with an RGBA-formatted PNG.
- Added a root `.imgbotconfig` that excludes the Tauri icon from
automatic image optimization.
- Fixed the `desktop:test` compilation failure caused by
`tauri::generate_context!()` rejecting the previous non-RGBA icon.
- Prevented ImgBot from potentially converting the icon back to an
unsupported indexed PNG while optimizing its file size.
- Verified that the current icon uses PNG Color Type 6 (`Truecolour with
alpha`).
```sh
[desktop:test] error: proc macro panicked
[desktop:test] --> src/lib.rs:202:12
[desktop:test] |
[desktop:test] 202 | .build(tauri::generate_context!())
[desktop:test] | ^^^^^^^^^^^^^^^^^^^^^^^^^^
[desktop:test] |
[desktop:test] = help: message: icon /Users/runner/work/Stirling-PDF/Stirling-PDF/frontend/editor/src-tauri/icons/icon.png is not RGBA
[desktop:test]
[desktop:test] error: could not compile `***-pdf` (lib) due to 1 previous error
[desktop:test] warning: build failed, waiting for other jobs to finish...
[desktop:test] error: could not compile `***-pdf` (lib test) due to 1 previous error
task: Failed to run task "desktop:test": exit status 101
Error: exit status 101
```
---
## 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.
# Description of Changes
Policies can currently throw when calling redact:
<img width="1186" height="824" alt="image"
src="https://github.com/user-attachments/assets/bdcc09fe-5bf4-4b0a-b119-bcc33c98c7f2"
/>
Policies really need to be updated to properly make use of the new
bidirectional mappings for this, but this will hopefully fix it for now.
The external-link "Developer Tools" buttons (API, Automated Folder
Scanning, SSO Guide, Air-gapped Setup) used `p="sm"` while normal tool
buttons use `p="none"`, making them render larger; this aligns their
padding so they match the size of every other tool button.
<img width="308" height="196" alt="Screenshot 2026-07-10 at 5 01 40 PM"
src="https://github.com/user-attachments/assets/fb125500-28fb-4b83-85ed-2edc12e66fc0"
/>
## What this does
Adds one test to `ResourceAccessServiceTest`: a foreign team's lead is
**denied** on a team-owned resource under the `ADMINS_AND_TEAM_LEADS`
default policy, even when an unscoped `isAnyTeamLeader` check would
admit them (stubbed `lenient()` to `true` precisely so the test fails if
the scoped path ever consults it again).
## Why
Main is already correct here — no behaviour changes in this PR. #6913
landed the scoped implementation (`matchesTeamLeadDefault`: ownerless
portal → `isAnyTeamLeader`, team-owned → `isLeaderOfTeam`), which
superseded #6893. The only piece not carried over was #6893's boundary
test, so the cross-team scoping isn't currently pinned by any test. This
adds that pin as cheap insurance for future refactors.
Verified the test does its job: it passes on main as-is, and fails if
the scoped check is swapped back to the unscoped one.
## Test plan
- `:proprietary:test --tests
"stirling.software.proprietary.access.service.ResourceAccessServiceTest"`
— green
- Spotless applied
Closes the loop on #6893.
Reworks the Processor (PAYG) meter to **size-scaled units** while
keeping a true **PDF count** visible and distinct from units, and
replaces the fragile content+time lineage grouping with **explicit
per-run grouping**. Built as one PR across three slices.
> Status: **all three slices committed + verified.** `:saas` payg suite
green (418 tests, 0 failures); FE green (typecheck 0, 1260 tests, lint
0, format 0). Remaining before it takes effect in prod: run the
size-scaled default-policy SQL (below) in the Supabase SQL editor +
attach the $0.01/unit Stripe price.
## Model (what we're implementing)
- **Size scaling**: 1 unit per 50 MiB (bytes only, no page charge, no
cap). *(policy-row config, applied separately via SQL.)*
- **Charge = number of input files**: split (1→N outputs) = 1 charge;
merge (N→1) = N charges. `doc_count` = input files, fixed at open;
joined steps add 0.
- **Grouping by run id, not time**: a pipeline/policy/AI run = one
`run_id`; its tool sub-steps group into one charge (content-lineage
still maps split/merge journeys *within* the run). Two separate runs on
identical bytes = two charges. The 5-min window survives only as a
stale-job janitor.
- **10-tool split kept**: within a run's single-file lineage, an 11th
tool run opens a 2nd charge (step limit 10).
- **Count vs units surfaced**: usage page shows unique PDFs,
per-category (automation/AI/API) counts + units, and how many PDFs hit a
size multiplier with avg units/PDF.
## Slice 1 — run-id grouping (behavioural core)
- `AutomationRunContext` (common) — thread-scoped run id.
- `InternalApiClient` — stamps `X-Stirling-Run-Id`.
- Orchestrators open a run scope **on the worker thread that
dispatches** (async-safe): `PipelineProcessor.runPipelineAgainstFiles`,
`PolicyEngine.runToCompletion` (uses `run.getRunId()`),
`AiWorkflowService.orchestrate`.
- `ChargeContext` + `JobContext`: add `runId`; the charge interceptor
reads the header.
- `JobService.joinOrOpen`: `runId == null` → always open fresh
(standalone never joins); non-null → match scoped to the same `run_id`.
`JpaJobLineageStore`/`JobArtifactHashRepository`: add `run_id` filter to
the match query. Step-limit 10 unchanged.
## Slice 2 — doc_count + document_fingerprint
- V33 migration + entity fields.
- `JobService.openFresh`: set `docCount = inputs.size()`, compute
`document_fingerprint` from input signatures, and denormalise both onto
the DEBIT row in `JobChargeService.recordLedgerDebit`.
## Slice 3 — usage analytics API + FE
- `WalletLedgerRepository`: per-category `SUM(units)` +
`SUM(doc_count)`, `COUNT(DISTINCT document_fingerprint)`, and count of
rows whose units exceed their doc_count (a size multiplier fired), over
the period.
- `WalletSnapshotResponse` + `PaygWalletController`: add `categoryDocs`,
`docsProcessedThisPeriod`, `uniquePdfsThisPeriod`,
`sizeMultiplierPdfsThisPeriod`.
- FE `types.ts` + `PdfsProcessedCard` + `useWallet` + `walletFixtures` +
i18n: headline is the **PDF count**; a summary line shows "{unique}
unique · {units} meter units · {avg} avg units/PDF"; the split bar is
per-category PDF counts; a size-multiplier line shows how many PDFs
scaled. Count is separated from meter units so a 5-unit large PDF reads
as "1 PDF, 5 units".
## Config (out of PR — run in the Supabase SQL editor)
Wrap in one transaction. The partial-unique `is_default` index only
allows one default, so the old default is flipped off **before** the new
one is inserted. The new policy carries the prior default's
`free_tier_units` forward (change the literal if the launch grant should
differ).
```sql
BEGIN;
-- 1) flip default off the current policy + close its effective window
UPDATE stirling_pdf.pricing_policy
SET is_default = FALSE, effective_to = now()
WHERE is_default = TRUE;
-- 2) new default: 1 unit / 5 MiB, no page charge, no scaling cap.
-- free_tier_units carried from whatever the last policy granted (COALESCE→0).
INSERT INTO stirling_pdf.pricing_policy
(version, effective_from, doc_pages_per_unit, doc_bytes_per_unit,
min_charge_units, file_unit_cap, free_tier_units, is_default, notes, created_by)
VALUES
('v2-size-scaled-2026-07', now(),
2147483647, -- doc_pages_per_unit = INT_MAX → pages never drive units
52428800, -- doc_bytes_per_unit = 50 MiB → +1 unit per 50 MiB
1, -- min_charge_units
2147483647, -- file_unit_cap = INT_MAX → no cap on size scaling
COALESCE((SELECT free_tier_units FROM stirling_pdf.pricing_policy
ORDER BY effective_from DESC LIMIT 1), 0),
TRUE, 'Size-scaled: 1 unit/5MiB, bytes only, no cap', 'connor');
-- 3) per-source step limits: standalone ops = own charge; pipelines split at 10
INSERT INTO stirling_pdf.pricing_policy_step_limit (policy_id, job_source, step_limit)
SELECT p.policy_id, s.src, s.lim
FROM stirling_pdf.pricing_policy p
CROSS JOIN (VALUES
('WEB',1),('API',1),('DESKTOP_APP',1),('LINKED_INSTANCE',1),('PIPELINE',10)
) AS s(src, lim)
WHERE p.version = 'v2-size-scaled-2026-07';
-- 4) attach the $0.01/unit Stripe price (you handle the real price id)
INSERT INTO stirling_pdf.pricing_policy_stripe_price (policy_id, stripe_price_id)
SELECT policy_id, 'price_XXXXXXXX'
FROM stirling_pdf.pricing_policy WHERE version = 'v2-size-scaled-2026-07';
COMMIT;
```
Note: the `free_tier_units` subquery reads the most-recent policy
*before* the insert — run it as written (the new row doesn't exist yet
at step 2's SELECT).
## Self-hosted parity — tracked follow-up (not in this PR)
Combined-billing (`stirling.billing.account-link.enabled`) is a
**separate metering engine** (`app/proprietary/accountlink` —
`UsageMeterService`/`LocalUsageService`/`UsageSyncService`). The unit
*math* is shared (`DocumentUnitCalculator`), so size scaling matches
once the policy is pushed. But run-id grouping, `doc_count`, and
fingerprints must be mirrored there, and the usage-sync protocol
extended to report counts/fingerprints, before the self-hosted usage
page shows the same breakdown. Frozen/deferred, so this PR does SaaS;
self-hosted mirrors when it ships.
## What
Phase 2 of the Free PDF Editors usage card (self-hosted shipped in
#6919): make it work on **SaaS**, where one backend serves many teams so
every figure must be scoped to the **caller's team**.
| Metric | SaaS (per team) |
|---|---|
| **Editors deployed** | team member count (`team_memberships`) |
| **Active this month** | distinct members with a free-UI
(`source='WEB'`, non-`UI_DATA`) audit event in 30d, clamped ≤ deployed |
| **PDFs edited** | the team's cumulative free-UI
`PDF_PROCESS`+`FILE_OPERATION` events |
Cost stays `$0`; uncomputable figures render **N/A**.
## Backend
- **Gate the self-hosted controller** `@Profile("!saas")` — its counts
are server-wide, which would leak across tenants on SaaS. New
team-scoped `SaasFleetUsageController` `@Profile("saas")` owns the same
`/api/v1/usage/fleet-stats` path (mutually exclusive profiles → no
mapping conflict).
- **Team resolution** mirrors `PaygWalletController`:
`AuthenticationUtils.getCurrentUser(auth, userRepo)` →
`TeamMembershipRepository.findPrimaryMembership` → members via
`findByTeamId`. `@PreAuthorize("isAuthenticated()")` (team leaders
aren't global admins; any member sees their own team's totals).
- **Audit → team join**: on SaaS the audit `principal` is the user's
email and `User.username == email`, so principals join cleanly to a
team's member usernames (no hashing — only raw-JWT/over-long principals
get hashed). Two new `principal IN` count queries do the filtering,
served by the `(source, timestamp, principal)` index from #6919.
- Billing/ledger is deliberately **not** used — it only records billable
ops; free-editor activity comes from audit (same `source='WEB'` signal
as self-hosted).
- `null`→N/A when EE auditing < STANDARD; 401 on no-auth; empty-fleet
guard for the (post-migration-shouldn't-happen) teamless caller.
## Frontend
- New `src/portal-saas/api/fleetStats.ts` (rides the `@portal/*` cascade
from #6900) reads via **`apiClient.saas`** — the Supabase JWT the SaaS
backend uses to resolve the team. Re-exports `FleetStats` via
`@portal-proprietary`. **The card and `useAsync` hook are untouched.**
## Tests
`STIRLING_FLAVOR=saas` build green — `:proprietary` + `:saas` compile,
`SaasFleetUsageControllerTest` (team scoping, audit-off→null, clamp,
no-team→empty, unauth→401) and the existing suites pass; spotless clean.
## Notes
- Requires SaaS auditing at STANDARD (it is) — else N/A.
- Depends on #6900 (merged) for the portal-saas override layer and #6919
(merged) for the audit `source` column + DTO.
# Description of Changes
Adds Editor source permanently available in the Sources list. Excludes
it from the Pipelines list of available sources currently because it's
not a real source on the backend, so attempting to connect to it causes
an error. It'd be nice to extend in the future to be able to set up
policies in the editor from the pipelines page, but this'll do for now.
## What
Reworks the free-tier home hero (`WelcomeBanner` + `SetupChecklist`) to
match marketing's reworked top card: a compact product header over
numbered getting-started steps, dropping the marketing chrome.
## aim
attachments/assets/75a80e5f-119e-46bb-80e7-fc4b9a62e5b6" />
<img width="1098" height="646" alt="01-aim-marketing-demo"
src="https://github.com/user-attachments/assets/681ca1b8-219e-4afe-9748-89435aafd440"
/>
## old hero
<img width="1800" height="1338" alt="02-before-old-hero"
src="https://github.com/user-attachments/assets/a396cec0-4752-4ac0-9951-9a49f9d50ea7"
/>
## new screenshots
<img width="1800" height="626" alt="03-after-onboarding-card"
src="https://github.com/user-attachments/assets/59332111-6c49-438d-af6d-200d99bf0f8f"
/>
<img width="1800" height="180" alt="04-after-deployed-header"
src="https://github.com/user-attachments/assets/ea631e6c-7878-4159-aaf7-1f0c2bd795ce"
/>
<img width="1024" height="1396" alt="05-after-install-modal-list"
src="https://github.com/user-attachments/assets/125d0a11-d799-40f0-bd8b-42f5dedcfe6d"
/>
<img width="1024" height="858" alt="06-after-install-modal-docker"
src="https://github.com/user-
## Changes
- **Compact dark header:** brand mark + "PDF Editor" + social-proof
stats (`30M downloads · 60+ PDF operations · Free forever`) + a single
**Open in browser** CTA (→ `EDITOR_URL`).
- **Dropped** the decorative editor mock, marketing
title/subtitle/"Open-source" badge/perks, the two extra banner buttons,
and the checklist's dismiss/progress/done tracking.
- **Numbered nav steps** (①②③) — each opens its in-app surface:
| # | Step | Goes to | Change |
|---|------|---------|--------|
| ① | Download the editor | `editor` view | was an external
`stirling.com/download` link → now in-app |
| ② | Confirm your policies | `policies` view | live active/recommended
counts retained |
| ③ | Invite teammates | `users` view | **replaces** "Connect your
sources" (sources dropped to match the demo) |
- **Enterprise rung** unchanged (Start Trial / Get Quote → procurement).
## Notes
- **Shared hero** — self-hosted sees it too (per decision).
- **One deliberate deviation from the demo:** the header CTA is blue
(brand primary) rather than the demo's white button. Trivial to flip —
say the word.
- Behaviour change: the hero is now a quick-start (navigational) rather
than a completion checklist — the dismiss control + per-step done chips
are gone to match the demo.
- Supersedes the incremental #6944 ("add Open in browser" 3-button
version) — that can be closed in favour of this.
- Portal `tsc` clean; `unusedTranslations` green (removed orphaned
welcome/onboarding keys, added the new ones).
Removes some cluttered/unused UI from the portal:
- Search bar in the header
- The top bar entirely (breadcrumb, notification bell, plan switcher,
user menu)
- The plan/usage indicator in the sidebar footer
- The floating assistant badge
UI only. Where a component isn't deleted it's just no longer rendered,
so anything here is easy to restore.
## What
The portal's **"What runs on your PDFs"** table (`PolicySummary`)
rendered raw i18n keys instead of text:
- `portal.policies.categories.ingestion.label` / `.desc`
- `portal.policies.categories.security.label` / `.desc`
- …and the other three categories (compliance, routing, retention)
## Why it broke
[#6910 "Remove in-app portal
mocks"](https://github.com/Stirling-Tools/Stirling-PDF/pull/6910) moved
the policy catalogue to real data and converted each category's
`label`/`desc` (and each config's `summary`) into **i18n keys** — see
the `// values are i18n keys — render with t()` note in
`api/policies.ts`. Every consumer was updated to call `t()`
(`PolicyCategoryCard`, `PolicyDetailPanel`, `PolicySetupWizard`)… except
`PolicySummary`, which was not part of that PR and kept rendering the
fields verbatim.
The translation keys themselves already exist in
`en-US/translation.toml` (`[portal.policies.categories.*]`) — nothing
was missing, they just weren't being looked up.
## Fix
Wrap the values in `t()` in `PolicySummary.tsx` (the `t` from
`useTranslation` was already in scope):
- category `label` / `desc` in the Policy column
- `config.summary` in the Active-rule column (same keyed-value
treatment, latent until a policy is active)
## Test plan
- [ ] Open the portal Home / policies summary → each row shows the
translated category name + description (e.g. "Ingestion" / "Classify
documents…") instead of a dotted key.
- [ ] A row with an active policy shows its translated rule summary in
the Active rule column.
## What
Removes the policy **management** surface from the editor's right rail —
the Policies list above Tools, the open-policy detail takeover, and the
collapsed-rail policy icons — along with the whole UI tree only they
used: the setup wizard and its tool-config steps (PII / redact /
watermark), the detail panel, delete modal, selection store,
enforcement-queue status chip, activity/stats derivation, the catalog
hook, their i18n keys, dead types, and the admin-gate spec that tested
the wizard flow.
**Enforcement is untouched.** Auto-run on upload, the viewer blocking
overlay, exit-point blocking, file badges, and export-time enforcement
all stay. `usePoliciesEnabled` moves to its own module (core stub /
proprietary / desktop shadow with the SaaS-connection check) since it
still gates mounting the headless `PolicyAutoRunController` from the
rail.
## Why
Policies are configured in the admin portal now
(`src/portal/views/Policies.tsx`). Keeping a second management UI in the
editor rail meant two surfaces to maintain for one feature; the editor
only needs to *enforce*.
## Notes for review
- The rail UI lived in the shared `core` `RightSidebar`, so this removes
it from every build flavour at once; the deleted `PoliciesSidebar`
module existed at the core (stub) / proprietary / desktop alias layers
and all three are gone.
- Every deleted module was verified to have zero remaining importers;
near-misses that stay: `enforcementQueue` (used by export enforcement),
`poll` (test-imported), `usePolicies` (used by auto-run).
- Net −3,900 lines.
## Testing
- `task frontend:check` green: typecheck, ESLint + dpdm, Prettier, all
1,196 tests.
- All build-variant typechecks pass (core / proprietary / saas /
desktop).
## What this PR does
Brings the enterprise procurement flow in line with the new D71 pricing,
tidies up the buyer journey, and fixes a handful of things we found
testing it end to end.
### Pricing
- Priced on the new run-based model (per PDF, per policy), USD only.
Dropped the old currency picker.
- Added the policy posture choice (Essentials / Governed / Regulated)
and show roughly how many policies each covers (~2 / ~4 / ~7).
- The live estimate in the quote builder now matches the real quote the
backend produces.
- Contracts renew each year with a fixed 3% increase. The agreement
shows this plus the first renewal figure, and we save that figure on the
quote so it can't drift later.
### Trial and journey
- Starting a trial now asks for your deployment (Cloud / Self-hosted /
Air-gapped) and team size up front, and that seeds the quote.
- Quote and agreement are now one step: you review the quote and the
agreement together and click "Accept & subscribe" once. No more
accepting a quote and then separately signing.
- "Start a trial" on the home page opens the setup popup right there
instead of sending you off to another page.
- The calculator asks for number of users again and works the volume out
from that.
- Removed the demo-only buttons (reset, simulate payment) and the "Key
documents" button (it wasn't real).
- The licence key now lives behind its own "Licence key" button instead
of being shown inside every popup.
### Air-gapped licence file
- Air-gapped teams can download their licence file (.lic) during the
trial, not only after they pay.
- The popup warns that a trial file needs re-downloading once the
agreement is done, because the file is a snapshot and doesn't refresh
itself the way the online key does.
### Fixes found while testing
- Accepting a quote now upgrades the licence from trial to full straight
away (it wasn't before).
- The "Download invoice" button keeps working after a page refresh (we
now save the invoice PDF link).
- Invoice line items read differently from each other instead of all
showing the same name.
### Notes for reviewers
- The matching backend changes (Stripe quote/accept functions, database
migrations) live in the Stirling-PDF-SaaS repo on `v3`. They ship when
we do the full v3 release.
- All checks are green.
## Summary
A batch of shared **design-system** fixes (Button, SegmentedControl,
Chip, a new CarouselDots) and the consumer/call-site cleanups they
unlock, following the button consolidation (#6787). Also includes
dark-theme token alignment and some portal/auth polish that rides on the
same components.
The shared Button now sizes to its content instead of clipping it, gains
per-axis padding controls, and no longer misbehaves while loading or
disabled; several call sites are then migrated onto the proper component
APIs.
## Shared components (`core/ui`)
### Button
- **Content-driven height.** `--button-height` is now a `min-height`,
not a fixed cap. Single-line buttons still land exactly on the shared
control-height scale (pixel-aligned with `ActionIcon` /
`SegmentedControl`), while taller content — wrapped labels, stacked
title + subtitle rows — grows the button instead of being clipped
mid-glyph. Short content is re-centered with `align-content`,
**without** overriding the root `display`, so a consumer's own layout
(e.g. a full-width list row) isn't disturbed.
- **Padding props.** New `p` / `px` / `py` props
(`none`/`xs`/`sm`/`md`/`lg`/`xl`) override the size-based padding per
axis. Vertical padding is applied through a `--sui-btn-py` CSS variable,
so consumers can also set it from their own class.
- **Loading no longer collapses.** A `fullWidth` button is never treated
as icon-only, so an execute button whose label is momentarily absent
while files hydrate (e.g. `ScopedOperationButton`) keeps its full width
with a centered spinner instead of shrinking to an icon-sized square for
a split second.
- **Disabled in dark mode.** A disabled *primary* button keeps a muted
version of its own accent fill (`opacity: 0.55`) instead of Mantine's
near-black `--mantine-color-disabled`, which blended into dark surfaces
and made the button all but disappear. Loading spinners are excluded so
they stay full-strength.
No breaking API changes — buttons that don't opt in render exactly as
before.
### SegmentedControl
- Fixed a bug where a segment marked `disabled` that also happened to be
the currently-selected value was rendered disabled, leaving the active
segment un-selectable/greyed. A disabled option is now only disabled
when it isn't the current value.
### CarouselDots (new)
- New shared dots indicator component (with Storybook story), used by
the login carousel.
### Chip / theme
- Dark-theme tokens in `theme.css` aligned to the portal's `tokens.css`
so the editor and portal (Processor) dark modes stop drifting (chrome
surfaces lift off the darker canvas); plus a Chip dark-mode styling fix
and a small `mantineTheme` cleanup.
## Consumer / call-site cleanups
- **Compare** tool: the swap control is now a regular shared Button
placed **between** the Original and Edited file cards (the bespoke
full-height vertical swap button and its CSS were removed), and the file
cards fill the full available width.
- **Certificate format**: replaced the inline-styled buttons with clean
two-state (primary / secondary) buttons.
- **ToolPicker**: restored the label selectors that #6787 renamed to the
never-emitted `.sui-btn__label`, and fixed the sidebar-search row
clipping.
- **File sidebar**: "View all files" row fix; `FileSidebarFileItem`
migrated off `display:flex` + `gap` on the Button root (which no longer
reaches the nested label) onto `leftSection` / `rightSection` + a
stacked label.
## Portal / auth polish
- Portal button consolidation and styling across Header, SettingsModal,
Home, Infrastructure, ApiKeyCard, and PopularUseCases.
- **Login**: onboarding text now shows the default starting username /
password; login carousel uses the new CarouselDots; desktop OAuth
styling tweak.
## Verification
- Storybook: button sizes measure exactly on the control-height scale
and match `ActionIcon`; icon-only buttons stay square and centered;
`fullWidth` loading buttons hold full width; disabled dark-mode primary
buttons render as a muted accent rather than grey.
- Single-line buttons are pixel-identical before/after; only buttons
whose content previously overflowed a fixed height render differently
(they now fit rather than clip).
- `task frontend:lint` clean; typecheck shows only the pre-existing
third-party `node_modules` noise also present on `main`.
## Why
The portal Users page worked on self-hosted but **403'd on SaaS**. It
called the proprietary admin endpoints (`/api/v1/user/admin/*`,
`/api/v1/team/*`, `ui-data/admin-settings`), all `hasRole('ADMIN')`.
SaaS users are always `ROLE_USER` (never `ROLE_ADMIN`), so those
endpoints reject them. This is the last SaaS-release blocker for the
portal.
## What
Route the SaaS build's Users page to the **existing**
`SaasTeamController` (invitation-based team management) - no new
backend. Done via a build-time flavor seam, mirroring the existing
`usersCapabilities` pattern.
- **New seam `@app/portal/usersBackend`** (interface in
`portal/api/usersBackend.ts`) with two impls resolved by the `@app/*`
alias:
- `proprietary/portal/usersBackend.ts` re-exports the existing
admin-endpoint functions - **self-hosted behaves exactly as before**.
- `saas/portal/usersBackend.ts` calls `SaasTeamController`
(`/api/v1/team/*`) via `apiClient.local` (already flavor-aware: SaaS
backend + Supabase JWT). Resolves the leader's team from `GET
/api/v1/team/my`, maps `TeamMemberDTO`/`InvitationDTO` onto the portal
`Member`/`PendingInvitation` types.
- **`manageInvitations` capability** (SaaS `true` / self-hosted `false`)
gates a new **Pending invitations** panel (list from `GET
/{teamId}/invitations`, Cancel via `DELETE
/api/v1/team/invitations/{id}`).
- **Remove re-enabled on SaaS** (was gated off): the roster remove
action now works at team scope against `DELETE
/{teamId}/members/{memberId}`, with a flavor-aware label ("Remove from
team" vs "Remove from org") and confirm copy.
- Invite (email) and rename routed through the seam (`POST /invite`,
`POST /{teamId}/rename`); `fetchAuthConfig` on SaaS is static (no
spurious admin-endpoint 403).
- **MSW handlers** (`mocks/handlers/teamSaas.ts`) mirror the controller
so the SaaS Users page is exercisable in mock mode. Registered in
`handlers` but deliberately **not** `embeddedDataHandlers` (would clash
with the editor's own `/api/v1/team/*` routes when portal shares its
origin).
## Constraints honoured
- No new backend endpoints - reuses `SaasTeamController`.
- Self-hosted path unchanged (proprietary impl re-exports the same
functions).
- No SaaS user is ever `ROLE_ADMIN` - `adminRole`/admin-only UI stay
hidden.
## Notes from an adversarial self-review (both fixed in this PR)
- Solo SaaS users' auto-created **personal team** now hides the Rename
control (the backend rejects renaming personal teams with 400) -
`isPersonal` threaded through `Team`/`TeamGroup`.
- Expired-but-still-`PENDING` invitations are filtered in the adapter,
and the expiry label no longer mislabels a just-expired invite as
"Expires today".
## Testing
- `task frontend:typecheck:all` - all 8 flavors pass.
- Portal vitest project: **122 passing** (added SaaS adapter +
shape-mapping tests via MSW, PendingInvitations panel, and
remove/manageInvitations/personal-team gating).
- `task frontend:lint` (ESLint `--max-warnings=0` + dpdm no circular
deps) and prettier clean.
## Open questions
- **Team resolution on SaaS**: I resolve the leader's single manageable
team (prefer a real non-personal team they lead). If a leader owns
multiple real teams, only the primary is shown - matches the "single
team" framing in the spec; flag if multi-team management is wanted.
- **`isSelf` on SaaS** uses the LEADER role (the portal Users page is
leader-only on SaaS, so the leader row is the viewer). Verified there's
no multi-leader creation path today; revisit if that changes.
Draft - not marking ready until reviewed.
# Description of Changes
Empty-state polish across the four processor (portal) list pages, so a
fresh workspace gets clear next steps instead of a row of zeroed-out
stat boxes.
- **Sources / Pipelines** - hide the KPI stat strip when the list is
empty; the empty state now shows an icon plus a primary + secondary CTA
(Connect source / Read the docs; Create a pipeline / Connect a source).
Also closes a gap where a successfully-fetched empty list rendered stat
boxes over a blank page with no empty state at all.
- **Policies** - hide the summary stat strip until at least one policy
is configured; the catalogue cards stay as the "configure a policy"
CTAs.
- **Documents** - hide the filter-pill + search toolbar on an empty
queue; the empty state gains an icon plus Create a pipeline / Connect a
source CTAs.
- **Storybook** - added `Default` + `Empty` stories for all four views;
the preview now loads the real English copy so stories render shipped
text rather than raw i18n keys.
---
## 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)
- [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)
- [x] 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.
# Description of Changes
- Audited every portal page/component for hardcoded UI strings not
routed through `t()`
- Wired the remaining ones to i18n (~80 new `portal.*` keys in
`en-US/translation.toml`):
- Infrastructure status/label maps (deploy, api-key, cert, key-mode,
attestation, audit, model, region, environment) + API-key permissions
- Procurement "Key documents" modal, editor-admin deploy targets, users
seats label, pipeline output-folder placeholder
- Follows the existing house pattern: label maps store i18n keys,
resolved via `t(MAP[value])` at the render site
- Documents CSV export now reuses the on-screen column keys, and fixes a
latent bug where the exported status leaked the raw key instead of the
translated label
- No UI-copy change: en-US values are identical to the previously
hardcoded strings; other locales fall back to en-US as before
---
## Checklist
### General
- [x] 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)
- [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.
## What this does
Reworks the portal's policy setup screens so a policy reads as **its own
settings**
rather than a list of tools you wire together, and rebuilds the forms on
the
shared design system so they match the rest of the portal.
## Why
Setup showed one card per underlying tool (tool name + a toggle), which
exposed
the "a policy is a pipeline of tools" plumbing. A policy should read in
terms of
what it does to a document, not which tools run under the hood.
## Changes
- **Setup reads as policy settings.** The per-tool cards are now a
plain-language
list of what the policy does — "Redact sensitive information", "Strip
active
content", "Apply a watermark", and so on — each with a short description
and a
toggle, with its options appearing inline when turned on.
- **Consistent design system.** The setup and edit forms use the shared
components instead of one-off styling.
- **Simpler setup.** Removed two sections that aren't part of what ships
here:
Document Types (scope-by-type) and Retries.
- **Watermarks are text-only.** A policy watermark is a text stamp, so
the image
option and the type picker are hidden.
- **The editor always shows as a source** (it used to disappear when no
other
sources were connected), and the source tiles now lay out correctly.
- **Clearer upsell copy.** Locked policies read **"Upgrade to
Enterprise"**
instead of "Coming soon".
## Scope
UI only — no backend changes. Keeping a policy's settings in sync
between the
portal and the editor is a known, separate issue and is **not** part of
this PR.
## Testing
Prettier, ESLint, typecheck (proprietary + saas), and the
unused-translation
guard all pass. Setup screens verified in Storybook.
The portal's `SettingsModal` was a parallel, mock-backed settings
implementation. It's replaced by the editor's `AppConfigModal`, mounted
via a new `PortalSettingsHost` that supplies the contexts the portal
doesn't have (app config, flavor-resolved session, preferences, editor
theme). Flavor resolution does the rest: the self-hosted portal gets the
admin sections, the SaaS portal gets the saas shell. The self-hosted
account-link panel rides in through the existing seam as an extra
section.
The shell gains three host props (`urlSync`, `initialSection`,
`extraSections`); editor behaviour is unchanged. Net −1,300 lines.
Manually verified on both flavors against live backends.
The portal no longer uses mock data — it always talks to the real
backend. Mocks still power Storybook and tests.
- Mocks button and all the in-app MSW machinery removed.
- Types the app needs moved out of mock files and into the api layer, so
the app no longer depends on `mocks/` at all.
- One deliberate exception for the onboarding tour (#6926):
`enablePortalDemoData()` fills the views with example data while a tour
runs, with zero cost the rest of the time.
Heads up: views without a real backend endpoint yet now show empty/error
states in dev.
# Description of Changes
Replaces the `.stirling/done` folder and its friends with a ledger in
the DB which tracks which documents have been processed. This should
scale dramatically better since it's just a few bytes being written for
each PDF processed, rather than each PDF being duplicated and held in
the folder forever. It's designed to work with the current folder
source, but also with S3 buckets and other sources in mind - each source
will define its own strategy for ensuring it knows whether the documents
have had policies run on them or not, and they all get written to the
same ledger.
## What this changes
Moves the admin portal's browser route from **`/portal`** to
**`/processor`**, to match the "Processor" product name (the in-app app
switcher already says "processor").
- `PORTAL_BASENAME` `"/portal"` → `"/processor"` — the single source of
truth for all portal paths on the frontend, so every `toPortalPath(...)`
link and redirect follows automatically.
- `adminRouteExtensions` now mounts at `` `${PORTAL_BASENAME}/*` ``
instead of a hardcoded `"/portal/*"`, so the route can't drift from the
constant.
- **Backend:** `RequestUriUtils.isStaticResource` now treats
`/processor` (not `/portal`) as the SPA shell, so a direct navigation or
hard refresh to `/processor` serves the app instead of 404ing. (This is
the only backend URL reference — there's no Spring Security matcher for
it.)
- Updated the two portal route tests and the doc comments that named the
old path.
**Not changed:** the `@portal/*` import alias — that's the code layer /
flavor-layering path, not the URL. Renaming it would be a much larger,
unrelated churn.
The portal isn't publicly launched yet, so there are no existing links
to preserve — no redirect from the old path is included.
## Testing
- `task frontend:typecheck:all`, full test suite (1,211), lint, format —
green
- `./gradlew :common:test --tests "*RequestUriUtilsTest"` — green
## What this changes
Cleaning up the portal home page. Most of what was under the plan card
was mock — it hit endpoints that don't exist on any backend, so on SaaS
it just showed a wall of "—" and "Nothing here yet". I removed the fake
stuff and wired the bits worth keeping to real data.
**Scrapped (all mock, no backend anywhere — self-hosted included):**
- The "No usage yet" usage chart
- The KPI strip (Docs/30d, Pipelines, Agents active, Eval pass rate)
- The "Build a pipeline in seconds" fork wizard (fake build animation,
deploy was a TODO)
- The Sources/Pipelines/Agents product cards
- The "Popular use cases" marketing cards
- Enterprise region health
- The "Try a PDF operation" runner
Deleted their component/api/mock/MSW/story files too, plus the now-dead
i18n keys and CSS.
**Wired to real data (finished, not removed):**
- The plan strip and the sidebar footer now show the **real** 30-day
processed-PDF count from `/api/v1/usage/fleet-stats` (was a mock KPI).
Shows "—" honestly when the backend can't compute it, never a fake
number.
- **Recent activity** now reads the **real audit log** — the same
endpoint the Infrastructure → Audit tab uses.
**Result:** one simple layout for all tiers — plan strip → recent
activity + quick actions → "What runs on your PDFs" (the real policy
summary). Every block is backed by data that actually exists.
Net: **−3,221 / +89 lines**, 17 files removed.
## Testing
typecheck (all variants), full test suite (1211), saas + proprietary
builds, lint, format, storybook build, toml-sort — all green. The
`unusedTranslations` test guarantees no orphaned i18n keys were left
behind.
## Problem
With `RUN_SUBPATH=app`, the app is served under base `/app/`. Vite
serves `index.html` at `/app/` and redirects `/` → `/app/`, but a bare
**`/app`** (no trailing slash) returns **404** — so you had to type
`localhost:5173/app/` to load the app. `/app` should work too.
## Fix
A small dev + preview middleware that **301-redirects `/app` → `/app/`**
(query string preserved), so either form loads the app. Only active when
`RUN_SUBPATH` is set; no-op otherwise.
Also routed the vite `base` through the same slash-stripped `runSubpath`
value the middleware uses, so a stray `RUN_SUBPATH=/app/` can't produce
a doubled `//app//` base.
## Verified (dev server + prod build, `RUN_SUBPATH=app`)
| Request | Before | After |
|---|---|---|
| `GET /app` | 404 | **301 → `/app/`** |
| `GET /app?foo=1` | 404 | **301 → `/app/?foo=1`** (query kept) |
| `GET /app/` | 200 | 200 (unchanged) |
| `GET /` | 302 → `/app/` | 302 → `/app/` (unchanged) |
Production build under the subpath still emits `<base href="/app/">` and
`/app/assets/...`. Lint + format green.
## Overview
Adds **AI document classification** and a **classification-aware Files
sidebar**: uploaded documents are automatically tagged with
document-type labels (Invoice, Contract, Lab report, …), and the sidebar
groups files under editable parent categories so a large library stays
navigable.
> [!IMPORTANT]
> **This feature only runs in the SaaS build.** Classification depends
on the AI engine and team-scoped label storage, so it's gated to SaaS
end-to-end:
> - The sidebar grouping is a `saas/`-layer override of the
`fileSidebarGrouping` seam; every other build (OSS core, self-hosted
proprietary, desktop) gets the null stub and renders the **unchanged
flat, recency-sorted list** — no categories, no "Other", no picker.
> - The classify/labels backend endpoints are gated on
`policies.enabled` (on in SaaS) and live in `app/proprietary`, so
they're absent from pure OSS and dormant in self-hosted unless
explicitly enabled.
> - The Python classifier is reached only via that gated path.
>
> Shared-layer changes that do compile everywhere are inert without the
engine (dormant schema/field additions) or intentional (`GetInfoOnPDF`
surfacing custom metadata).
## What it does
- **Classifier (engine):** reads the first/last two pages of a PDF and
assigns document-type labels from an allowed vocabulary. Labels are
deliberately document-*type* descriptors — no deep-content/PII
detection, since only a page window is read.
- **Team label vocabulary:** ~270 built-in defaults across ~15 families,
seeded per team. Editable by team leaders/admins in the Classification
policy settings (import/export/reset). Team-scoped and shared;
**per-user personal labels are intentionally out of scope** — the
vocabulary is team-level only.
- **Sidebar categories:** files group under parent categories
(Financial, Legal, Medical, …), busiest-first, collapsible, with a
"Recent" group on top and an "Other" group for anything uncategorised.
The category structure (names, icons, membership, custom categories) is
**device-local and user-editable** via a "Customize" picker — the only
per-user personalization; it never changes the team's label vocabulary.
- Classification results are written to PDF metadata
(`StirlingPDFClassification`), read back to keep files in their groups
without re-parsing.
## Architecture
Spans all three layers, mirroring the existing policy/source subsystem
conventions:
- **`frontend/editor`** — sidebar grouping seam + SaaS override,
category manager, labels editor, icon palette, file grouping, tests,
`en-US` i18n.
- **`app/proprietary` + `app/common` + `app/core`** —
`ClassifyLabelController`, team-scoped `ClassificationLabelStore` (Jpa +
in-process impls, same shape as `PolicyStore`/`SourceStore`), metadata
read/write.
- **`engine`** — the document-classifier agent, contracts, routes,
tests.
## Screenshots
**Files sidebar — grouped by category (SaaS)**
### Loading view
<img width="2056" height="1046" alt="Screenshot 2026-07-07 at 5 12
56 PM"
src="https://github.com/user-attachments/assets/1d712da5-50ae-4349-b0cd-e62665c3ec0c"
/>
### Organized in the sidebar
<img width="2056" height="1045" alt="Screenshot 2026-07-07 at 5 14
05 PM"
src="https://github.com/user-attachments/assets/3ea4fe21-da51-4cea-bc3a-18ce040d3d05"
/>
**Customize categories picker**
### Personal settings to change how labels are grouped in an individual
users editor
<img width="2056" height="1044" alt="Screenshot 2026-07-07 at 5 52
42 PM"
src="https://github.com/user-attachments/assets/40be03ce-0f63-4d1e-b58b-cec045d01cb2"
/>
**Classification labels editor (team settings)**
<img width="2056" height="1042" alt="Screenshot 2026-07-07 at 5 53
00 PM"
src="https://github.com/user-attachments/assets/337b0739-15c9-4749-9c6b-22e3b20825b8"
/>
## Testing
- Frontend `task frontend:check` — green (editor + portal tests,
typecheck across all flavors, lint, label-drift guard).
- Backend `task backend:check` (proprietary) and `:saas:test` — green.
- Engine `task engine:check` — green.
# Description of Changes
Disallow SaaS guests from accessing the portal. One day we might want to
make this better so they can go there but then have to sign up before
doing anything useful, but this is the easiest way to disallow it for
now.
## What
Adds a **Storybook preview** for PRs. When a PR changes any story
(`*.stories.{ts,tsx,mdx}`) or the `.storybook` config, this builds the
static Storybook, deploys it to the preview VPS on a PR-scoped port, and
comments with the URL plus an **expandable list of exactly which stories
changed**. Torn down automatically when the PR closes.
New file: `.github/workflows/storybook-preview.yml`. Nothing else is
touched.
## How
- **Detect** (`changes` job) - `dorny/paths-filter` with `list-files:
json` flags Storybook changes and captures the exact changed files.
Skipped on close and for fork PRs (which don't get the VPS secrets).
- **Deploy** (`deploy` job, only when Storybook changed) - builds the
static Storybook (`task frontend:prepare` + `frontend:storybook:build`),
tars it, and serves it from an `nginx:alpine` container on the VPS at
port `PR# + 20000` (offset from the app preview's bare-PR-number port to
avoid collisions). Mirrors `PR-Auto-Deploy-V2.yml`'s VPS SSH pattern and
reuses the same secrets.
- **Comment** - a single bot comment (replaced on each push) with the
preview URL and a `<details>` block listing the changed stories (and any
`.storybook` config changes), e.g.:
> ## 📚 Storybook preview
> 🔗 **Preview:** http://<vps>:26911
> <details><summary>2 stories changed (+1 config
file)</summary>…</details>
- **Cleanup** (`cleanup` job, on PR close) - stops the container,
removes the files, and deletes the comment.
## Validation
- Static Storybook builds locally (`task frontend:storybook:build` →
`frontend/storybook-static`, 151 stories).
- Confirmed `task frontend:prepare` regenerates the un-committed
`material-symbols-icons.json` that stories import, so a fresh CI
checkout builds (added it before the build step).
- Comment-markdown logic unit-checked against a sample changed-files
list.
- YAML validated; action pins match the repo (`setup-node` v6.4.0 / node
22, same `paths-filter`, `setup-bot`, `harden-runner`).
## Note
The VPS deploy mirrors the proven `PR-Auto-Deploy-V2` machinery but
couldn't be exercised end-to-end from a dev box (needs the VPS secrets)
- the first live run on a Storybook-touching PR will confirm the
deploy/serve/cleanup path. Everything build- and comment-side is
validated locally.
# Description of Changes
Portal access control + user management
What this does
- Adds server-side portal access enforcement: a ResourceGrant ACL (owner
→ admin → grant → default policy) gates the portal via
@resourceAccess.canUsePortal(), so access is authoritative on the
backend, not just hidden in the UI.
- New proprietary/access module: ResourceAccessService +
ResourceAccessSecurity, PrincipalResolver (default + SaaS + team-lead
lookup), OwnershipService, ResourceGrantController, and a SecretMasker
for safe config display.
- Exposes an authoritative portalAccess flag on /me (AuthController /
AdminUserSummary); drops the old org-principal shortcut.
- Full portal Users page: team + member management (members table,
invite, move-to-team, new/rename team, reset password, access controls,
confirm modals) wired to real user/team/grant endpoints.
- Per-flavor capabilities seam (usersCapabilities): self-hosted
org-admin gets everything; SaaS is trimmed to what a team leader can do
(no ROLE_ADMIN ever surfaced).
SaaS blockers (separate follow-up PR)
The portal Users page works on self-hosted but 403s on SaaS (it calls
the admin API hasRole('ADMIN'), and SaaS users are ROLE_USER). To ship
the portal on SaaS:
- Add a @app/portal/usersBackend seam and point the SaaS build at the
existing SaasTeamController (no new backend).
- Resolve the leader's team-id on SaaS and map member/invitation shapes
to the portal Member type.
- Add pending-invitation management (list + cancel) - the parity gap vs
the editor.
- Re-enable the roster remove action on SaaS against
SaasTeamController's remove-member endpoint.
<img width="1426" height="464" alt="image"
src="https://github.com/user-attachments/assets/7a441a35-7a57-472f-a8c7-e6d8ae998439"
/>
<img width="492" height="722" alt="image"
src="https://github.com/user-attachments/assets/d4e8a088-b2eb-4326-9e00-7ada6eb72a85"
/>
---
## 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: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
Builds on the procurement vertical slice (#6861). Adds the **enterprise
licence-key mechanism**: a Keygen licence is generated at trial,
upgraded in place when the committed subscription is created, and is
viewable/downloadable in the portal. Flag-gated — ships with the mock as
default until Keygen env vars are wired.
### What it does
- **Offline / air-gapped licence** is a new **priced add-on** on the
quote ($12k/yr, flat, alongside indemnification / training / QBR).
- **Licence key visible from the trial step** — the portal shows the key
with **Copy**, and (when the offline add-on is bought) a **Download
offline licence (.lic)** button.
- **Real Keygen client, called directly from Java**
(`KeygenEnterpriseLicenseService`), behind `stirling.keygen.enabled`;
`MockEnterpriseLicenseService` stays the default. All creds are env vars
(`STIRLING_KEYGEN_*`) — nothing committed.
- **Provisioning is driven by the Stripe `customer.subscription.created`
event** (source of truth), not a UI action — so a sales-led deal entered
manually in Stripe provisions a licence too. The webhook calls a new
admin `POST /api/v1/procurement/provision`, which upgrades the trial
licence **in place** to the committed annual term, **valid immediately**
(no wait for payment). The deal stays in the payment step so the
outstanding invoice remains visible.
- Offline `.lic` is checked out `base64+ed25519` (signed, unencrypted)
so the self-hosted `KeygenLicenseVerifier` validates it fully offline.
### Not in scope (deliberate, follow-ups)
- Cloud entitlement flip — a **cloud** customer sees/downloads the key
but the running cloud product doesn't unlock yet (self-hosted/air-gap
**are** unlocked by the key). Immediate next PR.
- `invoice.paid → fully-live` / `payment_failed → suspend` webhook
safety-net.
### Companion change (separate repo)
- The Stripe-webhook wiring that calls `/provision` lives in the
**Stirling-PDF-SaaS** repo (committed on `v3`, not part of this PR):
`stripe-webhook` routes enterprise-committed `subscription.created` →
`provisionProcurement()` → the Java admin endpoint.
### Prod setup required
- Create the committed-enterprise **Keygen policy with
`scheme=ed25519`** under the existing account, set
`STIRLING_KEYGEN_ENABLED=true` + account/token/policy env vars.
### Verified
saas `:saas:test` (procurement) · portal typecheck / eslint / prettier ·
82 portal tests · `deno check` on the webhook handler.
### Review follow-ups (PR review, tracked)
Low-hardening fixes applied in `85369633ed`: keep Keygen response bodies
out of thrown/logged messages; fail-fast at startup when the flag is on
but creds are missing; gate the offline `.lic` on the *accepted* quote
(not the latest draft).
Deliberately deferred, tracked here:
- **Pre-flag verification.** Before `stirling.keygen.enabled=true`,
confirm the id-vs-key addressing against live Keygen. (The shipping
self-hosted edge addresses licences by URL-safe key in the path and
Keygen docs allow it, so the client mirrors that — but confirm
empirically with the real committed-enterprise policy.)
- **No auto-revoke on non-payment.** Provision issues an
immediately-valid annual licence before payment settles; `invoice.paid →
live` and `payment_failed → suspend` are out of scope here. Note the
offline `.lic`, once downloaded, verifies offline for the full term and
**can't be revoked** — so the real mitigation for the offline case is a
shorter bridge term until `invoice.paid`, not just wiring `suspend`.
Enterprise is sales-led/ADMIN-gated, so this is a collections concern,
not mass abuse.
## What this changes
Getting the portal ready to show the world on SaaS. A few things bundled
in here:
**Developer docs tab** — now opens https://docs.stirlingpdf.com/ in a
new tab instead of taking you to an empty page (we haven't built the
in-app docs page yet).
**Hid the bits that aren't finished yet — SaaS only:**
- Took the Agent Builder button off the Sources page.
- Removed the Components page.
- Infrastructure: the tabs that aren't ready (Deployments, Security,
Models, Storage) are greyed out as "coming soon". API keys and Audit
stay live. Also dropped the "Manage editor deployment" button.
- Removed the floating AI assistant blob.
**Fixed the SaaS api client.** Before this, only the usage/billing page
actually reached the backend — everything else (sources, users,
policies, etc.) was going to the vite dev server with the wrong login,
so it never worked. Now every portal call goes to the one SaaS backend
using the Supabase login.
Self-hosted is left exactly as it was — all the SaaS hides go through
the saas override layer, so self-hosted still shows everything.
## Testing
typecheck (all variants), full test suite, both builds, lint + format —
all green.
## What
The procurement flow's **Schedule a call** action (deal-status hero →
side modal) was a mock: a fake "SE" avatar and four hardcoded time-slot
buttons that just closed the dialog. This wires it up to the real
Calendly booking widget the admin provided.
## How
- **New `CalendlyInline` component**
(`portal/components/procurement/CalendlyInline.tsx`)
- Lazily loads `assets.calendly.com/assets/external/widget.js` via the
existing `@app/utils/scriptLoader` — only when the modal actually opens,
deduped across reopens.
- Calls `Calendly.initInlineWidget()` explicitly so it rebuilds on
reopen / theme change.
- Colours track the portal's light/dark theme (`useTheme`) via
Calendly's `background_color` / `text_color` / `primary_color` params,
mapped to the portal design tokens (surface / text-1 / primary), plus
`hide_event_type_details=1`.
- Graceful fallback to an "open in a new tab" link if the script fails
to load.
- Base URL overridable via `VITE_CALENDLY_URL` (defaults to the
group-discussion link).
- **`ScheduleCallModal`** now renders `<CalendlyInline />` instead of
the mock; copy moved into i18n (`portal.procurement.schedule.*`).
- `SideModal` gains a `wide` variant so the embed has room; removed the
now-dead `.portal-se*` / `.portal-slots*` CSS and `SLOTS` constant.
## Notes / follow-ups
- No app-level CSP blocks `calendly.com`, so the embed loads without
config changes.
- Verified with the portal typecheck (`tsc -p src/portal/tsconfig.json`)
and ESLint on the changed files; only pre-existing Storybook/msw dev-dep
type errors remain.
<img width="1160" height="642" alt="image"
src="https://github.com/user-attachments/assets/d9b5d92d-ea7e-4862-9f35-a71f65392a2c"
/>
<img width="3744" height="1990" alt="image"
src="https://github.com/user-attachments/assets/0b8002c6-dd3e-41e6-8e8b-6faf6090314c"
/>
<img width="2620" height="1928" alt="image"
src="https://github.com/user-attachments/assets/781b7a60-7065-4a7a-b9f7-1cdb0dece7b3"
/>
## What
Replaces the **mocked** "Free PDF Editors" fleet card on the portal
Usage page with live figures. Cost stays a literal `$0`; any figure that
can't be computed renders **N/A** (never a misleading 0).
| Metric | Self-hosted source |
|---|---|
| **Editors deployed** | total users (`UserRepository.count()`) |
| **Active this month** | distinct `source=WEB` principals active in 30d
(excl. `UI_DATA` polling), clamped ≤ deployed |
| **PDFs edited** | cumulative `PDF_PROCESS` + `FILE_OPERATION` audit
events that are **free UI runs** |
## Why the counting approach
"Free operations = UI tool runs." Two dead ends first:
- **Billing/PAYG is the wrong source** — it *deliberately discards* free
ops (classified `BYPASSED`, no DB row); its tables only hold billable
(API/AI/automation).
- **Raw audit is also wrong** — a tool controller emits `PDF_PROCESS`
for UI **and** API/AI/automation calls, and billable traffic exists on
every tier.
So the count is **audit filtered to free UI runs**. Audit events gain a
`source` column, stamped from the always-on signal
`BillingCategoryClassifier.classify(...) == BYPASSED` (not API-key auth,
no `X-Stirling-Automation` header, not `/api/v1/ai/`) — zero
billing-module coupling. Captured on the request thread
(`AuditService.captureCurrentSource`), carried via MDC in
`ControllerAuditAspect` (same propagation as `requestId`), persisted by
`CustomAuditEventRepository`. The count filters `source = 'WEB'`.
## Endpoint
`GET /api/v1/usage/fleet-stats` — admin-gated, EE-only. Returns `null`
per field when EE auditing is off (→ N/A).
## Frontend
- New `portal/api/fleetStats.ts` → `apiClient.local` (this instance's
backend).
- `FreePdfEditorsCard` rewired to `useAsync(fetchFleetStats)`; preview
badge removed, `null`→"N/A", loading→"—".
## Tests
`:proprietary:build` green — `FleetUsageControllerTest` (4) and
`CustomAuditEventRepositoryTest` (+2 for source-from-MDC) pass; spotless
clean.
## Notes / follow-ups
- `deployed` currently counts all users incl. disabled — refine to
enabled-only later.
- **SaaS** (team-scoped endpoint + a `fleetStats.ts` override) is
deferred to a follow-up riding the portal-SaaS layering PR #6900.
- Depends on EE auditing running at `AuditLevel ≥ STANDARD` for the
audit-derived figures; otherwise they show N/A.
## What
While a per-file policy run is in flight, the editor now blocks every
way the file can leave the app, and shows why:
- **Viewer** — a blocking overlay with live progress ("Enforcing
policy…"). Dismissible: collapses to a corner badge (top right, tinted
with the policy's accent) so the file stays readable while the run
finishes.
- **Workbench bar** — Print / Download / Save As / Share are disabled
with an explanatory tooltip and progress bar. The Ctrl+P shortcut and
the form-fill bar's "Download PDF" button are covered too.
- **File lists** — the file sidebar, file-editor thumbnails, and files
page show a spinning shield badge on the affected file, and thumbnail
hover actions (download / upload to server) are blocked with the same
tooltip.
Once a run settles, everything unblocks — including FAILED and CANCELLED
runs. A failed check surfaces through the run's activity feed; it never
locks the user out of their file.
## Why
Upload-triggered policies exist so the enforced output is what leaves
the app. Before this, a file could be printed, downloaded, or shared
while its policy run was still processing.
## Also in here
- **One shared `PolicyBadges` component** — the sidebar, thumbnails, and
files page each had their own copy of the badge markup/CSS and had
drifted (different sizes, tints, missing spinner and glow on the files
page, hardcoded English tooltips). All badge surfaces now render the
same component: accent-tinted shield, spinner while enforcing, one-off
glow when recent, i18n'd tooltips.
- **Cascade fix:** outputs imported from reconciled
(server-rediscovered) runs are now tagged `derivedFromTool`, stopping an
auto-run → import → auto-run loop that produced ever-growing
`_sanitized_sanitized…` filename chains on fresh devices.
- **Core stub for `policyRunStore`** so the core build compiles —
`WorkbenchBar` and `ViewerShareButton` resolve `usePolicyRuns` via
`@app/*`.
## Testing
- `task frontend:check` green: proprietary typecheck, ESLint + dpdm,
Prettier, 915+ editor + 81 portal unit tests.
- `typecheck:core` / `saas` / `desktop` variants all pass.
- Enforcement flow exercised manually against a live backend with an
upload-triggered policy (overlay + progress during the run, dismiss to
corner badge, unblock on completion).
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
## What this PR does
Redesigns the portal home to match the marketing demo, across all tiers.
- Swapped the old rotating welcome carousel for a static **welcome
hero** on the free tier
- Subscribed (Processor) + enterprise get a **deployed-editor hero** —
shows the live instance (host, version, active users) with an *Open in
browser* button, pulled from the real editor-deployment API (same data
the Editor admin page uses)
- Added a time-of-day **greeting** on the paid tiers
- Rebuilt the **"Finish setting up" checklist** so it's real: the counts
and tick-offs come from the actual policies + sources (a step is done
when there's at least one), not hardcoded numbers
- *Download the PDF Editor* → `https://stirling.com/download`; the other
steps deep-link to Policies / Sources
- **Procurement is now a bolt-on to any tier** — if a deal's in flight
the deal-status hero drops into the hero's footer, otherwise you get the
setup checklist
- All new copy is translated (en-US) and it reuses the shared UI kit,
icons and design tokens
## Tidy-ups / fixes found along the way
- The subscribed hero was shadowing the real `/v1/editor/deployment`
endpoint (broke the Editor admin page) — now reuses it
- Renamed the hero's CSS namespace to `.portal-welcome` so it stops
clashing with the procurement hero's `.portal-hero`
- Refactored the merged procurement component into a shared
`useProcurement` hook + banner + flow, so the deal hero can live inside
the tier hero — `/procurement` route unchanged
## Screenshots
<img width="1258" height="1338" alt="pr-free"
src="https://github.com/user-attachments/assets/9bb7db44-5f8e-4388-857a-7f113c2d7d82"
/>
<img width="1258" height="862" alt="pr-enterprise"
src="https://github.com/user-attachments/assets/f1f36faa-1467-404e-9036-dab12e3d0b54"
/>
<img width="1258" height="944" alt="pr-subscribed"
src="https://github.com/user-attachments/assets/775f1228-f182-47b5-82ff-7ac6d5932bd9"
/>
---
## Checklist
### General
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### UI Changes
- [x] Screenshots demonstrating the UI changes are attached
### Testing
- [x] Portal typecheck, ESLint and Prettier pass on the changed files
- [x] Verified all states in Storybook and ran the app locally via `task
dev:portal`
## What
Lets the admin portal ("Stirling Processor") ship **inside the JAR**,
gated by a build flag. On `main` the portal already exists as a lazy
`/portal/*` route in the editor but isn't included in production builds
and isn't reachable in a login-enabled server. This PR makes it a
**flag-gated, directly-navigable** part of the editor bundle, and wires
it into the PR preview deployment so it can be tried live.
It keeps the exact architecture `main` uses (portal = a lazy chunk of
the editor, not a separate app), so it inherits all the editor's global
providers/styles and there's no second build to maintain.
## How
**Frontend - gate the existing lazy route**
([`adminRouteExtensions.tsx`](frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx))
```ts
const includePortal = import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
const PortalApp = includePortal ? lazy(() => import("@portal/PortalApp")) : null;
```
Vite bakes the env to a literal, so when off the dynamic import is
**tree-shaken out entirely** (no `PortalApp` chunk emitted). Always on
in dev. `VITE_INCLUDE_PORTAL` is typed in `vite-env.d.ts` and declared
(default `false`) in `editor/.env`.
**Gradle** ([`build.gradle`](app/core/build.gradle)) -
`-PbuildWithPortal=true` forces `buildWithFrontend=true` and sets
`VITE_INCLUDE_PORTAL=true` on the editor build. Process-env takes
priority over `.env`, so the flag wins for JAR builds while plain `vite
build` / Cloudflare Pages default to off.
**Backend - make the shell reachable**
([`RequestUriUtils`](app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java))
- permits `/portal` + `/portal/*` as public SPA routes. The editor keeps
its JWT in localStorage (not a cookie), so a direct nav/refresh to
`/portal` isn't authenticated at the server and would otherwise redirect
to `/login` and never load. Serving the shell pre-auth (like the editor
root already is) lets it load; **access control is unchanged** - the
portal has its own auth gate + `RequirePortalAccess`, and its data APIs
stay protected.
**Docker** - the embedded Dockerfiles take `ARG BUILD_PORTAL=false` →
`-PbuildWithPortal=${BUILD_PORTAL}`. Default off, so official
`push-docker` images do **not** bundle the portal.
**CI - scoped to the PR preview deploy only**
([`PR-Auto-Deploy-V2.yml`](.github/workflows/PR-Auto-Deploy-V2.yml)) -
the one job that builds the JAR and comments owns all portal wiring:
passes `BUILD_PORTAL=true`, enables the portal's backend features
(`POLICIES_ENABLED`, `STIRLING_BILLING_ACCOUNT_LINK_ENABLED`), and adds
an "Admin portal included" line (linking `/portal` via the direct IP) to
the deployment comment. `push-docker`, `build.yml`, `test-build-docker`,
and the shared paths-filter are untouched.
## Validation (real, in the JAR)
Built and booted the JAR with `-PbuildWithPortal=true` and login
enabled:
- `/portal` and `/portal/users` load via direct nav and render **fully
themed** (dark surfaces, gradients, filled buttons).
- Editor-only build (`-PbuildWithFrontend=true`, no portal flag) →
editor ships, **0 portal chunks** (tree-shaken).
- `-PbuildWithPortal=true` → `PortalApp` chunk present.
Green: `frontend:check:all` (typecheck all variants, lint, format,
build, tests incl. the `VITE_*` env guard), backend compile,
`RequestUriUtilsTest`, spotless.
## Notes
- **Official images never bundle the portal** (Dockerfile default off);
only the PR preview does. Flip `BUILD_PORTAL` / `-PbuildWithPortal` to
include it elsewhere.
- The `/portal` shell being public is the one deviation from `main`, and
it's required for the route to be reachable at all in a login-enabled
server; data access is still fully gated.
# Description of Changes
The Change Metadata tool was missed from the bidirectional mappings
added in #6867. This PR adds it to the list of supported tools.
Adds the portal's top-left app switcher to the editor sidebar, so you
can jump between the two apps from either side.
- Both sidebars render the same shared `AppSwitch` component (sui
dropdown).
- Switching is client-side (no page reload); portal→editor no longer
breaks when `VITE_EDITOR_URL` is unset.
- Editor side is admin-gated (`portalAccess`) and only exists in flavors
that ship the portal — core/desktop stub it out, same seam pattern as
the portal routes.
- Fixes en route: dropdown menu stacking in the editor sidebar, sui
dropdown item button reset, stale `dist-portal` ESLint ignore.
## What
Ground-work so the admin portal can build for the **SaaS flavor**
alongside self-hosted, using the editor's existing **build-time
file-override** mechanism — no runtime flavor flags. This PR
demonstrates SaaS end-to-end (single-login + Usage page loading via the
inherited Supabase session) without changing self-hosted behaviour.
This is intentionally scoped as foundations, not the whole feature.
## How
**Build hook**
- `tsconfig.saas.vite.json`: `@portal/*` now cascades
(`src/saas/portal/*` → `src/portal/*`); added `@portalCore/*` for the
explicit base path.
- New `src/portal-saas/` layer (sibling of `src/portal`) holds SaaS-only
overrides, so `@app/*` resolves only editor layers and `@portal/*` only
portal layers. Self-hosted builds never import it (tree-shaken).
**Seams live in the api-client + composition layers — never in page
components**
- `saasApiBase` — base URL source (self-hosted: `VITE_SAAS_API_URL`;
SaaS reuses the single `VITE_API_BASE_URL` backend).
- `portalSaasSession` — flavor-agnostic token from the shared Supabase
client.
- `PortalAuthBoundary` — self-hosted: Spring `AuthProvider` +
`AuthGate`; SaaS: Supabase `AuthProvider` + session-only gate (inherits
the SaaS session, so no second login).
**Link concept pulled out of the Usage page (one clean cut)**
- `Usage` is now a link-free wallet renderer with generic
`onWalletLoaded` / `onReauth` callbacks; it always loads the wallet and
has zero flavor awareness.
- `PortalBillingGate` is the single flavor seam: self-hosted gates on
link (prompt when unlinked; wires the callbacks onto link/tier +
re-auth), SaaS is a passthrough that renders `Usage` directly.
- Keeps the flavor switch out of the page entirely (no per-flavor code
in `Usage`).
## Testing
Green locally and in CI (CI runs the umbrella `task
frontend:check:all`):
- `task frontend:typecheck:all` — clean across all 7 build variants
- `task frontend:test` — vitest suites pass (portal + saas cover this
change; 146 tests)
- `task frontend:build:saas` and `task frontend:build:proprietary` —
both green
- `task frontend:lint` and `task frontend:format:check` — clean
## Also in this PR (added after the initial foundations)
- **Tier from wallet + full link-layer excision on SaaS.** `TierContext`
no longer reads `LinkContext` (via a `usePlanTier` seam: self-hosted
from link state, SaaS from `wallet.status`), and the SaaS
`PortalProviders` drops `LinkProvider` / `AccountLinkProvider` /
`LinkModalHost` entirely — the link machinery is *absent* from the SaaS
bundle, not mounted-but-inert.
## Deliberately out of scope (follow-ups)
- SaaS-only read-only "connected servers" settings view.
- Shared wallet source so the SaaS tier badge and the Usage page don't
both fetch `/payg/wallet` (harmless double-fetch today).
# Description of Changes
Followup work requested in review of #6867. Currently, there is nothing
enforcing that the endpoint chosen in the tool config is the correct
mapping for `toApiParams`, so theoretically it's possible for a tool to
be set up to call an endpoint with the wrong API params for it. There's
also nothing currently enforcing that `toApiParams` and `fromApiParams`
are compatible with each other (using the same types). This PR changes
it so that instead of creating the config object directly, tools create
it via a generic function, which enforces that all of the relevant
mappings are using compatible types.
## Summary
Converts SUI's existing Select and Slider to Mantine-backed
implementations, and adds three new Mantine-backed SUI components:
MultiSelect, NumberInput, ColorInput.
All five components follow the same contract as the rest of the SUI
catalogue:
- Imported from `@app/ui` — Mantine is an implementation detail
- Explicit prop allowlists: appearance props (color, variant, radius,
classNames, styles) are locked internally to SUI tokens; only
behavioural props are exposed
- Labels and error messages stripped from the interface — callers use
`<FormField>` for both. The components take an `invalid` flag that
applies error styling only; Mantine never renders its own message
element, so the text can't appear twice
- `aria-label` / `aria-invalid` / `aria-describedby` and `FormField`'s
injected `required` are forwarded, so the injected accessibility wiring
reaches the underlying input. Mantine drops some of this wiring
internally (`aria-describedby` on inputs, all aria props on Slider's
thumb, `required` on MultiSelect's field), so `ariaForwarding.ts`
re-applies it to the DOM node and `ariaForwarding.test.tsx` locks the
contract in
- Typed escape hatches (`comboboxProps`, `popoverProps`, `rightSection`)
documented for the z-index-in-modal use case
**Select** — rebuilt from native `<select>` to Mantine combobox. Gains
searchable/clearable. `onChange` now receives the value string directly,
not a DOM event — callers updated.
**Slider** — rebuilt from native `<input type="range">` to Mantine
Slider. Gains accessible keyboard navigation and `marks` support.
**MultiSelect, NumberInput, ColorInput** — new components. The behaviour
(multi-select combobox, number stepper, colour picker) is too complex to
hand-build correctly; Mantine provides it for free behind a locked SUI
interface.
Also wires `suiCssVariablesResolver` into the Storybook
`MantineProvider` so Mantine combobox/popover dropdowns follow the SUI
palette in dark mode, and adds `"neutral"` accent variant to
`IconBadge`.
## Usage
```tsx
import { Select, Slider, MultiSelect, NumberInput, ColorInput } from "@app/ui";
import { FormField } from "@app/ui/FormField";
// Select — onChange receives string | null, not a DOM event
<FormField label="Retention">
<Select options={options} value={value} onChange={setValue} searchable clearable />
</FormField>
// Slider — same external API as before, now with marks support
<FormField label="Confidence">
<Slider value={v} onChange={setV} min={0} max={1} marks={[{ value: 0.5, label: "0.5" }]} />
</FormField>
// New components
<FormField label="PII types">
<MultiSelect data={options} value={value} onChange={setValue} searchable clearable />
</FormField>
<FormField label="Opacity">
<NumberInput value={opacity} onChange={setOpacity} min={0} max={100} suffix="%" />
</FormField>
<FormField label="Watermark colour">
<ColorInput value={color} onChange={setColor} />
</FormField>
```
## Notes
- **Select `onChange` is a breaking change** — receives `string | null`
instead of a DOM event. All existing callers in this repo are updated.
- The policy PR (`main` WIP) depends on this merging first.
- Stories for all five components are under **Primitives / Forms** in
Storybook.
## What this is
The enterprise procurement flow, built into the customer portal as a
**vertical slice** — one linked account can go the whole way from trial
to a paid, committed subscription, using real Stripe under the hood.
Procurement no longer lives as a nav tab. It sits on **Home** as a
deal-status hero and expands into a full-screen takeover, matching the
marketing prototype.
## The journey (what a customer does)
- **Start a trial** in one click — the deadline and next steps show on
the Home hero (no card, mock licence).
- **Build a quote** — a short form (volume → commitment & service →
details); pricing is computed server-side.
- **Generate the quote** — this creates a real **Stripe Quote** with a
proper **PDF** you can download and share, and it becomes a milestone
you can come back to.
- **Review & sign the agreement** — one combined agreement (MSA + Order
Form + EULA + DPA) with an itemised order form and an "I agree" (no
e-signature yet).
- **Accept** — Stripe creates the committed annual **subscription** and
its **first invoice**, which you can **pay or download right in the
app** (no waiting on email).
- Edit a quote any time — it remembers your inputs and company name; the
old Stripe quote is cancelled so it can't still be accepted.
- The hero also has quick actions: **key documents**, **invite
teammates**, **schedule a call**, and a **trial countdown** you can
extend.
## Architecture — Supabase vs Java
Pricing, deal/quote state, and the commercial journey live in **Java
(`:saas`)**. Everything that touches **Stripe** (writes + PDFs) lives in
**Supabase edge functions** — Java has no Stripe SDK and only reads
Stripe via the sync mirror. The portal calls both.
```mermaid
flowchart LR
Portal["Portal (React · editor/src/portal)"]
subgraph JAVA["Java :saas backend (trusted cloud)"]
Pricing["Pricing engine (volume bands, SLA, term, add-ons)"]
Deal["Deal + quote state, journey, snapshot"]
Trial["Trial (mock Keygen licence seam)"]
Authz["Auth: team resolve + leader gating"]
Mirror["Reads Stripe via sync mirror (stripe.* tables)"]
end
subgraph SUPA["Supabase edge functions (own Stripe)"]
Issue["issue-procurement-quote → create + finalize Stripe Quote"]
Accept["accept-procurement-quote → subscription + finalize invoice"]
Pdf["get-procurement-quote-pdf → proxy the quote PDF"]
RPC["SECURITY DEFINER RPCs (read/write stirling_pdf, enforce team/leader)"]
end
Stripe["Stripe (Quotes · Subscription · Invoice)"]
Portal -->|"price / build / trial / agreement / snapshot"| JAVA
Portal -->|"issue / accept / download PDF"| SUPA
SUPA --> Stripe
SUPA --- RPC
Mirror -. reads .-> Stripe
```
| Top-level feature | Handled in |
|---|---|
| Quote pricing (bands, SLA, term, add-ons) | **Java** |
| Deal + quote state, journey, snapshot | **Java** |
| Trial start / extend (mock licence) | **Java** |
| AuthN/Z (team resolve, leader gating) | **Java** |
| Issue quote → Stripe Quote + PDF | **Supabase edge fn** |
| Accept → subscription + invoice | **Supabase edge fn** |
| Quote PDF download | **Supabase edge fn** |
| Reading Stripe state | **Java** (sync mirror) |
| `stirling_pdf` writes from edge | **SECURITY DEFINER RPCs**
(service-role only) |
## Screenshots
<!-- Drag each PNG into the box below it before publishing. -->
**Home deal-status hero (trial)**
<img width="1920" height="1009" alt="hero-check"
src="https://github.com/user-attachments/assets/7ae21831-9578-4f4d-b91a-d3ab2cb171dc"
/>
**Issued quote milestone (with breakdown)**
<img width="1920" height="1009" alt="milestone-breakdown"
src="https://github.com/user-attachments/assets/3a463c67-3c6e-4f93-a1cc-59b250d54cc9"
/>
**Agreement step (itemised order form)**
<img width="1920" height="1009" alt="agreement-itemised"
src="https://github.com/user-attachments/assets/1a4efa16-d0a3-41d0-849c-a125b1492a34"
/>
**Key documents**
<img width="1920" height="1009" alt="keydocs-modal"
src="https://github.com/user-attachments/assets/5672d1d2-99d9-499e-9edf-d485df378e7f"
/>
**Subscription created (pay / download invoice)**
<img width="1920" height="1009" alt="accepted-check"
src="https://github.com/user-attachments/assets/3a8cbe9f-e72c-4389-b365-0c1749108b6f"
/>
## Mocked for now (scaffolding, not wired to real backends)
- **Key documents** ledger — static demo list.
- **Schedule a call** — static solutions-engineer + time slots.
- **Invite teammates** — routes to the existing Users view.
- **Simulate payment received** / **Reset procurement** — demo controls,
**off by default** in prod (flag-gated), 404 unless enabled.
## Deferred (separate follow-up PRs)
- **Real `invoice.paid` webhook** → go-live (today a demo button stands
in).
- **Keygen licence controller** — real licensing (currently a mock
seam).
- **Document sharing**.
- **Stirling admin / Deal Desk** view.
- **Minimum ACV floor** — pending a number from marketing (server-side
enforcement is a one-liner once decided).
## How to test
- **Frontend, no backend:** runs against MSW mocks (Storybook + mocks-on
dev) — the whole journey is clickable.
- **Real end-to-end:** apply the migrations (Flyway `V27–V29` / Supabase
`20260701–20260707`), deploy the three edge functions, ensure
**Invoicing Plus** is enabled on Stripe, and set
`STIRLING_PROCUREMENT_DEMO_CONTROLS_ENABLED=true` if you want the demo
controls.
- Paired SaaS PR: **Stirling-Tools/Stirling-PDF-SaaS#318**.
## Notes for reviewers
- Pricing is server-authoritative (client sends config, never amounts).
- Security review done: edge functions validate the JWT and enforce
**team membership** (and **leader** for issue/accept) via the RPC; demo
endpoints are flag-gated off. Only open item is the ACV floor (policy).
# Description of Changes
Modernises the codebase and gets rid of warnings where Node complains
that it doesn't know what type of JS it's supposed to be reading on
`.js` files. We might as well update everything to just use correct JS
syntax instead of keeping with some files having Node-specific imports.
# Description of Changes
Please provide a summary of the changes, including:
- What was changed
- Moved PostHog startup out of `index.tsx` and into a config-aware
initializer inside `AppProviders`.
- Added a dedicated `usePosthogTracking` hook that only initializes
PostHog when `enableAnalytics` is explicitly `true` and `enablePosthog`
is not disabled.
- Kept cookie-consent handling in the same flow so consent is applied
only after PostHog is actually initialized.
- Removed the unconditional `PostHogProvider` and `posthog.init(...)`
bootstrap from the app entrypoint.
- Added targeted frontend tests covering analytics-disabled and
analytics-enabled startup behavior.
- Why the change was made
- The previous frontend bootstrap initialized PostHog before app config
was loaded, so disabling analytics in the UI or via environment settings
did not prevent PostHog network activity.
- This change makes analytics behavior follow the server-provided config
instead of always connecting on page load.
Closes#6358
---
## 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.
# Description of Changes
In SaaS mode the self-hosted "Update Available" notification could still
appear and the update-check code (external call to
`supabase.stirling.com/functions/v1/updates`) still ran, even though the
cloud owns app versioning. The web `UpdateStartupPopup` was already
SaaS-gated via a null override, but two other paths were not:
- **Desktop app in SaaS connection mode** - `useDesktopUpdatePopup()`
ran its startup check and rendered the `UpdateModal` regardless of
connection mode, so a self-hosted update popup appeared while connected
to SaaS.
- **Settings → General** - the core `GeneralSection` fired
`checkForUpdate()` on mount unconditionally, even when the update
section was hidden (as SaaS does), so the external call still ran.
**What changed**
- `useDesktopUpdatePopup.ts` - the startup timer now bails out
immediately when `connectionModeService.getCurrentMode() === "saas"`. No
mode lookup, no external fetch, no modal.
- `core/GeneralSection.tsx` - the mount `checkForUpdate()` now returns
early when `hideUpdateSection` is set, so hiding the section (web SaaS,
managed-disabled desktop) also stops the external call.
- `desktop/GeneralSection.tsx` - passes `hideUpdateSection` when
`useSaaSMode()` is true, which (via the above) suppresses the settings
check in desktop-SaaS too.
**Why** - in SaaS the update check should never be called and no update
notification should be shown; the cloud handles versioning.
---
## Checklist
### General
- [x] 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)
- [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.
# Description of Changes
A few changes to improve things in the engine:
- Changed the PDF to Markdown code to be a real tool in Java, to remove
the need for the `pdf_ingest` code, which looked a bit like an agent but
wasn't behaving as an agent. It's now just covered automatically by the
edit agent.
- Noticed that 0-parameter-endpoints were previously being ignored by
the `tool_models` generator, so some tools which require no params were
being mistakenly excluded.
- Removed tools which currently never succeed like Add Stamp, Cert Sign,
and Overlay, because they require the supporting files to be sent in a
different location in the API call, which we don't currently do.
Ideally, we'd add proper support for this, but we're better off now
removing support for these tools rather than just have them crash. We
can re-add these tools in a future PR properly.
# Description of Changes
Fix https://github.com/Stirling-Tools/Stirling-PDF-SaaS/issues/281. Add
generated backend API mappings to the frontend code, and the logic to
convert from a backend API to frontend parameters objects.
Previously, it was impossible to tell if changing the backend API would
require a change to the frontend to support it because the frontend had
no static type information about the backend API. This PR adds
autogenerated tool API types to the frontend (in `toolApiTypes.ts`) and
adds explicit typed mappings between the frontend parameter types and
the backend API types, so theoretically the type checker should be able
to catch issues when changing one puts us in an invalid state with the
other. During development, it pointed out several inconsistencies that
we have between the frontend and backend types, some of which were
genuine bugs, and others were only happening to work because the backend
is more permissive than its API claims to be.
This also unlocks the ability for us to render the frontend settings on
saved backend API structures, which we've previously had to avoid doing
because we had no reverse mapping.
# Description of Changes
Fixes intermittently failing tests (and replaces one that wasn't useful
in its previous state) and also adds a CI check to warn if there are any
Playwright tests which failed on their first go and succeeded on
retries, to hopefully help find intermittently failing tests more
quickly and avoid them being merged in the first place.
## Account-link Phase 2: metering + daily usage sync
Phase 1 (already on main) let a self-hosted instance link a SaaS account
and blocked billable work when it was over its limit. It blocked, but it
never actually charged anything. This PR adds the metering + billing
half.
**It's off by default.** Everything sits behind
`stirling.billing.account-link.metering.enabled`, on top of the existing
`stirling.billing.account-link.enabled` master flag. Both have to be on
for any of it to run, so it can't touch production. The billing model
isn't going live yet — this is a dark merge.
### How it works
1. The instance classifies each billable request (API / AI / Automation
— manual PDF editing stays free) and counts it locally into a per-period
counter.
2. Once a day it reports its running totals to SaaS.
3. SaaS bills only the delta since the last report, reusing the existing
charge path (free grant + wallet ledger + Stripe meter). No new money
logic.
4. The portal shows current usage (synced spend plus anything not
reported yet), and when you subscribe it now reflects the new plan right
away instead of waiting for a cache to expire.
### What's worth a reviewer's eyes
- **It can't double-charge.** SaaS only ever bills the delta, refuses a
counter that goes backwards, dedups repeat/late reports on a monotonic
sequence number, and takes a row lock so a duplicate delivery can't
charge twice.
- **The cap is enforced at the instance gate**, not in the charge path
(same as the in-cloud flow). A $0 cap blocks all metered work.
- Page counts use jpdfium so the instance and the cloud agree on the
number that gets billed.
- New SaaS surface: `POST /api/v1/instance/sync`, migrations V25
(`payg_instance_usage`) and V26 (allow the `LINKED_INSTANCE` job
source), and a small `POST /api/v1/payg/wallet/refresh` the portal calls
after checkout.
### Companion PR
Stirling-PDF-SaaS #314 (on `v3`): the checkout edge function so the
embedded Stripe flow finishes in-page instead of reloading, plus a
`Deno.serve` migration so the edge functions actually deploy.
### Testing
Java unit tests (proprietary + saas), portal vitest, and the SaaS
edge-function tests all pass. Branch is merged up to date with main.
### Not done yet (doesn't block this merge — only matters once both
flags are on)
- V25 Supabase twin in the SaaS repo.
- Same in-page checkout fix for the editor's upgrade modal.
- A flags-on smoke test in staging (one real sync round-trip).
---------
Co-authored-by: James Brunton <james@stirlingpdf.com>
# Description of Changes
Playwright tests currently fail in Firefox and Safari because of
inconsistent behaviour across the browsers. This is causing the
nightlies to fail every night. This PR fixes the test behaviour to work
consistently across browsers (most of the issues were to do with the
tests opening the file picker, which was being automatically suppressed
in Chromium, but not the other browsers).
# Description of Changes
This change fixes the artifact upload path used by the Playwright E2E
workflows after the frontend directory structure was updated.
### What was changed
- Updated the Playwright report artifact path from:
- `frontend/editor/playwright-report/`
- to `frontend/playwright-report/`
- Applied the fix to:
- `build-enterprise.yml`
- `e2e-stubbed.yml`
- `nightly.yml`
- Renamed the nightly Playwright artifact from:
- `playwright-nightly-${{ github.run_id }}`
- to `playwright-report-nightly-${{ github.run_id }}`
for consistency with the other workflows.
### Why the change was made
The workflows attempted to upload artifacts from a directory that no
longer exists, causing GitHub Actions to report:
> No files were found with the provided path:
`frontend/editor/playwright-report/`
Updating the upload path ensures Playwright reports are successfully
collected and available for debugging failed E2E runs.
---
## 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.
# Description of Changes
Please provide a summary of the changes, including:
- What was changed
- Moved PostHog startup out of `index.tsx` and into a config-aware
initializer inside `AppProviders`.
- Added a dedicated `usePosthogTracking` hook that only initializes
PostHog when `enableAnalytics` is explicitly `true` and `enablePosthog`
is not disabled.
- Kept cookie-consent handling in the same flow so consent is applied
only after PostHog is actually initialized.
- Removed the unconditional `PostHogProvider` and `posthog.init(...)`
bootstrap from the app entrypoint.
- Added targeted frontend tests covering analytics-disabled and
analytics-enabled startup behavior.
- Why the change was made
- The previous frontend bootstrap initialized PostHog before app config
was loaded, so disabling analytics in the UI or via environment settings
did not prevent PostHog network activity.
- This change makes analytics behavior follow the server-provided config
instead of always connecting on page load.
Closes#6358
---
## 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.
# Description of Changes
We don't have any strong reasons to keep the Portal as a separate Vite
app, and it needs access to so many things from the Editor that it no
longer makes sense to keep them separate. This PR moves the Portal code
to have direct access to the Editor code and gets rid of the shared
folder.
# Description of Changes
In SaaS mode the self-hosted "Update Available" notification could still
appear and the update-check code (external call to
`supabase.stirling.com/functions/v1/updates`) still ran, even though the
cloud owns app versioning. The web `UpdateStartupPopup` was already
SaaS-gated via a null override, but two other paths were not:
- **Desktop app in SaaS connection mode** - `useDesktopUpdatePopup()`
ran its startup check and rendered the `UpdateModal` regardless of
connection mode, so a self-hosted update popup appeared while connected
to SaaS.
- **Settings → General** - the core `GeneralSection` fired
`checkForUpdate()` on mount unconditionally, even when the update
section was hidden (as SaaS does), so the external call still ran.
**What changed**
- `useDesktopUpdatePopup.ts` - the startup timer now bails out
immediately when `connectionModeService.getCurrentMode() === "saas"`. No
mode lookup, no external fetch, no modal.
- `core/GeneralSection.tsx` - the mount `checkForUpdate()` now returns
early when `hideUpdateSection` is set, so hiding the section (web SaaS,
managed-disabled desktop) also stops the external call.
- `desktop/GeneralSection.tsx` - passes `hideUpdateSection` when
`useSaaSMode()` is true, which (via the above) suppresses the settings
check in desktop-SaaS too.
**Why** - in SaaS the update check should never be called and no update
notification should be shown; the cloud handles versioning.
---
## Checklist
### General
- [x] 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)
- [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.
# Description of Changes
Fix#6801, along with fixing policies on desktop, which would attempt to
download policy outputs from the local backend instead of the server,
where they actually live. I've changed the policies logic to maintain
the same backend for the file retrieval as it used for the policy
running, so when we support running policies locally, it should still
work correctly.
## What
Makes `@shared` the single home for the Stirling brand logo assets.
Moves the editor's two logo sets — `classic-logo` + `modern-logo` (22
files: marks, wordmarks, favicons, login headers, PNGs) — out of
`editor/public/` into `shared/assets/brand/`, and adds a Storybook
**Brand/Logos** gallery.
## Why this shape (not a plain move)
The editor serves logos by **URL** from `public/` and switches
`classic`/`modern` by a **user preference** (`useLogoAssets`,
`manifest.json` / `manifest-classic.json`, `index.html` favicon links).
Rewiring all that to module imports would be a large, risky change to
the variant system.
Instead the editor keeps its variant system **unchanged** and just
sources the files from shared: `vite-plugin-static-copy` copies
`shared/assets/brand/{classic,modern}-logo/*` back to the served
`/{classic,modern}-logo` paths (the editor already uses this plugin for
pdfium/pdfjs assets). Single source of truth in shared, zero editor
code/manifest/markup changes.
## Verified
- **Build:** editor builds with both sets present at
`dist/{modern,classic}-logo/`; `manifest.json` + favicon refs resolve.
- **Dev:** the vite dev server serves the bridged paths —
`/modern-logo/logo512.png`,
`/modern-logo/StirlingPDFLogoNoTextDark.svg`,
`/classic-logo/favicon.ico` all return **HTTP 200** (the plugin's dev
middleware).
- Typecheck clean on core/proprietary/saas; prettier clean; `storybook
build` succeeds with the `Brand/Logos` gallery bundled.
- The portal's existing `@shared/assets` brand imports are untouched.
## Follow-ups (not in this PR)
- **Dedup:** `shared/assets/stirling-mark-*.svg` is byte-identical to
`brand/modern-logo/StirlingPDFLogoNoTextDark.svg`, and
`stirling-pdf-logo-*` is a near-twin of the modern wordmark. Reconciling
these (and re-pointing the portal) needs a designer eye on which
wordmark is canonical, so it's left out here to avoid changing the
portal's rendered logo.
- `editor/src/logo.svg` appears unused (no references) — candidate for
deletion separately.
## Problem
The account-link / billing / Usage strings migrated to i18next in #6738
call `t("key")` with **no inline default**. When no i18next instance is
initialized — which is the case in **Storybook** (the preview doesn't
load the portal i18n config) — or whenever a key is missing,
react-i18next renders the **raw key** (e.g. `billing.walletMeter.title`)
instead of English. That's why the billing stories regressed to showing
keys.
## Fix
Add the English string as the `t()` default value, matching the
**existing portal convention** (`AuthGate`, `Header`, `Sidebar`) and the
editor:
- plain → `t("key", "English")`
- interpolation → `t("key", "English {{var}}", { var })`
- plural → `t("key", "{{count}} …", { count })`
Dynamic keys resolved via data fields carry a sibling `*Default` string
passed as the default:
- `LINK_INFO` badge labels → `labelDefault` (`t(info.labelKey,
info.labelDefault)`)
- `PdfsProcessedCard` segment legend → `labelDefault` / `descDefault`
Defaults were sourced **verbatim from the merged
`en-US/translation.toml`**, so the TOML stays the source of truth — the
inline default only fills in when the catalogue isn't loaded or lacks
the key.
## Scope
All strings added in #6738: 5 account-link + 12 billing components + the
Usage view (157 static call sites + the `LINK_INFO` / segment dynamic
ones). No new keys; no copy changes.
## Verification
- `tsc -p portal/tsconfig.json` → 0
- `eslint --max-warnings=0` (changed files) → 0
- `prettier --check` → clean
- portal `vitest` → **62/62 pass**
No behaviour change when i18n is initialized; Storybook and any
missing-key fallback now render English.
> **Draft / WIP** — print enforcement is still to come (see below).
## Goal
A "run on export" policy must enforce on **every** path where a PDF
leaves the editor, not just the main Download/Export button. This routes
the remaining exits through the existing export-policy gateway
(`downloadFileWithPolicy`), which runs `enforceExportPolicies` before
the file leaves and is a no-op when no export policy is active.
## Audit of exit paths
| Path | Status |
|---|---|
| Web download / export, page-editor, file-editor, thumbnails | ✅
already covered (gateway) |
| **Form-fill download** (`FormSaveBar`) | ✅ fixed here — was a raw
`createObjectURL` download |
| **Desktop Ctrl+S save** (`useSaveShortcut`) | ✅ fixed here — was raw
`downloadService` |
| **Desktop save-operation-results** (`operationResultsSaveService`) | ✅
fixed here — was raw `downloadService` |
| Viewer `saveAsCopy` (annotations/redactions) | n/a — in-memory version
saves, not exits |
| **Print** (`printActions.print`) | ⏳ pending — enforce-then-print
(below) |
| Web operation-results (`downloadFromUrl`) | ⏳ pending — URL-stream,
needs a fetch→enforce wrapper |
| Share link | excluded by design (enforce at share-creation, not
recipient download) |
## In this PR
All three fixes are the same pattern — route the raw download through
`downloadFileWithPolicy` instead of `URL.createObjectURL` / the raw
download service.
## Still to come (why it's a draft)
- **Print** — enforce-then-print: on print, run the same
`enforceExportPolicies`; if it changed the doc, swap the viewer to the
enforced version (new version in history) and toast *"PDF updated by
policy enforcement — review, then print again"* rather than silently
printing a different doc; if unchanged, print. Covers Ctrl+P, the
toolbar button, and embedded PDF-JS print.
- **Web operation-results** (`downloadFromUrl`) — fetch the result to a
blob, enforce, then download.
## Verification
Typecheck (core/proprietary) + prettier clean for the changes here;
desktop tsc clean for the touched files. The print UX, once added, needs
a manual run with an active export policy — there's no automated path
for it.
> **Draft / WIP.** Combined-billing **Mode A** (connected self-hosted).
Entirely behind `stirling.billing.account-link.enabled` (default **off**
→ beans absent → 404). Pairs with Stirling-PDF-SaaS PR #313 (twin
migration → `v3`).
## What this does
A self-hosted instance links a SaaS account in the **Portal**, gets a
**device credential**, and authenticates unattended metering/entitlement
with it — no long-lived user JWT on the server. The Portal then surfaces
the team's **billing** (free trial → metered Processor plan) driven by
the live wallet.
```mermaid
sequenceDiagram
participant Portal as Portal (browser)
participant Supa as SaaS Supabase Auth
participant Local as Self-hosted backend
participant SaaS as SaaS Java (app/saas)
Portal->>Supa: signIn / signUp (Supabase JS, short-lived JWT)
Supa-->>Portal: JWT (SDK-refreshed, stays in browser)
Portal->>Local: hand JWT (same-origin)
Local->>SaaS: POST /account-link/register (Bearer JWT, leader)
SaaS-->>Local: { device_id, device_secret } (secret once)
Note over Local: store device_secret server-side
loop unattended
Local->>SaaS: /api/v1/instance/** (X-Device-Id + X-Device-Secret)
SaaS-->>Local: entitlement / gate decision
end
```
**Auth model:** human auth = Supabase JS (ephemeral JWT, kept for
attended portal features). Durable instance auth = a team-bound
**device_id + secret** (SHA-256 stored, shown once), non-user
`ROLE_LINKED_INSTANCE`, path-scoped to `/api/v1/instance/**`. Instance
binds to a **team**, never a user.
## Billing surface (Portal · Mode A states)
`Usage & billing` is state-driven by the link/subscription dimension and
built to the marketing designs, sharing one component layer across
states:
- **Unlinked** → link-account prompt.
- **Linked · Free** — the *Processor trial*: a one-time 500-PDF free
grant ("Process 500 PDFs free, then $X/PDF"), the team's free-editor
fleet, and a leader-only **Switch on the Processor →** (embedded Stripe
Checkout).
- **Linked · Subscribed** — the *Processor plan* dashboard:
PDFs-processed split (API / Agents / Automation), **spend this month**
vs. a **spend limit** meter with a run-rate projection and an **in-place
cap editor** (preset buckets + suggested value + guardrail), Stripe
**invoices** (with billed PDFs per invoice), and the default **payment
method**. Card / subscription changes deep-link to Stripe's hosted
portal.
Manual PDF editing is always free — only Automation / AI / API is
metered; a `$0` cap blocks all metered work (≠ "no cap").
**Shared, not duplicated:** the editor-fleet card, the Enterprise
upsell, and the meter (`@shared/billing` `MeterBar`) render in both the
free and subscribed views; money/cap math lives once in
`@shared/billing`. The page header is a sticky, full-bleed bar.
**New SaaS reads** (defensive — degrade to empty/"—" when the Stripe
mirror lacks a table, never 500):
- `GET /api/v1/payg/payment-method` — default card (brand / last4 /
expiry) from `stripe.payment_methods`.
- Invoice **PDFs processed** — billed line-item quantity from
`stripe.invoice_line_items`.
## Progress
- [x] Schema: `V22 linked_instance` (+ Supabase twin in #313)
- [x] `AccountLinkController` register / list / revoke (leader-only,
team from caller)
- [x] Device-credential filter (path-scoped, constant-time,
revocation-aware) + `SupabaseSecurityConfig` wiring (conditional)
- [x] `GET /api/v1/instance/whoami` + **`/entitlement`** (reuses
`EntitlementService`/`TeamBillingService`) + tests
- [x] Self-hosted backend (`app/proprietary`): orchestrator + instance
gate (dark + **fail-open**) + tests
- [x] Portal: in-app Supabase login modal + register hand-off +
`LinkContext` (unlinked default) + "Linked instances" view — all
`@shared` Storybook components
- [x] **Portal billing surface** — free (Processor trial) + subscribed
(Processor plan) Usage views to marketing spec; link-state derived from
the **live wallet**; in-place cap editor; over-cap banner
- [x] **SaaS reads** — payment-method endpoint + invoice billed-units
(defensive `stripe.*` mirror DAOs) + tests
- [x] Orphan guard: block leaving/accepting away from a team whose
departure orphans its linked instances
- [ ] Metering Step 2 (lease + reconcile loop) + bounded fail-open
cutoff
- [ ] Proprietary hardening (SaaS base-url config, secret-at-rest, finer
billable classification) + HTTP integration test
- [ ] Cross-repo Stripe lifecycle certified end-to-end (subscribe →
meter → cancel → 402)
- [ ] Admin ⟺ SaaS-leader enforcement (separate portal-team-mgmt
workstream)
## Verification — all green
| Gate | Result |
|---|---|
| `STIRLING_FLAVOR=saas :saas:test` | BUILD SUCCESSFUL (account-link +
payg, incl. `PaygPaymentMethodControllerTest`,
`PaygInvoicesControllerTest`) |
| `:proprietary:test` | BUILD SUCCESSFUL (account-link + entitlement
cache/interceptor) |
| portal | tsc 0 · eslint 0 · **vitest 55** · storybook build (all
billing stories) |
| frontend post-sync | typecheck shared + portal + editor (saas +
desktop): 0 |
## Screenshots — billing UI
_Latest Storybook renders (Portal/Billing). Drag each capture below its
caption — kept out of the repo._
**Linked · Free — Processor trial**
<img width="1648" height="503" alt="01-free-processor-trial"
src="https://github.com/user-attachments/assets/afe6238a-d3b4-47fd-8ea2-cbaed8b0a653"
/>
**Linked · Subscribed — Processor plan dashboard**
<img width="1648" height="930" alt="02-subscribed-processor-plan"
src="https://github.com/user-attachments/assets/329e6808-a9a9-4e65-99af-5a8a5e6bf4ab"
/>
**Spend limit — in-place cap editor**
<img width="1648" height="411" alt="03-spend-limit-editor"
src="https://github.com/user-attachments/assets/acc95096-bf8e-4ab0-a32c-3c20dc94f816"
/>
## Review feedback applied
Reworked the portal after first-pass feedback: linking signs in via the
**shared Supabase login** (SSO + email/password) — no bespoke form; the
**device secret is never shown in or sent to the FE** (the local backend
registers + stores it server-side); billing copy reads **PDFs**, not
"units"; the wallet surface uses **`@shared` components** matching the
SaaS Plan page. Re-verified including an assertion the link response
carries no `deviceSecret`/`deviceId`.
**Synced onto unified auth + in-app login (2026-06-23).** Merged `main`
incl. **#6725 unified auth** (`frontend/shared/auth`); the link flow
uses a shared `useSupabaseLogin` hook + `SupabaseLoginForm`, a portal
`LinkAccountModal`, and `useAccountLink.completeLink(session)` (+
on-mount SSO redirect-return). Config: `VITE_SAAS_SUPABASE_URL` +
`VITE_SAAS_SUPABASE_ANON_KEY`. The local `/account-link/link` call
carries the Spring admin bearer with the SaaS JWT in the body. **SSO**
needs the SaaS Supabase project to allow-list the portal redirect URL
(email/password works without it).
## Assumptions / open
- **Proprietary remains a scaffold** (placeholder SaaS base-url,
plaintext device secret at rest, coarse billable classification).
- Payment-method + invoice-quantity render only when
`stripe.payment_methods` / `stripe.invoice_line_items` are in the
Sync-Engine target (confirm in the Supabase/Sync-Engine config);
otherwise they degrade gracefully.
- A self-contained local HTML report + manual E2E runbook live in
`notes/account-link-report/` (dev artifacts, outside the repo).
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
# Description of Changes
- Fix PR CI for base-image changes: the embedded build's buildx
container builder could not resolve the locally-built
`stirling-pdf-base:pr-test` and tried to pull it from a registry,
failing the build
- `test-build-docker.yml`: when the base changed, build the embedded
image with the docker driver (`docker build`) so the locally-built base
resolves from the daemon image store
- `docker-compose-tests.yml`: when the base changed, skip the buildx
container builder + gha cache so `test.sh`'s local base build resolves
via the default docker driver
---
## Checklist
### General
- [x] 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)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs (if functionality has heavily
changed)
- [ ] I have read the section Add New Translation Tags (for new
translation tags only)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
# Description of Changes
Continued effort to expand linting scope to ban the `any` type in our
codebase. This PR pulls in a lot of subfolders into the linting scope,
because the excluded list was getting short enough that it was feasible
to move a layer down. I then fixed all the trivially fixable `any` type
violations in the subfolders, which just required local changes to the
one file. The aim of this PR is more to expand the scope to all the
folders we can that already avoid `any` types, rather than actually fix
violations.
# Description of Changes
We can't convert to TS7 completely yet because it lacks the TS API, so
ESLint and some of our scripts don't work, but we can do [what the TS
team suggest and run TS6 and TS7
side-by-side](https://devblogs.microsoft.com/typescript/progress-on-typescript-7-december-2025/#compiler).
When we do that, we take the `task frontend:typecheck:all` job from ~76s
to ~13s, and everything else continues to work as it did before.
I've set it so that CI will still use TS6 for the time being and locally
we use TS7 out of an abundance of caution because CI time doesn't really
matter but local time does. I do think it was a bit pointless doing that
since the TS team claim the type checking performs identically, but we
might as well have it like that for now. If it happens to go badly
locally for any devs, they can use `CI=true task frontend:typecheck` to
revert to use TS6 trivially.
# Description of Changes
[Our nightlies have literally never passed
before](https://github.com/Stirling-Tools/Stirling-PDF/actions/workflows/nightly.yml).
As far as I can tell, that's because the frontend was never being built,
so the Playwright tests would just never start up.
I've forced a nightly run from this branch, and the Playwright tests
still fail, but for legitimate failures now. It's a separate job to
track down why they're actually failing, so I'm leaving that for
followup work.
# Description of Changes
Redesign policies backend to treat sources a lot closer to how the
frontend imagined them working (they're persistent now and have an API).
Then connect the portal to the sources when mocks are off to allow for
source creation in the UI. It's not particularly useful to do that right
now because there's no policies UI, but I've tested manually that
sources set up in the UI are usable by policies created via the API.
I had to change the portal so that when mocks are off, it doesn't just
hard crash when attempting to connect to all the backend APIs that don't
exist yet. It'll still log the errors, but just continues on rendering
the UI now.
I also changed all the policies backend APIs to be gated behind a flag
instead of behind the SaaS profile. This is because we haven't yet got
the payment model sorted, but we're going to need this stuff running
self-hosted to be able to test it locally.
# Description of Changes
- Change the nightly build to not sign any of the desktop builds, since
we just care about the compiled code. The restored code will still be
signed dependent on the OS in the PR builds.
- Change RPM Linux to use zstd for compression because the one it was
using runs really slowly, and the Jar is already compressed so it makes
basically no difference (arguably we shouldn't compress at all)
- ~Switch to consistently use Depot for Docker caching to stop filling
up the GHA cache and evicting the Rust cache~ Decided against switching
to Depot because we're probably doing another PR to remove Depot
altogether in the near future
# Description of Changes
Closes#6695
This PR adds bulk cleanup actions for comments and annotations in the
PDF editor, while tightening the save and navigation behavior around
annotation edits.
### Comments sidebar
Adds a “Clear all comments” action to the comments sidebar overflow
menu. The action opens a confirmation modal before clearing sidebar
comments and replies.
The implementation distinguishes between standalone comment annotations
and comments attached to existing visual annotations. Standalone
comments and replies are removed from the document, while comments
attached to markup, shapes, ink, or other visual annotations are cleared
from the sidebar without deleting the underlying annotation itself. This
preserves the visible document markup while removing the comment
metadata and persisted comment contents.
The comments sidebar state is also reset after clearing, including draft
comments, reply drafts, edit state, and open confirmation/delete modal
state.
### Annotate tool
Adds a document-level “Clear all annotations” action to the Annotate
tool. The action is exposed through the annotation panel’s overflow menu
and uses a confirmation modal before removing annotations.
The clear operation is routed through the existing annotation API bridge
and delegates to EmbedPDF’s document-level annotation clearing API. The
UI handles unavailable annotation state, successful clears, and
failures.
After annotations are cleared, the editor resets annotation interaction
state, exits placement/selection-specific state, returns to select mode,
and marks the document as having unsaved changes only when annotations
were actually removed. The user can then persist the removal through the
normal Save Changes flow.
### Save and navigation hardening
Improves the viewer save/apply flow used by annotations and manual
redactions.
Save operations are now deduplicated while an apply operation is already
in flight, preventing duplicate exports or duplicate file consumption
when users trigger save/navigation repeatedly.
The global unsaved-changes navigation modal now waits for “Apply &
Leave” to complete successfully before navigating. If saving fails, the
modal keeps the user in place instead of leaving with unsaved edits
still present.
The Annotate panel also prevents “Save Changes” and “Clear all
annotations” from running concurrently.
<!--
Please provide a summary of the changes, including:
- What was changed:
- Why the change was made
- Any challenges encountered
-->
---
## 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)
- [X] 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)
Clear all comments :
<img width="310" height="397" alt="image"
src="https://github.com/user-attachments/assets/d1682611-13f8-4f40-aa77-44b37450e56e"
/>
Clear all annotations:
<img width="284" height="549" alt="image"
src="https://github.com/user-attachments/assets/e4049bc1-f07b-4b36-b08e-ad6d6b86fe62"
/>
### 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.
# Description of Changes
Changes
- Use 127.0.0.1 instead of localhost for the local backend. The bundled
backend starts on a random port and binds the IPv4 wildcard, but the
frontend health-checked http://localhost:{port}. On macOS (and some
Linux) localhost resolves to IPv6 ::1 first, so the connection is
refused and every backend-dependent tool shows "backend offline" even
though the backend started fine. Switched getBackendUrl() and the
health-check URL to the 127.0.0.1 loopback literal (already in the Tauri
HTTP capability allowlist, and what the OAuth loopback server already
uses). Client-side tools were unaffected, which matches the reports.
- Fail the desktop build when the bundled JRE is older than the app JAR.
The app JAR is compiled for Java 25, but the bundle could ship an older
runtime/jre (jlink:runtime short-circuits on an existing runtime, and
nothing checked its version), producing UnsupportedClassVersionError at
launch so the backend never starts. Added a jlink:verify task that reads
the jlink release file and fails the build if the bundled JRE major is
below REQUIRED_JAVA (25, kept in sync with build.gradle
modernJavaVersion). It runs after the runtime is staged - including the
short-circuit reuse path that lets a stale JRE slip through.
Cross-platform Node script, no new 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.
# Description of Changes
## What was changed
- Updated all GitHub Actions workflows using Gradle from older versions
(9.3.1 and 9.5.1) to Gradle 9.6.0.
- Updated the Gradle Wrapper distribution URL to use Gradle 9.6.0.
- Updated all Gradle-based Docker build stages to use the
`gradle:9.6.0-jdk25` image and corresponding image digest.
- Aligned CI, Docker, and local development environments on the same
Gradle version.
- Included the regenerated `gradlew` script changes produced by the
Gradle wrapper update process.
## Why the change was made
- Ensures consistent Gradle versions across local development, CI
workflows, and Docker builds.
- Takes advantage of the latest Gradle 9.6.0 improvements, fixes, and
compatibility updates.
- Reduces the risk of version mismatches causing build or deployment
inconsistencies.
- Simplifies maintenance by standardizing the build toolchain throughout
the repository.
---
## 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.
# Description of Changes
This change centralizes several dependency version declarations into
shared Gradle version properties and updates module build files to
reference those properties instead of hardcoded version strings.
### What was changed
- Added centralized version properties in the root `build.gradle` for:
- commons-io
- commons-lang3
- rhino
- okhttp BOM
- gson
- guava
- bucket4j
- archunit
- batik
- jpdfium
- JWT
- AWS SDK
- Testcontainers
- Replaced hardcoded dependency versions across multiple modules with
shared version variables.
- Updated `resolutionStrategy.force` declarations to use centralized
version properties.
- Updated dependency constraints and BOM references to use shared
version variables.
- Removed module-specific duplicate version declarations from
`app/proprietary/build.gradle`.
- Standardized dependency declarations across `common`, `core`,
`proprietary`, and `saas` modules.
## Why the change was made
- Reduce duplication of dependency version definitions.
- Simplify future dependency upgrades and maintenance.
- Ensure consistent dependency versions across all modules.
- Improve readability and reduce the risk of version drift between
subprojects.
- Make security-related dependency overrides easier to maintain from a
single location.
---
## 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.
## Summary
Published Docker images (`stirling-pdf:latest`, `:2.13.1`) still shipped
the full `ffmpeg` package even though it was disabled in source back in
#6053.
**Root cause:** `push-docker.yml` passed a hardcoded
`BASE_VERSION=1.0.0` build-arg for the regular image, overriding the
Dockerfile's `ARG BASE_VERSION=1.0.2` default. Base `1.0.0` is the
original base that still does the explicit `ffmpeg` apt install, so the
published image never picked up the removal.
# Description of Changes
Fixes one of the main causes of `any` typing left in tools, the way that
we register tool parameters in the registry. Currently, it just accepts
tool params via `any`, but instead we can explicitly change them to
`Record<string, unknown)`, so on the way back out they can more safely
be cast back to their correct type when known.
One consequence of this is that I had to redesign the way we
special-case the Convert tool, which previously was a different shape
than all the other param types. Now it's just got optional parameters on
it, which isn't quite as type-safe as before, but it does mean all tools
are a consistent shape now, which I think is worth the tradeoff.
• Removed colors from policies to make them look more professional.
• upgraded to enterprise link to contact us.
• Hid inactive policies from users (Kept for admin and team lead).
• Closing policies had wrong arrow, made a standard component for chat,
tools and policies header.
## What
Make the **SaaS** build always use the modern logo, so the classic logo
can no longer appear anywhere in the SaaS app.
This is a minimal, SaaS-only alternative to the full classic-logo
removal PR (~80 files). **OSS (`core`) and proprietary builds are
untouched** — they keep the full modern/classic variant system,
including the admin _Logo Style_ picker.
## How
A single SaaS-layer override shadows the core hook:
- `frontend/editor/src/saas/hooks/useLogoVariant.ts` → returns
`"modern"` unconditionally.
In the SaaS build the `@app/*` alias cascade resolves
`@app/hooks/useLogoVariant` to `src/saas/*` before `src/core/*`, so this
shadows the core implementation (which otherwise resolves the variant
from the stored user preference or the server `logoStyle`).
## Why one file is enough
All logo rendering funnels through `useLogoVariant()`:
- `useLogoAssets()` → favicon, web manifest, apple-touch icon, wordmark,
`logo512`, tooltip logo — consumed by `BrandingAssetManager` (which sets
`<link rel="icon|manifest|apple-touch-icon">`), `Wordmark`, `LogoIcon`,
`Tooltip`.
- `useLogoPath()` → the no-text logo SVGs.
- The login-carousel slides (`buildLoginSlides`) receive the variant
from `AuthLayout`, which calls the hook.
Everything else that references a logo in SaaS already hardcodes
`modern-logo` (`index.html`, the SaaS
`Login`/`Signup`/`AuthCallback`/`OAuthConsent` routes, cloud onboarding,
account/MFA QR logos).
The only hardcoded `classic-logo` reference — the admin _Logo Style_
picker in `AdminGeneralSection` — is **not shipped in SaaS**:
`createSaasConfigNavSections` builds from the core nav sections and
never includes the proprietary admin sections.
`manifest-classic.json` and the classic assets remain in the shared
`public/` folder (served by all builds) but are never referenced in the
SaaS bundle.
## Test plan
- [x] `task frontend:typecheck:saas` — clean
- [x] `eslint` on the new file — clean
# Description of Changes
TS6 introduced backwards-incompatible changes which affected us a little
bit. Other than that, I don't think it significantly changes things for
us, but we will need to deal with these breaking changes to be able to
upgrade to TS7 (the version written in Go, so dramatically faster), so
I'd rather do the work now before TS7 actually releases.
Main things I've done:
- Removed the use of `baseUrl` in the `tsconfig.json` files
- Explicitly provide the `node` types where needed
- Explicitly reference the un-referenced but required Google API types
- Dropped the installation of `madge` which we weren't using and wasn't
directly compatible with TS6
- Updated the `i18next` packages for explicit TS6 compatibility
- Explicitly override `tsconfck` to force TS6 compatibility since we
can't upgrade it. We're only using that for `vite-tsconfig-paths` and it
all still seems to work fine, so I don't think this is an issue. I think
we can theoretically drop `vite-tsconfig-paths` when we upgrade to Vite
8 ([because it supports
`paths`](https://v8.vite.dev/guide/features#paths)), but that's a bigger
job than I want to do in this PR
# Description of Changes
Refactor frontend auth to the shared folder and hook it up to both the
portal and editor so they share the same system. Also adds various tasks
to help run the portal, including `task dev:portal` to spawn the portal
with the backend, and `task dev:portal:proxy` to spawn the editor,
portal and backend, and a reverse proxy (at localhost:3000) to allow you
to use both at once to simulate how this will actually be deployed,
allowing you to check whether the seamless transition between the two
actually works.
# Description of Changes
> [!note]
> GitHub absolutely mangles the diff unless you change to ignore
whitespace changes
This page has never had the Enter key bound to the Change Password
button:
<img width="673" height="745" alt="image"
src="https://github.com/user-attachments/assets/7a1b06f0-2945-4270-a795-f799ec556c12"
/>
This PR changes the modal to be properly wrapped in a form so key
commands work correctly on it.
# Description of Changes
Rust cache added in #6732 never fired because `main` builds don't
include building the desktop apps. We could build them on `main` builds,
but that's fairly expensive, so just build them on nightlies instead to
warm the cache for any desktop PRs the next day
## Overview
Internationalizes the **developer portal**, which previously had **zero
i18n** — every string was hardcoded across ~118 components. Rather than
stand up a parallel system, this shares the **editor's** existing i18n
setup (same TOML locale format, same Crowdin pipeline), then converts
every portal surface to `react-i18next` and adds a CI guard so coverage
can't regress.
## What's included
### 🔗 Shared i18n core (`@shared/i18n`)
- Extracts the editor's `TomlBackend` (HTTP loader for
`public/locales/{lng}/translation.toml`) and language metadata/helpers
(the 42-language list, RTL set, `LanguageSource` priority, code
normalizers) into `frontend/shared/i18n/`.
- The **editor** now imports and re-exports these from `@shared/i18n` —
its 20+ consumers are unchanged. Its local `tomlBackend.ts` is deleted.
- The **portal** builds its own i18next instance from the shared core,
with **en-US as the source of truth** and the same
`/locales/{lng}/translation.toml` layout.
### 🌍 Full portal coverage
- Every view and component converted to `t()` — all feature areas (home,
pipelines, sources, infrastructure, usage, documents, agent-builder,
editor-admin, policies, users, docs, catalogue, components view) plus
app shell, nav, modals, and the home/domain widgets.
- **1108 keys across ~30 namespaces** in
`portal/public/locales/en-US/translation.toml`, grouped by feature;
shared strings under `[common]`. Plurals use i18next count forms;
dynamic labels (nav, settings sections, status badges) use template keys
against populated tables.
- Data-driven strings (values from `@portal/api/*` mocks, enum/id
values, code samples) are intentionally left untranslated — they're
data, not UI chrome.
### ✅ CI coverage guard
- `portal/scripts/check-i18n.mjs` fails if any static `t("key")` in
portal source is missing from the en-US locale. Wired into
`frontend:check` and `frontend:check:all`, so missed keys break CI. This
mirrors the editor's `missingTranslations` test for the portal, which
has no vitest harness of its own.
## Testing
- `task frontend:check:all` passes locally (typecheck all variants,
lint, format, **portal i18n guard**, builds, tests, storybook).
- Every static `t()` key verified to resolve in the locale (1108 keys /
186 source files); all dynamic key prefixes map to populated tables.
- Runtime sweep of all 12 portal routes shows **no unresolved keys** on
screen; nav labels, plurals, and array-backed copy all render real text.
## Follow-ups (not in this PR)
- **Crowdin** — register `frontend/portal/public/locales/` as a source
so portal strings flow through the same translation pipeline as the
editor (an ops step on the Crowdin side; there's no Crowdin config in
the repo).
- Only `en-US` is populated; other languages will arrive via the
pipeline.
AI PDF creation ("create a PDF for me") has been broken since the
Policies backend (#6527) introduced PolicyExecutor as the tool execution
pipeline. PolicyExecutor runs normal single-input tools with a per-file
loop, but generator tools like `create-pdf-from-html-agent` take no
input file and build their output purely from parameters. With zero
input files the loop ran zero times, so the endpoint was never called
and the step silently produced nothing. The chat reported success
("Created Purchase Order") while no document ever appeared.
This adds an `else if (inputFiles.isEmpty())` branch so a generator tool
is called once with an empty file list, matching what the multi-input
branch already does for an empty input. Two files changed: the
one-line-ish fix in `PolicyExecutor`, and a regression test covering the
no-input case.
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
# Description of Changes
The Tauri jobs are very slow, especially the Linux ones, which can take
>1hr to build all the necessary code. A lot of that is because of the
actual Rust compilation, which isn't cached at all as far as I can tell.
This introduces a cache step for the Rust dependencies, so PRs will just
reuse the compiled Rust from the last build of main (if it's safe to do
so).
# Description of Changes
Continued effort to remove the remaining uses of the `any` type from our
TS code. The vast majority of these uses that it cleans up was just
catching errors as `any`, which are pretty simple to fix. I couldn't
completely remove the `any` type usage from `core/tools` because there
were cascading issues from a couple of the files in there (most notably
Automate) but still, moving in the right direction.
# Description of Changes
#6727 introduced frontend code which goes against the architecture, so
this PR re-implements it in the architecture properly, along with
another bad Tauri check that I found in the source. I also updated the
`AGENTS.md` file to use Claude's "read this file" syntax to try and
force AI to actually read the file instead of just suggesting that it
does it.
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md">js-yaml's
changelog</a>.</em></p>
<blockquote>
<h2>[4.2.0] - 2026-06-01</h2>
<h3>Added</h3>
<ul>
<li>Added <code>docs/safety.md</code> with notes about processing
untrusted YAML.</li>
<li>Added <code>maxDepth</code> (100) loader option. Not a problem, but
gives a better
exception instead of RangeError on stack overflow.</li>
<li>Added <code>maxMergeSeqLength</code> (20) loader option. Not a
problem after <code>merge</code> fix,
but an additional restriction for safety.</li>
<li>Added sourcemaps to <code>dist/</code> builds.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Stop resolving numbers with underscores as numeric scalars, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/627">#627</a>.</li>
<li>Switched dev toolchains to Vite / neostandard.</li>
<li>Updated demo.</li>
<li>Reorganized tests.</li>
<li><code>dist/</code> files are no longer kept in the repository.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Fix parsing of properties on the first implicit block mapping key,
<a
href="https://redirect.github.com/nodeca/js-yaml/issues/62">#62</a>.</li>
<li>Fix trailing whitespace handling when folding flow scalar lines, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Reject top-level block scalars without content indentation, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/280">#280</a>.</li>
<li>Ensure numbers survive round-trip, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/737">#737</a>.</li>
<li>Fix test coverage for issue <a
href="https://redirect.github.com/nodeca/js-yaml/issues/221">#221</a>.</li>
<li>Fix flow scalar trailing whitespace folding, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Fix digits in YAML named tag handles.</li>
</ul>
<h3>Security</h3>
<ul>
<li>Fix potential DoS via quadratic complexity in merge - deduplicate
repeated
elements (makes sense for malformed files > 10K).</li>
</ul>
<h2>[3.14.2] - 2025-11-15</h2>
<h3>Security</h3>
<ul>
<li>Backported v4.1.1 fix to v3</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/nodeca/js-yaml/commits">compare view</a></li>
</ul>
</details>
<br />
[](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>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md">js-yaml's
changelog</a>.</em></p>
<blockquote>
<h2>[4.2.0] - 2026-06-01</h2>
<h3>Added</h3>
<ul>
<li>Added <code>docs/safety.md</code> with notes about processing
untrusted YAML.</li>
<li>Added <code>maxDepth</code> (100) loader option. Not a problem, but
gives a better
exception instead of RangeError on stack overflow.</li>
<li>Added <code>maxMergeSeqLength</code> (20) loader option. Not a
problem after <code>merge</code> fix,
but an additional restriction for safety.</li>
<li>Added sourcemaps to <code>dist/</code> builds.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Stop resolving numbers with underscores as numeric scalars, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/627">#627</a>.</li>
<li>Switched dev toolchains to Vite / neostandard.</li>
<li>Updated demo.</li>
<li>Reorganized tests.</li>
<li><code>dist/</code> files are no longer kept in the repository.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Fix parsing of properties on the first implicit block mapping key,
<a
href="https://redirect.github.com/nodeca/js-yaml/issues/62">#62</a>.</li>
<li>Fix trailing whitespace handling when folding flow scalar lines, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Reject top-level block scalars without content indentation, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/280">#280</a>.</li>
<li>Ensure numbers survive round-trip, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/737">#737</a>.</li>
<li>Fix test coverage for issue <a
href="https://redirect.github.com/nodeca/js-yaml/issues/221">#221</a>.</li>
<li>Fix flow scalar trailing whitespace folding, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Fix digits in YAML named tag handles.</li>
</ul>
<h3>Security</h3>
<ul>
<li>Fix potential DoS via quadratic complexity in merge - deduplicate
repeated
elements (makes sense for malformed files > 10K).</li>
</ul>
<h2>[3.14.2] - 2025-11-15</h2>
<h3>Security</h3>
<ul>
<li>Backported v4.1.1 fix to v3</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/nodeca/js-yaml/commits">compare view</a></li>
</ul>
</details>
<br />
[](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>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
# Description of Changes
Add message describing the most common tasks when running `task` with no
arguments. I think this should help newcomers because `task --list` is
massive at this point and nobody's going to read through it all. Let me
know if you think any other commands should be in the default message.
# Description of Changes
## What was changed
- Updated editor translation files across multiple locales.
- Migrated numerous count-based translation keys from legacy
`{{plural}}` handling to ICU-style plural forms using `_one`, `_other`,
and where applicable `_zero` variants.
- Added translations and localization keys for newly introduced features
and UI areas, including:
- Stirling Agents
- Chat interface and quick actions
- Files management and folder organization
- Desktop update workflow
- Folder scanning warnings
- Team and workspace management
- Sharing and upload dialogs
- Comparison status messages
- Relative time formatting
- Additional tool panel and update UI strings
- Added missing translation entries required by recently introduced
frontend functionality.
- Reorganized some translation sections to maintain consistency and key
ordering.
## Why the change was made
- To align locale files with the current frontend feature set.
- To support proper pluralization behavior across languages.
- To prevent missing translation keys and fallback text in newly added
UI components.
- To improve localization consistency and maintainability as the
application grows.
---
## 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.
Auto-generated by stirlingbot[bot]
This PR updates the backend license report based on dependency changes.
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
## What
Residual dead-code cleanup following the credits engine teardown
(#6687).
- **Delete the `ErrorTrackingService` dead island** (6 files): the
service, `UserErrorTrackerRepository`, `UserErrorTracker`,
`ProcessingErrorType`, `CreditsProperties`, and
`ErrorTrackingServiceTest`. These formed a self-referential cluster with
**zero external callers** once the credit machinery was removed.
- **Remove `/api/v1/credits/**`** from both `excludePathPatterns` blocks
in `PaygWebMvcConfig` — the credits controller no longer exists, so the
exclusion is defunct. (spotless collapsed the lists to one line.)
## Verification
- `./gradlew :saas:compileJava :saas:compileTestJava` → **BUILD
SUCCESSFUL**
- grep confirms zero dangling references to the deleted types
## Not in scope (deliberately deferred)
Destructive DB drops
(`user_credits`/`team_credits`/`user_subscription_plans` tables, dead
`payg_shadow_charge` columns, `user_error_tracker` table) are gated
behind the post-release soak (`live_ratio==1.0 ≥7d`) and tracked in a
separate bundle.
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
# Description of Changes
The `pre-commit` commands in this repo are inconsistent with the rest of
the dev workflow, as they are impossible to run through Task and they
can cause CI to fail with no way for a developer to run the `pre-commit`
scripts after they've failed. This PR adds `task pre-commit` (and `task
pre-commit:fix`) and then hooks up the existing `pre-commit` hooks and
CI to call the Task rule, so if developers are using pre-commit hooks
then they should still work, but they're also runnable without using
pre-commit at all.
I think it'd be worth reviewing what we're actually running at
pre-commit in the future because I'm not entirely convinced by all of
the scripts that we are running, but this should at least make what we
have properly enforced and usable by all devs.
Fix issues with the theme of the app that caused some things to persist
in light mode/dark mode whilst the rest of the app was the opposite
theme.
Removed dead rainbow mode code.
Added system theme option to settings.
## What & why
Policies (automation-backed enforcement) execute and bill through the
cloud backend, so the feature should only be available in the hosted
**SaaS** product — not in self-hosted proprietary or core builds. Today
it's enabled in the proprietary build (and the API is exposed in any
proprietary backend), so this locks it to SaaS on both layers.
## Frontend (build-flavor gate)
`POLICIES_ENABLED` is the single gate `usePoliciesEnabled` uses (rail +
auto-run controller).
- `proprietary` flag → **`false`** (self-hosted web no longer shows
policies)
- new `src/saas/constants/featureFlags.ts` → re-exports proprietary
flags, overrides `POLICIES_ENABLED = true`
- new `src/desktop/constants/featureFlags.ts` → same `true` override —
**required**: desktop's `@app` alias has no saas layer, and desktop
already gates policies on `POLICIES_ENABLED && useConfirmedSaaSMode()`,
so without a `true` here that runtime gate could never be satisfied.
Behaviour unchanged: desktop shows policies only when connected to SaaS.
- `PoliciesSidebar.test` mocks the flag on (it tests the component, not
the build gate — same pattern the existing `usePolicyAutoRun.retry.test`
uses).
## Backend (`@Profile("saas")` gate)
The saas backend runs under the `saas` Spring profile (as
`EntitlementGuard`, the AI controllers, etc. already do). The policy
beans are now `@Profile("saas")`, so `/api/v1/policies/*` and the
auto-run triggers exist **only** in the saas backend:
`PolicyController`, `PolicyEngine`, `PolicyRunner`, `PolicyRunRegistry`,
`PolicyValidator`, `JpaPolicyStore`, `FolderInputSource`,
`FolderOutputSink`, `InlineOutputSink`, `PolicyAccessGuard`,
`FolderAccessGuard`, `FolderWatchTrigger`, `ScheduleTrigger`,
`PolicyTriggerManager`.
**Deliberately *not* gated:** `PolicyExecutor` — `AiWorkflowService`
(always-on) injects it to run ad-hoc pipelines, so it stays
profile-free. It only depends on shared infra (`InternalApiClient`,
`ToolMetadataService`, `TempFileManager`, `ObjectMapper`), so leaving it
on is safe. Gating the engine/store/triggers as a set keeps wiring
consistent (nothing un-gated depends on a gated bean).
The saas `PolicyManagementAuthority` impl
(`TeamLeaderPolicyManagementAuthority`, `@Profile("saas")`) satisfies
`PolicyAccessGuard` in the saas context.
`AdminPolicyManagementAuthority` (`@Profile("!saas")`) becomes an unused
orphan in non-saas builds — harmless; left as-is rather than expanding
this PR's scope.
## Testing
- Frontend: full suite **869 pass**; typecheck clean on
proprietary/saas/core.
- Backend: `:proprietary` compiles, spotless clean, policy tests pass,
and the proprietary (non-saas) Spring context still boots with the
policy beans gated out (verified via the MCP `@SpringBootTest`
integration tests — no missing-bean failures).
Net: SaaS web build + desktop-in-SaaS-mode get policies (UI + API);
self-hosted proprietary and core get neither the UI nor the
`/api/v1/policies` endpoints.
# Description of Changes
Currently, `task dev` explicitly calls the backend with
`AIENGINE_ENABLED=true` even though it isn't being spawned, so you just
get a dead FAB in the UI. This PR fixes it so that the engine will only
be enabled for tasks that will actually spawn the engine.
It also fixes a bug with the chat which makes it unusable locally. The
API path was not going through `apiClient` so for local dev you end up
with `//api/v1/...` which is not a valid path, so you get CORS errors
when trying to connect to the AI engine.
# Description of Changes
Redesign policy running so the server is in charge of policy IDs and
running, to make it impossible to have the frontend miss the results.
This solves a minor bug that we currently have in policies, where if you
load a file and then refresh while the policy is running, you'll never
receive the outputted file.
Complete legacy-credits teardown ("Group 3"). The per-user/per-team
credit model is fully superseded by PAYG (`wallet_ledger`) — confirmed
no PAYG code references it. Authorized to also remove the `TeamCredit`
pool + its monthly reset.
## Frontend (saas)
- Deleted `saas/hooks/useCredits.ts`, `apiKeys/hooks/useCredits.ts`,
`types/credits.ts`, `apiKeys/UsageSection.tsx`.
- `UseSession.tsx`: removed credit members (`creditBalance`,
`creditSummary`, `hasSufficientCredits`, `updateCredits`,
`refreshCredits`, `fetchCredits`) + the credit types + global
credit-update callback. **Kept** `isPro`/`refreshProStatus` and the
Supabase auth subscription listener.
- `services/apiClient.ts`: removed the dead `x-credits-remaining`
handler + low-credit plumbing (token-refresh / PAYG / 401 logic
untouched).
- Credit refs removed from `ApiKeys.tsx`, `AppConfigModal.tsx`,
`auth/teamSession.ts`.
## Java (:saas)
**Deleted (15):** `UserCredit`(+repo),
`TeamCredit`(+repo)+`TeamCreditService`, `CreditService`,
`CreditHeaderUtils`, `CreditResetScheduler`, `CreditController`,
`CreditInterceptorConfig`, `UnifiedCreditInterceptor`,
`CreditSuccessAdvice`, `CreditErrorAdvice`, `CreditConsumptionResult` (+
the CreditController test).
**Edited — stripped legacy credit side-effects, preserved
auth/role/AI/PAYG logic:**
- `AiCreate`/`AiProxyController`: dropped the
`X-Credits-Remaining`/`X-Credit-Source` response header (its only
consumer, the desktop credit system, was already removed).
- `SaasTeamService`: dropped UserCredit/TeamCredit init on team-create +
seat-update.
- `SupabaseAuthenticationFilter` / `SupabaseSecurityConfig`: dropped
`getOrCreateUserCredits` on signup + the credit field/CORS header.
- `UserRoleService`: dropped `resetCycleAllocationForRoleChange`;
`ROLE_PRO_USER` grant/revoke preserved.
- proprietary `UserRepository`: dropped
`findUsersWithApiKeyButNoCredits()`.
- Tests updated to drop credit mocks/refs.
## Kept / scope
- `isPro` / `is_pro` RPC / `ROLE_PRO_USER` (that's the separate Group-4
/ EE effort) and **all PAYG** are untouched.
- **No DB tables dropped.** `user_credits`/`team_credits` stay until a
later **gated** migration — which this PR unblocks (the JPA entities
that pinned them are gone).
## Verify
`:saas:compileJava` + `:saas:compileTestJava` pass; FE `tsc --noEmit`
(saas) + eslint clean; 0 stray artifacts; no residual source refs to the
deleted classes.
## Follow-up (not in this PR)
`ErrorTrackingService` (+
`UserErrorTracker`/`ProcessingErrorType`/`CreditsProperties`) is now a
dead island — its only callers were the deleted interceptors. Safe to
delete, but it cascades beyond the credit scope, so it's a separate
tidy-up.
Targets `feat/desktop-cloud-saas-reuse`.
## What & why
Chained on top of #6649 (the `cloud/` refactor). The AI assistant was
effectively dead on desktop:
1. **Hidden** — `ChatFAB` gates on `aiEngineEnabled`, which desktop
reads from the **local** bundled backend's `/api/v1/config/app-config`.
The local backend has no AI engine, so the flag is always `false` and
the FAB never renders.
2. **Mis-routed** — even if shown, AI calls used `getApiBaseUrl()`,
which is empty/local on desktop, so the orchestrate stream and AI
result-file download missed the engine (which only runs in the cloud).
This PR wires AI properly **without hardcoding it on**, so the cloud
keeps the kill switch: flip `aiEngineEnabled` server-side and the
desktop FAB disappears on the next load — no desktop release required.
(Deliberately *not* assume-on, so a future "turn AI off" doesn't strand
shipped versions.)
## Changes
**General SaaS app-config service** (reusable for any cloud flag, not
just AI):
- `desktop/services/saasAppConfigService.ts` — SaaS-mode-only fetch +
5-min cache of the **public** `/api/v1/config/app-config` from the
**SaaS** backend over native HTTP (`@tauri-apps/plugin-http`, no CORS).
Returns `null` outside SaaS mode.
- `desktop/hooks/useSaasAppConfig.ts` — hook over it; reloads on
connection-mode change.
**AI gating + routing seams:**
- `useAiEngineEnabled()` — core reads `useAppConfig()` (web), desktop
reads `useSaasAppConfig()`. `ChatFAB` consumes it.
- `getAiBaseUrl()` — core uses the normal API base (web), desktop points
AI calls at the SaaS backend. `ChatContext` uses it for the orchestrate
stream + result-file download.
- `operationRouter` — route `/api/v1/ai/*` to the SaaS backend
(cloud-only prefix).
**Docs:** AGENTS.md gains a short "cloud feature flags on desktop" note
so the pattern is maintained.
## Verification
- `tsc --noEmit` green for saas / desktop / cloud flavors
- `eslint --max-warnings=0` clean (cloud-layer guardrail respected — the
platform-coupled bits live in `desktop/`)
- New `saasAppConfigService.test.ts` (3 tests) + existing
`operationRouter` / `tauriHttpClient` / `httpErrorHandler` suites green
- 0 stray compiled artifacts
## Not headlessly verifiable — needs a live Tauri smoke
The orchestrate **SSE stream** uses the webview's global `fetch` (native
HTTP can't stream the body the same way), so it's subject to browser
CORS to the SaaS backend. The `SupabaseSecurityConfig` tauri-origin
allowance (from #6649) covers it, but please confirm on a real build:
open the FAB in SaaS mode, run an agent task, watch the stream + a
result-file download succeed.
- Surface the real reason an MCP token is rejected: the 401's
WWW-Authenticate header now includes error_description
(audience/issuer/expiry), and a present-but-rejected token logs the
concrete OAuth2 reason. Tokenless 401s (the normal discovery handshake)
stay at debug.
- Add McpConfigValidator that sanity-checks MCP config at startup and
logs actionable warnings (missing issuer-uri/resource-id, unrecognized
auth mode, sub + require-existing-account, open access, scopes,
allow/block overlap) so misconfig shows up in the logs before a client
ever connects.
- Align the audience-rejection message to mention both resource-id and
accepted-audiences.
- Harden audit writes: hash JWT-shaped or over-long principals
(token:<sha256-prefix>) so the insert fits the column and never stores a
raw bearer token, and stop logging the raw principal on persist failure.
---
## 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.
# Description of Changes
One of the Playwright tests is flaky, despite several attempts to fix it
before it made it into main. This disables the test for now so a
followup PR can try to fix it again.
Closes#6518
# Cause of the bug
This is a fix to the #6518 issue. The bug happened because the link
toolbar was rendered inside the PDF page layer. That layer can be
affected by the viewer/page rotation transform, so the toolbar was laid
out using local page coordinates and then visually transformed together
with the page.
As a result, the placement logic could calculate a position that was
correct in the page’s local coordinate space, such as above or below the
link, but the parent transform would rotate or shift that result after
layout. On rotated pages, this could make the toolbar appear on the
wrong side, inverted, or misaligned relative to the link.
More specifically, in the PDF that exposed the bug, the page content
appears to have been authored upside down and then corrected with a
180-degree page/viewer rotation so it looks normal to the user.
Because the toolbar was rendered inside the same transformed page layer,
it inherited that 180-degree rotation as well. The PDF content looked
upright because the rotation was part of how the page was displayed, but
the toolbar is viewer UI and should not be rotated with the page. As a
result, the tooltip appeared upside down even though the PDF itself
looked correct.
# Description of Changes
Fixes the inverted link tooltip/toolbar positioning in rotated PDF
viewer pages.
The link toolbar is now rendered through a body portal and positioned
from the link element’s real viewport bounds, so page rotation
transforms no longer flip or misalign it.
The update also keeps the toolbar within the viewport during scroll,
resize, zoom, and rotation changes, preserves the hover delay between
the link and toolbar, centralizes the z-index in a shared constant, and
improves label sizing to avoid clipped text.
Note: The link hover styling was also changed from an underline to a
subtle rectangular highlight based on the PDF link annotation bounds.
<!--
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)
UI behaviour before the changes :
<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-12-10"
src="https://github.com/user-attachments/assets/321edbb3-42a2-4bc3-96ad-3ccc70a355b8"
/>
<img width="762" height="496" alt="Captura de tela de 2026-06-16
00-11-46"
src="https://github.com/user-attachments/assets/be4c1af4-5488-4a54-9b6f-675e3bea73b8"
/>
<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-12-57"
src="https://github.com/user-attachments/assets/60f44cd5-c772-44a8-97c8-bde135764e53"
/>
UI behaviour after the changes :
<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-22-02"
src="https://github.com/user-attachments/assets/dda77bda-0780-4807-a70d-3bbc60683e5a"
/>
<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-23-07"
src="https://github.com/user-attachments/assets/5745c37e-438a-4bbe-ba1e-c6f2098421de"
/>
<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-23-24"
src="https://github.com/user-attachments/assets/85932541-4a6f-48e4-879f-41f34a6d79e6"
/>
### 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: James Brunton <jbrunton96@gmail.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
## What & why
Production reports of policy enforcement "hanging" traced to
large/many-page documents: the watermark step's flatten-to-image
(`convertPDFToImage`) on a 500+ page PDF takes minutes, exceeding both
the client poll cap and the backend per-step timeout. This makes the
slow case graceful instead of looking broken, and makes load-shedding
non-fatal.
### Poll runs to completion (no false "hang")
The client poll loop used a flat ~150s cap that was **shorter than the
backend's 300s per-step timeout**, so it abandoned long-but-healthy runs
mid-flight. The budget is now sized to the backend's real worst case —
`stepCount × per-step timeout + grace`, learned from the first status
report — so the client always polls long enough to surface the run's
**actual** terminal state (success or the backend's real error) rather
than a misleading client-side timeout.
### Per-step progress
The activity feed now shows `Enforcing… · step n/m` (from
`currentStep`/`stepCount`), so a slow step shows movement instead of a
dead spinner.
### Soft-retry on queue rejection
Under load the shared `JobQueue` rejects runs ("queue full"), which
previously surfaced as a hard failure needing a manual Retry. The
backend now tags that rejection with a stable `POLICY_QUEUE_FULL`
errorCode; the client treats it as transient backpressure and
**auto-retries the file in place** with exponential backoff (≈4s→64s, ~2
min), shown as a soft "Busy — retrying…" row, falling back to the manual
Retry only once the retry budget is spent.
## Testing
- **Frontend unit tests** (30 pass across the policies suite), including
a new `usePolicyAutoRun.retry.test.tsx` that drives the real controller
orchestration (poll → `POLICY_QUEUE_FULL` → relabel → backoff → in-place
re-dispatch), plus poll-budget, step-progress, and activity-feed relabel
cases.
- **Backend** `PolicyEngineTest` case asserting a queue-rejected run
carries the `POLICY_QUEUE_FULL` code.
- Typecheck clean on all three flavors (proprietary/saas/core); prettier
+ spotless clean.
- Poll-budget + progress + real-error surfacing were also verified live
end-to-end against a 599-page run (survived past the old cap, showed
step progress, reported the backend's real 300s-timeout failure,
recovered after a simulated network drop).
## Not included (follow-ups)
- The underlying flatten-to-image cost itself (bounded-memory/streaming
flatten, revisiting `convertPDFToImage` default and the 300s timeout) —
the real perf fix, deliberately out of scope here.
# Description of Changes
Fix Playwright failing test in SaaS (I think this is my third attempt
now so who knows if this will actually fix it for real this time, but
hopefully it does)
# Description of Changes
> [!warning]
> **Do not** squash this on merge. It should be merged via a merge
commit
Fixes conflicts in `pgvector_store.py`.
Also since codespell is failing, add comments to ignore the errors in
`sync_en_us_spelling.py`
---------
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
# Description of Changes
* Remove complex port selection logic from `engine.yml`. It's
inconsistent with the frontend & backend task files, and caused issues
with Docker, which have been worked around but would be simpler to just
get rid of the problem altogether
* Fix Ruff formatting of Python script
* Remove payg tests which are failing and have drifted too far from the
implementation to save directly
`PolicyController` was annotated `@PremiumEndpoint` (requires a
Pro-or-higher server license). Policies don't need a server-license
gate:
- On SaaS the server runs in Pro mode, so the check is always satisfied
anyway — it gates nothing in practice.
- Access is already governed by team scoping (#6632) plus the per-user /
guest gates.
So the annotation is dead weight and misleading. This removes it (and
its import) — a 2-line change.
## Verification
- `:proprietary:compileJava` succeeds; spotless clean. No other premium
gate on policy classes.
Guests (anonymous users on a login-enabled deployment) could open a
policy's setup/detail. Policies are an account feature, so a guest
clicking a policy should be nudged to sign up rather than opening it.
## Behaviour
A guest clicking a policy row — or a collapsed-rail icon — now
**re-summons the existing guest sign-up banner** ("You're using Stirling
PDF as a guest!…") and does **not** open the policy.
- `GuestUserBanner` listens for a `stirling:show-guest-banner` window
event and re-shows (even if previously dismissed; the render guard still
hides it for non-anonymous users).
- The policy sidebar dispatches that event on a guest click (cross-layer
via `CustomEvent`, same pattern as `payg:signupRequired`; a no-op on
builds without the banner).
- `usePolicyGuestBlocked()` gates it: `config.enableLogin === true &&
user.is_anonymous === true`.
- **Login-disabled single-user** deployments have an anonymous local
operator with full access → not gated.
## Verification
- Typecheck clean (proprietary + saas); eslint clean; sidebar tests
pass.
## Note
No dedicated guest unit test — the suite mocks `useAppConfig` at module
scope, and making it per-test controllable needs `vi.hoisted` plumbing
that risked the existing tests. Easy follow-up.
## Problem
We're getting 402s when an AI **agent** (chat) run hits the free
allowance / spending cap, but the frontend handles them poorly and never
pops the usage-limit modal.
The agent runs its tool calls **server-side** (loopback HTTP via
`PolicyExecutor`), so the 402 never reaches the `apiClient` interceptor
that pops the modal for direct calls. It was caught by the generic
tool-failure handler and flattened into a `CANNOT_CONTINUE` reason
string (`"The /api/v1/… tool failed: 402…"`), streamed as a `result`
event, and rendered as a scary chat bubble. This is the same gap the
policy auto-run path bridges (#6626) — one layer up.
## Fix
**Backend** (`proprietary`)
- `AiWorkflowResponse` gains `errorCode` + `errorSubscribed`.
- `AiWorkflowService` detects a downstream 401/402 entitlement sentinel
in its three tool-exec catch sites (`onToolCall`, `runPlan`,
`onConvertMarkdown`) and surfaces the structured code (+ `subscribed`)
on the terminal response instead of the raw failure text.
- Factored the 401/402 body extraction `PolicyEngine` already had into a
shared `DownstreamEntitlementError` util so the two server-side paths
can't drift.
**Frontend**
- New `usageLimitBridge` (`PAYG_LIMIT_REACHED_EVENT` +
`dispatchPaygLimitReached`) generalises the previously policy-only
bridge. Proprietary can't import the saas modal API (layering), so
server-side limit hits broadcast a window event the saas
`UsageLimitModalHost` opens the modal from. Migrated the policy path
onto it.
- `ChatContext` fires the matching modal (free → subscribe, subscribed →
raise cap) on the limit result **and** on a direct 402, replacing the
raw reason with a brief friendly line
(`chat.responses.usage_limit_reached`).
No Python engine changes — the charge/402 happens on the Java tool
endpoint that Java itself calls.
## Test plan
- [x] `:proprietary:compileJava` + `spotlessCheck` clean
- [x] `AiWorkflowServiceTest` + `PolicyEngineTest` green
- [x] eslint, proprietary + saas typechecks clean
- [ ] Manual: drive an agent run over the limit → brief line in chat +
the right modal (free vs cap)
> Note: proprietary test compilation is currently blocked on the
pre-existing `InitialSecuritySetupTest` 6-arg ctor break (unrelated,
tracked separately); verified locally by temporarily patching it.
add en-US changes to SaaS, previously merged into main. So this is
effectively a main -> SaaS PR also. It seems to be all additive.
Also take the 230 ish missing translations from en-GB over to en-US
using a script, and also make and english spellings American when adding
them to the en-US file, and fix any existing American spellings in the
en-GB file.
# Description of Changes
Search has got significantly worse since #6581, where I added all the
missing tags for tools that should have been there for months. Turns out
that the fuzzy matching search logic has always been way too permissive
to match words with Levenshtein distances way too far away from the
target word, so long searches include way too much stuff. The new tags
just exposed that underlying logic issue. This PR makes the Levenshtein
logic much stricter, so it is still tolerant to minor typos in tool
names, but doesn't match completely inappropriate strings.
Frontend follow-up to #6632 (team-scoped policies, editing gated to team
leaders on the backend). Brings the UI's edit gate in line.
## Problem
The policy config UI gated editing to `config.isAdmin`. On SaaS, org
users are never the single global admin, so **no one could open the
policy editor** — the same lockout #6632 fixed on the backend.
## Fix
`usePolicies` now allows a **team leader** to configure, falling back to
a global admin self-hosted:
```ts
canConfigure =
config != null && (!config.enableLogin || isTeamLeader || config.isAdmin === true);
```
- SaaS → `isTeamLeader` (from `useSaaSTeam()`) — team leaders can
configure; members get the read-only surface.
- Self-hosted → `config.isAdmin` (the core `useSaaSTeam` stub returns
`false`, so admins aren't locked out).
- Login disabled (single-user) → always allowed.
- The `config != null` guard keeps the gate closed until app-config
resolves, so edit controls never flash for users who can't use them.
The two locked-policy banners now read "Contact a team leader to change
this policy" (updated in the `t()` defaults and the `en-GB`
translations).
## Verification
- Typecheck clean (proprietary + saas); eslint clean.
- Tests pass: `usePolicies`, `PoliciesSidebar`.
## Problem
When the `EntitlementGuard` refuses a request with **402** (team is over
its free allowance / spending cap, or has no subscription to bill), the
handler never runs — so it must not charge. But it did: the guard (order
**1100**) ran *after* the charge interceptor (**1000**), so
`openProcess` had already written the charge before the 402, and
`afterCompletion` then billed it as "customer paid for the attempt" — a
ledger debit, and a **Stripe meter for a subscribed-over-cap team**.
## Fix
**Run the guard first** (order **900**, before the charge interceptor at
1000). Spring runs interceptors in ascending order on the way in and
**skips a later interceptor's `preHandle` (and `afterCompletion`)
entirely once an earlier one returns `false`** — so a refused request
short-circuits with its 402 *before* the charge interceptor runs at all.
A blocked request never opens a process, materialises inputs, or writes
a charge.
This replaces the earlier attribute-flag + `afterCompletion`-refund
approach with a simpler reorder (per review): the no-charge-on-block
guarantee is now **structural**, and it also avoids the wasted
open-then-refund churn (no temp-file write, no debit/refund pair) for
refused requests.
### Why the reorder is safe
- `EntitlementGuard` reads no `PaygChargeInterceptor` state and has **no
`afterCompletion`** (only `preHandle`), so reverse-order teardown is a
non-issue.
- The legacy `UnifiedCreditInterceptor` (default order **0**, and only
registered under the `legacy-credits` profile) still runs first, so any
legacy rejection wins.
- For *admitted* requests both interceptors still run (guard then
charge) — behaviour is unchanged; only refused requests now
short-circuit before the charge.
## Tests
`PaygWebMvcConfigTest` locks the `ENTITLEMENT_GUARD_ORDER <
INTERCEPTOR_ORDER` invariant (if it's ever reversed, refused requests
would bill again — this fails first). Existing `EntitlementGuardTest`
already proves the guard returns 402 on a degraded/billable request.
`:saas:test` + spotless green.
## Related (separate, in progress)
The **fail-cleanly + fire-the-modal** half (suppress the error toast,
trigger the subscribe/raise-cap modal via the existing
`subscribed`/`category` signal — catching the 402 centrally so direct
API usage is handled, and propagating the entitlement reason through the
async policy-run status) lands **with Ethan's modal** so we don't remove
the toast before there's a popup to replace it.
## Problem
Two AI surfaces slipped through PAYG unbilled:
1. **AI document tools** — `/api/v1/ai/tools/**`
(`PdfCommentAgentController`, `MathAuditorAgentController`) live in the
**proprietary** module, which can't depend on `saas` and so can't carry
the saas-only `@RequiresFeature`. They also lacked
`@AutoJobPostMapping`, so the charge interceptor's scope gate
short-circuited them **before** category resolution: **not charged, and
not even entitlement-gated** — whether called directly or dispatched by
the orchestrator.
2. **AI Create** — `/api/v1/ai/create` is JSON/session-based with no
file input, so the multipart charge path never fired. The old
per-generation charge ran through the now-dead legacy credit system, so
it currently charges nothing.
## Fix
- **`AiToolRoutes`** (new, saas) — single source of truth for the
`/api/v1/ai/tools/**` prefix. The proprietary controllers stay
untouched; the saas hot-path recognises them by path:
- **`PaygChargeInterceptor`**: brings these routes into scope and bills
them **AI** on a direct call. An orchestrator-dispatched call still
resolves to **AUTOMATION** first (the `X-Stirling-Automation` header is
checked before the path rule), so AI-tool-inside-a-workflow keeps
billing as automation.
- **`EntitlementGuard`**: gates them on **`AI_SUPPORT`**.
- This keeps the `proprietary → saas` layering intact (no backwards
dependency).
- **`JobChargeService.chargeStandalone(ctx, units)`** — charges a fixed
unit count for a non-file billable action, reusing the existing
free-grant split + shadow row + ledger debit + `close()`→meter path on a
standalone bookkeeping job (no lineage inputs, so nothing lineage-joins
it). **`JobService.open(ctx, docUnits)`** opens that bare job.
- **`AiCreateController.createSession`** — charges **one document per
session** at creation (best-effort; entitlement is already enforced
upstream by the class-level `@RequiresFeature(AI_SUPPORT)`). Follow-up
edits (`outline` / `reprompt` / `draft` / `template` / `stream`) carry
**no** charge — they have no charge hook, so "charge on create,
follow-ups free" falls out naturally.
Per the agreed scope: charge AI Create on create now; we can optimise
follow-up handling later. **AI workflow categorisation (AUTOMATION vs
AI) intentionally left as-is** (the orchestrator's automation header
dominates by design).
## Tests
- `PaygChargeInterceptorTest`: AI-tool route (no annotations) is in
scope + **AI** category; same route with the automation header →
**AUTOMATION**; a plain non-AI route still short-circuits.
- `EntitlementGuardTest`: AI-tool route is in scope + gated on
**AI_SUPPORT** (degraded team → 402; anonymous → 401 with `category:
AI`).
- `JobChargeServiceTest`: `chargeStandalone` charges + meters the paid
portion for a subscribed team, draws the free grant (no meter) for an
unsubscribed team, and rejects `BYPASSED`.
`:saas:test` + `:saas:spotlessCheck` green; coverage gates met.
## Follow-ups (not in this PR)
- Make AI Create follow-ups explicitly cheaper / chained if we want
(currently free by absence of a hook).
- Decide whether AI-tool-inside-a-workflow should bill as AI rather than
AUTOMATION.
## Problem
A **free team can invite a paid team's leader** to join. The leader
accepts, and:
- `acceptInvitation` moves them onto the inviting team and removes their
membership from the old team, but only deletes the old team if it's
**personal**.
- Their paid (non-personal) team is left **memberless but still
subscribed** — an orphaned Stripe subscription billing for a team nobody
is in.
## Root cause
Two gaps in `SaasTeamService.acceptInvitation`:
1. The pre-accept guard checks `hasPaidSubscription(acceptingUser)` →
`existsActivePaidSubscriptionForUser(supabaseId)`, keyed on
**`user_id`**. A team's plan is keyed on **`team_id`**
(`existsActiveSubscriptionForTeam`), so a paid team's *leader* isn't
caught and accepts freely.
2. The 'leave existing teams' loop deletes the membership directly and
only marks **personal** teams for deletion — it bypasses the last-leader
protection `leaveTeam` already enforces (`"Cannot leave as the last team
leader. Transfer leadership first."`), and never cleans up / cancels the
non-personal team.
There is no in-app subscription-cancel path (cancellation is
Stripe-portal/webhook driven), so nothing reconciles the orphan after
the fact — it has to be prevented.
## Fix
Add `assertCanLeaveCurrentTeamsToJoinAnother(user)`, called in
`acceptInvitation` before any membership changes. For each non-personal
team where the user is the **last leader**, the accept is rejected:
- team has an active subscription → *"Cancel the plan or transfer
leadership before joining another team."*
- otherwise → *"Transfer leadership before joining another team."*
This mirrors the protection `leaveTeam` already has and is
team/leadership-aware, closing the `user_id`-vs-`team_id` gap. Regular
members and teams with another leader are unaffected.
## Verification
- `ENABLE_SAAS=true ./gradlew :saas:compileJava` — passes.
- Manual: with a paid team leader, accepting an invite to another team
should now be rejected with the message above; verify a non-leader
member can still accept.
## Note
This prevents *new* orphans. Any teams already orphaned by this bug
(memberless, still subscribed) would need a one-off reconciliation —
happy to follow up with a query/cleanup if useful.
## Problem
A policy ran over a file but the owner's **free usage was never
consumed**.
A policy run executes on a **background virtual thread**
(`PolicyEngine.submit` → `asyncExecutor`), and Spring's
`SecurityContextHolder` is thread-local — so the worker thread has no
identity. When `PolicyExecutor` → `InternalApiClient.post` resolves the
tool-call API key via `UserService.getCurrentUsername()`, it finds
nothing and falls back to the **`INTERNAL_API_USER`** key. The loopback
tool calls then authenticate as that system account, so
`PaygChargeInterceptor` attributes the charge to *its* team (or none) —
the real owner's free grant is untouched. Folder-watch / scheduled
triggers are even further removed (fired from a background watch loop
with no request context at all).
The charging *mechanism* was fine (AUTOMATION, multipart,
`openProcess`); only the **attribution** was wrong.
## Fix
Propagate the acting identity onto the worker thread using the
**audit-principal MDC key** that `UserService.getCurrentUsername()`
already reads as its documented async fallback (the same mechanism used
for other async jobs). No new plumbing through the executor.
- **`runPolicy`** (stored policies — covers triggers *and* manual
`runWith`) → bill the **policy owner**. `Policy.owner` is the username
stamped at creation, so `getApiKeyForUser(owner)` resolves it.
- **`submit`** (ad-hoc Automate/AI one-offs) → bill the **submitting
user**, captured on the request thread (it doesn't survive the hop to
the worker otherwise).
With the principal set, `InternalApiClient` dispatches each tool call as
that user → the interceptor resolves the right team → free grant draws /
Stripe meters correctly.
## Tests
`PolicyEngineTest`:
- `runPolicyDispatchesToolCallsAsTheOwner` — asserts MDC
`auditPrincipal` == the policy owner at the moment
`InternalApiClient.post` is invoked.
- `adHocRunDispatchesToolCallsAsTheSubmittingUser` — asserts it's the
submitting user for an ad-hoc run.
`:proprietary:test` + `:saas:test` + spotless green; coverage gates met.
## Heads-up (not in this PR)
Once attributed, **automatic folder-watch / scheduled runs consume free
grant (or bill) per file** — set up once, runs forever. That's
automation-is-billable working as intended, but a set-and-forget policy
can drain an allowance fast, so it may warrant a per-policy cap or a
heads-up in the UI. Flagging for a product decision.
Follow-up to #6604 (merged). Builds the Security policy out so it
actually enforces, driven from the editor.
## What it does
- **Run on upload or export** — a single choice in the wizard: enforce
when a file is uploaded, or just before it's exported.
- **Output** — enforced result is a **new version** of the file
(default) or a **new file**, with optional filename
prefix/suffix/auto-number ("Output filename" subsection; auto-number
only for new files).
- **Export enforcement** — exporting an export-mode file runs the policy
first and downloads the enforced result; never hard-blocks (on failure
the original downloads). For new-version policies the in-editor file is
versioned too. Covers every export path incl. multi-file ZIP. A toast
(glowing in the policy's accent while it runs) reports progress and
fades after ~10s.
- **Affordances** — a freshly enforced file briefly glows its policy
accent and carries a shield badge.
- **Config tidy-up** — removed the unwired Security setting fields + the
wizard's review step; "Upgrade to enterprise" on locked categories;
category accent in the detail/wizard headers.
## Notes
- Builds on the manual-only (client-driven) policy model from #6587
(`trigger: null`, metadata in `output.options`), adding the `runOn`
field + export-time enforcement.
- The page-editor merge-export (no single source file) enforces +
downloads but doesn't version in place.
## Verification
typecheck (core + proprietary), eslint, prettier; proprietary suite
(105) green; flows checked in-app.
## Problem
When a user is invited to a team, the SaaS web app shows **no invitation
banner** — even though the pending invite is returned by
`/api/v1/team/invitations/pending` on refresh.
## Root causes
1. **Never rendered in SaaS.** `TeamInvitationBanner` only existed in
`desktop/`, wired solely into `DesktopBannerInitializer`. The SaaS
banner stack rendered only `<UpgradeBanner />`.
2. **Single banner slot.** `BannerContext` holds one node; `setBanner`
replaces it. `TrialStatusBanner` called `setBanner(null)` when there was
no active trial (and re-fired once `trialStatus` resolved async), wiping
any other banner.
3. **Shadowing was too fragile.** A first attempt shadowed the
proprietary `UpgradeBannerInitializer` from the saas layer, but
`vite-tsconfig-paths` resolves the `@app` specifier once at dev-server
start — a newly-added shadow of an already-resolved module isn't picked
up on a browser refresh, only a full restart. So the proprietary
initializer kept running and no invite banner appeared (while the SaaS
team context still fetched + populated the invite, which is why the
pending call was visible).
## Fix
- Add `saas/components/shared/TeamInvitationBanner.tsx` — ported from
desktop, minus the desktop `connectionMode` gate and explicit billing
refresh (SaaS `acceptInvitation` already refreshes credits + session).
- Render it **inline in `saas/routes/Landing.tsx`** next to
`GuestUserBanner` — a new import specifier in an existing file
(HMR-friendly), unambiguously inside `SaaSTeamProvider`, mirroring the
proven `GuestUserBanner` pattern. No dependency on the single banner
slot.
- **Remove `TrialStatusBanner`** (trials are being retired) so it can't
clobber banners. Also drops the stale mention from the stripe-lazy-load
test comment.
## Verification
- `tsc --noEmit -p tsconfig.saas.vite.json`: clean in touched files;
total unchanged from baseline (37 pre-existing, unrelated).
- Manual: pull + verify the Accept/Decline banner appears for an account
with a pending invite.
# Description of Changes
Kill off agents pane now that we have the FAB. Also fixes a bug with the
FAB where it would sometimes fail to render the chat, and fixes a
duplicated entry in the Vite config which was throwing a warning
## Problem
A team that **cancelled** its PAYG subscription kept full subscribed
access:
- **UI didn't reflect cancellation** — the Plan tab still rendered the
subscribed view, never the free/upgrade view.
- **Automation wasn't stopped** — automation / AI / API kept running
without ever falling back to the free-grant gate.
## Root cause
`TeamBillingService.compute` decided `subscribed` as:
```java
boolean subscribed =
subscriptionId != null
|| extOpt.map(PaygTeamExtensions::getStripeCustomerId).filter(s -> !s.isBlank()).isPresent();
```
On cancellation, the `customer.subscription.deleted` webhook calls
`payg_unlink_subscription`, which nulls `payg_subscription_id` but
**deliberately keeps `stripe_customer_id`** (so a future re-subscribe
can reuse the Stripe customer).
`payg_link_subscription` is the **only** writer of
`payg_team_extensions.stripe_customer_id`, and it writes it in the
*same* `UPDATE` as `payg_subscription_id` (on
`customer.subscription.created`). So the customer id is never set before
the subscription id — the "pre-webhook stand-in" the old comment claimed
**cannot happen**. The fallback only ever pinned a team that *ever*
subscribed to `subscribed` forever, because the Stripe customer outlives
the subscription.
Both symptoms are this one flag:
- `PaygWalletController` status → `SUBSCRIBED` vs `FREE`
- `EntitlementService` gate branch → monthly-cap vs free-grant
## Fix
Gate `subscribed` purely on `payg_subscription_id != null`. A cancelled
team now correctly drops to free (UI shows free; billable ops gate on
the one-time grant). This aligns the wallet/entitlement read with the
**meter path** (`JobChargeService.close`), which already gated on
`payg_subscription_id`.
Handles both Stripe cancel modes: "cancel at period end" keeps the sub
`active` (id stays set) until `.deleted` fires at period end → access
through the paid period; immediate cancel fires `.deleted` now → flips
to free now.
**No data migration / backfill** — already-cancelled teams have
`payg_subscription_id = NULL`, so they flip to free as soon as this
ships (within the 30s billing-cache TTL).
## Tests
Adds `TeamBillingServiceTest` — the `subscribed` computation previously
had **no** unit coverage (which is how this shipped). Covers: subscribed
iff subscription id present; **cancelled team (customer id remains,
subscription id null) ≠ subscribed** + free grant survives;
no-subscription/no-customer; no extension row.
`:saas:test` + `:saas:spotlessCheck` green; coverage gates met.
## Not included (optional hardening, can fast-follow)
- Cross-check the synced `stripe.subscriptions.status` to guard a
*missed* `.deleted` webhook leaving `payg_subscription_id` stale.
- Push cache-invalidation from the webhook (currently ≤30s TTL
staleness).
## What
Removes all em dash (`—`) characters from the **user-facing text** on
the Plan page (PAYG section), replacing them with colons, commas, or
restructured punctuation so the copy reads naturally.
## Changes
- `frontend/editor/public/locales/en-GB/translation.toml` — all `payg.*`
strings (this is what actually renders on the page)
- `PaygFree.tsx` — `t()` default fallbacks + the `{" — "}` JSX
benefit-list separators (now `{": "}`)
- `Payg.tsx` — `t()` default fallback for the editor-plan body
## Notes
- The en-dash range separator (`{{start}} – {{end}}`) in the
billing-period string is intentionally **kept** — only em dashes were
targeted.
- JSDoc / code comments containing em dashes were **left unchanged**,
since they aren't rendered text on the page.
<img width="990" height="502" alt="image"
src="https://github.com/user-attachments/assets/13d89b0f-007c-4b4c-b72d-1d912f968bc7"
/>
# Description of Changes
- Use pool for postgres connections
- Add ability to require user ID to be set on API calls to the engine
- Add process-wide concurrency cap on AI access (in addition to existing
user caps)
- Allow number of workers (threads) to be specified for stirling engine
- Update env var names to reflect that the DB is not just for RAG
# Description of Changes
Remove the Pro guards from the Team settings page and also fix the
styling of the MCP settings screen (the code sections were black text on
black background in light mode)
## Summary
Pay-as-you-go (PAYG) billing for Stirling-PDF SaaS. Manual PDF editing
stays free forever; only **automation, AI, and API** usage is metered.
Every team gets a **one-time lifetime free grant** (default 500 PDFs)
before any billing; past that, a team adds a card and pays per metered
document, with a self-set monthly spending cap.
This branch combines and supersedes the in-flight BE (#6574) and FE
(#6579) work plus the SaaS edge functions (Stirling-PDF-SaaS PR, now on
`v3`), hardened into a single reviewable feature after a pre-merge
dead-code/security review.
## Billing model
- **Always free:** manual / JWT web-tool usage is `BYPASSED` — never
metered, no matter where it's triggered.
- **Billable categories:** `AUTOMATION`, `AI`, `API`.
- **One-time lifetime free grant** (`pricing_policy.free_tier_units`,
default 500): never resets, survives subscribing. It gates unsubscribed
teams (billable API calls hard-stop with a 402 once exhausted) and
decides the free-vs-paid split of every job.
- **Subscribed:** paid documents (beyond the grant) are metered to a
Stripe Billing Meter; an optional monthly spending cap degrades billable
categories when reached.
- **Dedup:** the same file pushed through several steps within a
workflow window counts **once** (lineage join), so API/AI chaining on
one file isn't double-charged.
## What's included
**Database** — Flyway migrations `V11`→`V21` with matching Supabase
twins: pricing policy + per-team sidecar (`payg_team_extensions`:
subscription id, Stripe customer, free-grant counter), append-only
`wallet_ledger`, shadow charges, subscription-state RPCs (`V14`), audit
logs (`V15`), billing category (`V16`), one-time lifetime free grant
(`V19`), launch-grant seed (`V20`), drop of the unused
`wallet_category_summary` view (`V21`).
**Charge pipeline** — `PaygChargeInterceptor` (open/join a process,
split the free grant, write the ledger DEBIT), `JobChargeService`
(consume the grant under a row lock, restore it on a first-step refund,
meter only the paid portion on completion), `StaleJobCloser` fallback
(idempotent close → meter).
**Entitlement** — `EntitlementService` (per-team cached snapshot:
grant-gated for free teams, monthly-cap-gated for subscribed) +
`EntitlementGuard` (401 `SIGNUP_REQUIRED` / 402 `FEATURE_DEGRADED` /
`PAYG_LIMIT_REACHED`).
**Metering** — `PaygMeterReportingService` writes a durable
`payg_meter_event_log` row around every POST to the `meter-payg-units`
edge fn (pending → posted/failed); `PaygMeterReconcileScheduler` retries
unposted events under the same idempotency key inside Stripe's 24h dedup
window.
**Billing facts** — `TeamBillingService` reads the synced `stripe.*`
mirror (subscription window, per-document rate; the unsubscribed-team
estimate resolves the rate by Price `lookup_key = plan:processor`).
**Wallet API** — `PaygWalletController`: `GET /api/v1/payg/wallet`,
`PATCH /api/v1/payg/cap`.
**Frontend** — PAYG Plan page (two-card free layout + subscribed views),
`useWallet`, upgrade modal with lazy-loaded Stripe Embedded Checkout and
a shared `SpendCapControl`, customer-portal link, 402/401 interceptor
toast, en-GB i18n. (Per-member usage shows each teammate's spend; the
activity feed is behind a flag until polished.)
**SaaS edge functions** (`Stirling-PDF-SaaS` `v3`) —
`create-checkout-session`, `create-payg-team-subscription`,
`create-customer-portal-session`, `meter-payg-units`,
`payg-subscription-webhook`, `stripe-sync`, plus the stripe-sync
`migrate` + scoped-`backfill` scripts. All price lookup is DB-driven (no
`STRIPE_PAYG_PRICE_ID_*` env vars).
## Release prerequisites (prod)
1. Apply Flyway migrations (`V11`→`V21`) and the Supabase migration
twins.
2. Stripe Sync Engine: run `stripe-sync:migrate`, then a **scoped**
backfill — `product`, `price`, `customer`, `subscription` only (not
`all`, which rate-limits).
3. Register 2 PAYG webhook endpoints (each its own signing secret):
`stripe-sync` (product/price/customer/subscription `.*`) and
`payg-subscription-webhook` (`customer.subscription.created`/`.deleted`
drive state; `.updated` + `invoice.*` observed). Keep the legacy
`stripe-webhook` only if credits/self-hosted flows still run.
4. Stripe Billing Meter: `event_name = payg_doc_units`, value key
`processed_documents`.
5. Env: `PAYG_METER_ENDPOINT` + `SUPABASE_EDGE_FUNCTION_SECRET`
(backend); the webhook signing secrets (edge fns). The default pricing
policy must point at the PAYG Stripe Price(s); `V20` seeds
`free_tier_units = 500`.
## Testing
- `:saas:test` green, `:saas:spotlessCheck` clean, edge-fn Deno tests
green, FE saas typecheck clean (the remaining errors are pre-existing
`proprietary/*` + `prototypes/*`, untouched here). Cucumber shadow-mode
suite + CI workflow included.
## Pre-merge review
An independent dead-code/security pass came back **clean on security**
(team-derived authz / no IDOR, leader-only cap mutation, no
billing-category downgrade, dev/mock hooks gated to
`import.meta.env.DEV` + `/dev/`, no secrets/injection, fail-open
metering by design). The dead/unwired code it flagged has been removed
in this branch (unenforced sub-cap control, an unused JDBC DAO + its
view, dead methods).
## Follow-ups (tracked, not blocking)
- **Enforce per-member sub-caps** — the control was removed because it
read for display but never gated a request; the per-member usage display
and `cap_units` column are retained for when enforcement is wired.
- **API/AI chaining billing model + `ProcessType` enum** — confirm
same-file dedup covers API chaining; define per-tool AI charging; decide
whether the unused enum values stay.
- **Activity feed** — hidden behind a flag until the meter-event surface
is polished.
---------
Co-authored-by: Reece <reece@stirlingpdf.com>
Added the create agent. Use [these
prompts](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/blob/main/docgen/backend/default_templates/sample_prompts.md)
to test or try your own :)
Here’s the one I use
```
Hey, I need to generate an employee expense report for reimbursement.
Company: Summit Consulting Partners Company address: 88 Riverside Plaza, Suite 1400, New York, NY 10069 Accounting department email: expenses@example.com
Employee details:
* Employee Name: Michael Tran
* Employee ID: EMP-1047
* Department: Client Services
* Report Date: January 20th, 2026
* Reporting Period: January 5th, 2026 – January 16th, 2026
* Manager Approver: Laura Simmons
Trip purpose: Client onsite meetings with Atlantic Energy Solutions in Boston, MA.
Expense items:
* Flight (NYC to Boston roundtrip) — $325.40 — January 5th, 2026 — Airline ticket
* Hotel (3 nights at Harborview Hotel) — $822.75 — January 5th-8th, 2026
* Taxi from airport to hotel — $48.00 — January 5th, 2026
* Client dinner (3 attendees) — $186.20 — January 6th, 2026
* Parking at JFK Airport — $72.00 — January 5th-8th, 2026
* Breakfast (per diem not used) — $18.50 — January 7th, 2026
* Uber to client office — $22.10 — January 7th, 2026
* Printing + presentation materials — $46.90 — January 8th, 2026
* Lunch with client — $39.75 — January 8th, 2026
* Office supplies (notebooks, pens) — $27.60 — January 10th, 2026
* Mileage reimbursement (client visit in NJ, 42 miles @ $0.67/mile) — $28.14 — January 14th, 2026
* Team lunch meeting (internal) — $64.30 — January 15th, 2026
Reimbursement method should be direct deposit.
Add a notes section stating: "All receipts attached. Expenses are business-related and comply with company travel policy."
```
---------
Co-authored-by: Anthony Stirling <77850077+frooodle@users.noreply.github.com>
Follow-up to #6598 (squash-merged into `SaaS`). These are the policy
refinements made after that merge, against the current `SaaS` tip.
## Changes
- **Simplify Security config + plain-language info buttons** — Redact
config reduced to the PII field; Sanitise has no config
(JavaScript-removal only) with a non-technical info button; per-tool
info buttons reworded to match the tool-steps style.
- **Hide 'Flatten PDF pages to images' from the watermark policy
config** — new `PolicyWatermarkConfig` wrapping the watermark settings
with the flatten checkbox gated off.
- **Flatten-to-image on by default for redact + watermark** — both
normalise `convertPDFToImage: true` on mount.
- **Self-heal a stale backing folder** — `ensurePolicyFolder` recreates
a backing folder whose `folderId` no longer resolves (preferring the
backend's stored automation), instead of hanging Edit Settings on a
permanent "Loading…".
- **Version the input file on 'new version' output mode** — completed
runs whose policy output mode is `new_version` replace the input file
with a versioned child (origin tool `automate`) rather than adding a
separate file; falls back to a new file if the input is gone.
`outputMode` is plumbed through `PolicyState`, the local-cache default,
and backend reconciliation.
## Verification
- `typecheck:proprietary` + `typecheck:core` clean
- policy + hooks vitest: 17 passing
- eslint + prettier clean on all changed files
# Description of Changes
* Improve typing of API (breaking change but unreleased, frontend also
updated in this PR)
* Add ownership concept to policies
* De-AI the comments
* Update the `task dev:saas` rule to spawn the engine as well
A configured frontendUrl/server_url already includes the subpath (e.g.
/bpp), but the code also applied withBasePath, producing /bpp/bpp/...
Append the route directly to a configured URL; reserve withBasePath for
the bare-origin fallback. Matches the ShareFileModal convention.
## Summary
Adds the **Policies** feature (proprietary, behind the
`POLICIES_ENABLED` flag): backend-driven enforcement that runs a fixed
tool pipeline on documents, docked in the right tool sidebar alongside
Tools.
## Highlights
- **Policy catalog** — 5 categories; **Security** is wired (redact PII +
sanitize), the others are marked "Coming soon".
- **Backend as source of truth** — policies persist via the Policies
engine (`/api/v1/policies`), one policy per category, with a local cache
+ offline fallback.
- **Auto-run** — enabled policies run on every uploaded file: dispatch →
poll → import outputs into the workspace.
- **Security redact config** — PII preset dropdown + custom word/regex
entry + advanced options; tool params map to the backend endpoint
fields.
- **Activity feed** with retry on failures; **file badges** showing
which policies ran on a file (sidebar + files page), tinted to the
policy colour.
- Reuses the **Watched Folders** engine for each policy's backing
folder; policy-owned folders are filtered out of the Watched Folders UI.
## Notes
- Gated by `POLICIES_ENABLED` (true in proprietary, false in core) —
unreachable in the open-source build.
- Frontend-only diff; depends on the backend Policies engine and the
merged Watched Folders feature.
# Description of Changes
Add team settings UI to SaaS, which is currently only available in
desktop. It'd be nice to refactor this so they're more shared, but
they're slightly different so needs to be done with some care. Leaving
for followup work.
The home page's bottom-left settings button is FileSidebar's bottom
bar, which hardcoded an initials circle - the avatar work in
useConfigButtonIcon only affects the QuickAccessBar rail, which the
home page doesn't render. Add a layered useProfilePictureUrl hook
(core stub returns null; saas returns the auth context URL) and render
the picture inside the existing avatar circle, falling back to the
initial when absent or on image load failure.
The saas chain authenticates bearer requests twice:
SupabaseAuthenticationFilter builds an EnhancedJwtAuthenticationToken
with the resolved User principal, but BearerTokenAuthenticationFilter
(oauth2ResourceServer) then re-authenticates the same token through the
static toAuthentication converter and overwrites the SecurityContext
with a token whose principal is the raw Jwt - so storage endpoints kept
returning 401 "Unsupported user principal" despite the principal fix.
Carry the User across in the converter: when the context already holds
an EnhancedJwtAuthenticationToken for the same subject with a User
principal, attach that User to the converter-built token. No extra DB
lookups; anonymous sessions and API-key auth unchanged. Covered by new
unit tests (carry, no-context, subject mismatch).
The bottom-left settings button and the settings page both read
profilePictureUrl, but only the settings page had a fallback (initials
avatar) - the button silently fell back to a gear. The URL itself was
usually null because fetchProfilePicture raced the background OAuth
avatar sync with a fixed 500ms delay and never retried, and a missing
bucket object simply resolved to null.
- useConfigButtonIcon: fall back to the same initials avatar as the
settings page instead of the gear when no picture URL is available.
- UseSession: fetch the profile picture when syncOAuthAvatar settles
(init and SIGNED_IN) instead of after an arbitrary 500ms.
- fetchProfilePicture: when the bucket copy is missing, fall back to
the OAuth provider's own photo URL so the picture shows immediately
on first login - unless the user explicitly uploaded/removed a
picture (metadata source 'upload'), preserving the remove flow.
FileStorageService.requireAuthenticatedUser and
FolderService.requireAuthenticatedUser authorize via
'principal instanceof User', but EnhancedJwtAuthenticationToken extends
JwtAuthenticationToken whose principal is the decoded Jwt - so every
/api/v1/storage/* request 401'd for JWT users AFTER Spring Security had
already authenticated them. This persistent 401-with-valid-session was
the trigger feeding the frontend login loop.
Attach the filter-resolved local User as the token principal for full
accounts (User implements UserDetails, matching the form-login
convention every shared instanceof check expects). Anonymous sessions
keep the raw Jwt principal, preserving their existing exclusions. All
other principal consumers verified safe: AuthenticationUtils checks
instanceof User first, extractSupabaseId/CreditController/Team
SecurityExpressions switch on the authentication type, not the
principal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit of every code path that can produce the login->/->login cycle
found the observed loop was one instance of a repeatable class: any
automatic API call that persistently 401s while the Supabase session is
valid triggers httpErrorHandler's hard redirect to /login, which sees
the valid session and bounces back. Close the class, not just the
instance:
- saas apiClient: a 401 that survives a refresh-and-retry means the
backend rejected a valid token (authz bug / wrong origin), not an
expired session - never redirect to /login for it. Also fix the stale
publicEndpoints entry ('endpoints-enabled' matched nothing; the real
routes are endpoints-availability and endpoint-enabled).
- httpErrorHandler: sessionStorage loop breaker - if a 401 redirect
already fired within 10s, suppress the repeat instead of cycling.
- Guard the remaining unflagged automatic callers: /api/v1/credits
(fires on session init and TOKEN_REFRESHED), endpoints-availability
(fires on app load), and ui-data/login (auto-called when a stale
stirling_jwt is present).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The deployed app looped /login -> / -> /login forever: Login sees a
valid Supabase session and navigates to /, the global FolderProvider
pulls GET /api/v1/storage/folders, the backend rejects it with 401, and
the global error handler hard-redirects back to /login?from=/bpp.
fileSyncService's /api/v1/storage/files pull already opts out via
suppressErrorToast + skipAuthRedirect, so its 401 fails silently;
folderSyncService.list() passed neither flag, so its 401 fell through to
the redirect. Add the same flags - FolderContext.pullFromServer already
handles 4xx locally (flips serverReachable, suppresses the banner).
Note: the underlying 401 on /api/v1/storage/* with a valid session is a
backend/deployment issue (storage endpoints rejecting the Supabase
token); this change makes the frontend resilient so it degrades to
"folder sync unavailable" instead of an auth loop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two issues seen on the hosted /bpp login screen:
1. GET /api/v1/storage/folders fired (and 401'd) on the login page. The
global FolderProvider pulls from the server whenever
appConfig.storageEnabled is true, with no auth gate, so it hits the
authenticated storage API before the user has signed in. Skip the pull
on auth routes (/login, /signup, /auth/*, /invite, /reset-password),
mirroring the existing LicenseContext / AppConfigContext guards. Tests
wrap FolderProvider in MemoryRouter (now uses useLocation).
2. manifest.json and modern-logo/favicon.ico 404'd from the domain root
instead of /bpp/. vite base for RUN_SUBPATH deploys was "/bpp" with no
trailing slash, so <base href="/bpp"> made the browser resolve relative
links against the parent (root). Use "/bpp/"; getBasePath() strips the
trailing slash, so BASE_PATH, routing and asset URLs are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Description of Changes
Add new triggers:
- Schedule (fires every X amount of time)
- Folder watch (fires whenever the OS tells us a folder has a new file
in it; on Mac this is technically a 2s schedule but that's just how Java
implements it)
Add new sources:
- Folder (reads from this directory)
Add new sinks:
- Inline (stores in FileStorage)
- Folder (stores in specified directory)
Still want to do S3 buckets and web hooks and stuff, but they can come
in a future PR. I'm hoping this should make it sufficient to be able to
integrate with processing folders frontend etc. I've also changed it so
that policies can have multiple sources and triggers at once, which
seems like it might be useful.
The console warned "Multiple GoTrueClient instances detected in the same
browser context" and storage endpoints (/api/v1/storage/folders,
/files) kept 401ing even after a successful token refresh.
Cause: the SaaS bundle instantiated TWO Supabase clients on the same
sb-<ref>-auth-token storage key. :saas/auth/supabase.ts creates the
primary client (used by UseSession + apiClient), while billing /
licensing / user-management code imports @app/services/supabaseClient,
which fell through to :proprietary/services/supabaseClient.ts and called
createClient() again. Each client runs its own autoRefreshToken timer,
so they rotate the refresh token out from under each other → "Already
Used" refresh failures and spurious 401s, plus a residual /login flash.
Add a :saas override of @app/services/supabaseClient that re-exports the
single instance from @app/auth/supabase. The path mapping
(@app/* → src/saas/* → src/proprietary/* → src/core/*) now resolves
every consumer to the same client, so the :proprietary createClient() is
never bundled in the SaaS build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On returning to the app with an expired Supabase access token, bootstrap
requests fired with the stale token and 401'd before Supabase finished
refreshing. The global 401 handler then hard-redirected to
/login?from=… (a full window.location navigation), and once the refresh
landed the app sent the user straight back in — the login/logout/login
flicker.
Two holes in the SaaS apiClient response interceptor caused it:
1. "public" endpoints (e.g. /api/v1/config/app-config) skipped the
refresh-and-retry path. The backend 401s any expired Bearer token
regardless of route, so those bootstrap calls 401'd and fell through
to handleHttpError, which redirected to /login. Now public endpoints
also refresh-and-retry, and a 401 on a public endpoint sets
skipAuthRedirect so it can never trigger the global login redirect.
2. Concurrent 401s each called supabase.auth.refreshSession()
independently. Supabase rotates the refresh token on first use, so
the racing refreshes failed with "Invalid Refresh Token: Already
Used" and bounced the app even though the session was recoverable.
Refreshes are now de-duplicated through a single in-flight promise.
Existing apiClient unit tests (refresh-and-retry on protected 401, bare
/login redirect on genuine refresh failure) are preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Description of Changes
Adds all the missing translations that I could find (they're all dynamic
ones that the existing test can't detect are required) and adds a new
test to find as many unused translations as possible. The test has an
ignore list for translations that are used, but dynamically so the test
can't find them (most of the settings UI translations are built up
dynamically like that).
This PR is scoped to just include en-GB translation changes, since
that's the main supported language. We'll need to do a translation PR to
trim all the dead keys from the other languages, and add the missing
ones.
Adds an optional MCP server (proprietary module) that exposes Stirling's
PDF operations and AI capabilities to MCP clients. Off by default, zero
footprint when disabled.
### What
- New `/mcp` endpoint: streamable-HTTP + JSON-RPC 2.0; 8 tools
(describe_operation, pages/convert/misc/security category tools, AI,
upload, download).
- Runs real operations over an internal loopback; results returned
inline as base64 (small) or by fileId (large).
### Auth (two modes)
- OAuth2 resource server: RFC 9728 protected-resource metadata, RFC 8707
audience binding, JWKS, `mcp.tools.read/write` scopes; binds each token
to a provisioned Stirling account.
- API-key mode: reuses Stirling per-user `X-API-KEY` (no IdP needed).
### Security
- Per-user file ownership in FileStorage: async/queued writes scoped to
the submitting user; legacy/owner-less files stay readable.
- Admin allow/block list controls which operations are exposed.
- Python engine gated behind a shared secret (`X-Engine-Auth`).
- MCP filter chain is isolated and cannot weaken the main app's
security.
- Hardened: no upstream error-body leakage, log injection sanitized,
fileId path/sidecar enumeration blocked.
### Config / footprint
- Disabled by default (`mcp.enabled=false`); all beans
`@ConditionalOnProperty`.
---
## 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.
## What this PR does
Bundles the **low-risk polish items** from the [multi-agent review of
#6519](https://github.com/Stirling-Tools/Stirling-PDF/pull/6519). Each
change is independent, mechanical, and ships with focused unit-test
coverage.
The medium-severity items (\`?async=true\` OUTPUT recording,
JSON-consumes endpoint coverage, SpringBootTest harness) are tracked
separately in [\`notes/PAYG_DESIGN.md\` §7.5
PR-S4](https://github.com/Stirling-Tools/Stirling-PDF/blob/payg-s4-hardening/notes/PAYG_DESIGN.md)
— they need design decisions + bigger infrastructure work, so this PR
sticks to the mechanical wins.
Stacked on #6519. When that merges to main, this rebases cleanly — no
code changes.
## Changes
| Area | What | Why |
|---|---|---|
| **\`tool_id\` becomes route pattern** |
\`PaygChargeInterceptor.resolveToolId()\` prefers
\`HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE\` over
\`request.getRequestURI()\`. Truncates to 128 + WARN log +
\`payg.filter.errors\` increment when truncation fires. | Audit rollups
aggregate by endpoint instead of by every individual request's
path-variable / matrix-param variant. Silent truncation now louder. |
| **Direct PDF magic-byte check** | \`PaygOutputExtractor.extract()\`
magic-checks the body even for direct \`application/pdf\` responses. |
Asymmetric with the ZIP-entry path which always magic-checks. A tool
that emits \`application/pdf\` for a JSON / HTML payload would otherwise
pollute \`job_artifact_hash\`. |
| **DESKTOP_APP detection** | \`X-Stirling-Client: desktop\` header →
\`JobSource.DESKTOP_APP\`. | The enum value was unreachable from
\`determineSource()\`; Tauri shell traffic was mis-classified as WEB. No
anti-spoof — V12 step limits are identical for WEB/DESKTOP_APP so the
worst-case abuse value is zero today. |
| **\`max-bytes\` sensible default** | 500 MiB instead of \`null\`
(unbounded). | Covers the largest realistic Stirling responses (full
split-to-ZIP on a 1000-page document) while preventing pathological
cases from tying up the interceptor for minutes. Set to \`null\` to
disable. |
| **\`BufferedOutputStream\` for spill** | Wraps the spill
\`OutputStream\` in 64 KiB \`BufferedOutputStream\`. | Previously every
Tomcat chunk (default 8 KiB) was a separate syscall. Big spilled
responses get a syscall-bound speedup. |
| **Duration timer per phase** | \`payg.filter.duration\` tagged
\`phase=preHandle\` vs \`phase=afterCompletion\`. | Two distinct latency
distributions were blended into one histogram; hard to alert on. |
## Tests
| Test | What it covers |
|---|---|
|
\`PaygOutputExtractorTest.pdfContentType_butBodyMissingPdfMagic_returnsEmpty\`
| New direct-PDF magic-byte gate. |
|
\`PaygChargeInterceptorTest.preHandle_desktopClientHeader_setsJobSourceDesktopApp\`
| New \`X-Stirling-Client: desktop\` → DESKTOP_APP path. |
|
\`PaygChargeInterceptorTest.preHandle_toolId_prefersBestMatchingPattern\`
| Route pattern wins over URI when both are set. |
|
\`PaygChargeInterceptorTest.preHandle_toolId_truncatesAndCountsWhenLongerThan128\`
| Oversized values truncate + increment errors counter. |
Full saas suite green (210 tests), coverage targets met.
## What's NOT in this PR (deliberately)
- **\`?async=true\` OUTPUT recording.** The JobExecutorService returns a
synchronous \`JobResponse{jobId}\` body before the async tool actually
runs; \`afterCompletion\` fires too early. Needs a design decision:
short-circuit PAYG when \`async=true\` OR hook into \`TaskManager\`
completion. Tracked in PR-S4 design doc.
- **JSON-consumes endpoint coverage.** The
\`MultipartHttpServletRequest\` cast skips endpoints with \`consumes =
APPLICATION_JSON_VALUE\` (e.g.
\`ConvertPdfJsonController.exportPartialPdf\`). Fix is either extract a
request-body hash for JSON or add a CI lint forbidding non-multipart
\`@AutoJobPostMapping\`. Design discussion needed.
- **SpringBootTest harness for filter + interceptor wiring.** Saas
module doesn't have one yet. Separate work — PR-S3 takes a different
approach (docker-compose + Behave); a SpringBootTest layer would be
additive in-process coverage.
These are tracked in \`notes/PAYG_DESIGN.md §7.5\` so they don't slip.
## Tracked in
\`notes/PAYG_DESIGN.md\` §7.5 PR-S4.
### Description of Changes
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-GB/translation.toml`.
- Ensured consistency and synchronization across all supported language
files.
- Highlighted any missing or incomplete translations.
- **Format**: TOML
#### **2. Update README.md**
- Generated the translation progress table in `README.md` using
`counter_translation_v3.py`.
- Added a summary of the current translation status for all supported
languages.
- Included up-to-date statistics on translation coverage.
#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.
---
Auto-generated by [create-pull-request][1].
[1]: https://github.com/peter-evans/create-pull-request
---------
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <anthony@stirlingpdf.com>
## Summary
Two Flyway migrations + matching JPA entity updates. **Part 1 of 2** in
the Stripe/Supabase wire-up (PR-SB-1 in
`payg-stripe-supabase-plan.html`); the companion SaaS PR carries the
twin Supabase migrations + new edge functions.
### V14 — payg_subscription_state.sql
- `payg_team_extensions.payg_subscription_id` — the single switch that
decides whether a team is billed. NULL = free-tier or block; NOT NULL =
post Stripe meter events.
- `pricing_policy.free_tier_units_per_cycle` — per-policy free allowance
before a card is required. Default 0.
- `payg_link_subscription(team_id, customer_id, sub_id)` RPC —
idempotent.
- `payg_unlink_subscription(team_id, reason)` RPC — called on
`subscription.deleted`.
- AFTER-INSERT trigger on `teams` so every new signup gets a
`payg_team_extensions` sidecar row from creation.
- Backfill for existing teams without a sidecar row.
- RLS: SELECT permissive (any team member), UPDATE restricted to LEADER.
Service-role bypasses (backend reads + day-1 migration writes).
### V15 — payg_audit_logs.sql
- `payg_meter_event_log` — backend audit of every Stripe meter event
POST attempt (idempotency-key UNIQUE; index on unposted rows for nightly
reconcile).
- `payg_subscription_change_log` — written by V14 RPCs on every
link/unlink.
### Entity updates
- `PaygTeamExtensions.paygSubscriptionId` — read-only field; RPC
functions are the only writers.
- `PricingPolicy.freeTierUnitsPerCycle` — read by upcoming
`PaygTeamUsageService` (PR-SB-4).
### Behaviour change
**None yet.** The columns + functions sit unused until PR-SB-4 wires
`PaygMeterReportingService` and the free-tier gate into
`JobChargeService`. This PR is pure schema + JPA wiring.
## Test plan
- [x] `./gradlew :saas:test` — BUILD SUCCESSFUL
- [x] Manual schema review: column types, FK directions, RLS scope
- [ ] Apply against v3-Supabase via `supabase db push` (after companion
SaaS PR merges)
- [ ] Smoke-test the trigger: `INSERT INTO teams(...)` → assert
`payg_team_extensions` row appears
- [ ] Smoke-test RPCs: SQL-only test of `payg_link_subscription` +
`payg_unlink_subscription` produces expected row + audit entries
## References
- `notes/PAYG_DESIGN.md` (revision note 2026-06-03)
- `payg-stripe-supabase-plan.html` §3.1 (RPC functions), §3.5 (RLS),
§3.10 (audit-log tables)
## What this PR is
End-to-end cucumber coverage for the PAYG shadow charging engine (the
filter + interceptor stack from #6519), wired into CI via a new
`docker-compose-tests-saas.yml` workflow that runs only on PAYG-touching
PRs.
Stacked on #6519.
## Automated scenarios (run by `docker-compose-tests-saas.yml`)
See
[`testing/cucumber/features/payg/shadow_charges.feature`](../tree/payg-s3-cucumber/testing/cucumber/features/payg/shadow_charges.feature):
| Scenario | Validates |
|---|---|
| First tool call writes a CHARGED row | Filter + interceptor fire
end-to-end |
| Lineage join — second call on output | `JobService.joinOrOpen`
matching; no new shadow row |
| 4xx leaves the row CHARGED | "Customer paid for the attempt" semantics
|
| ZIP-returning tool records per-PDF OUTPUT | `PaygOutputExtractor`
unpacks + records signatures |
| Multi-file input writes a single shadow row | Multi-input group sizing
|
| `X-Stirling-Automation` sets PIPELINE source | Header → `JobSource`
detection |
All 6 run locally via `./testing/test-payg.sh` and will run on CI for
any PR that touches `app/saas/**`, the PAYG cucumber features, the saas
compose stack, or the workflow itself.
## Manual-only scenarios — documented in design doc, not in this suite
Two parts of the shadow engine are deliberately not automated; the
engine paths are unit-tested in
`PaygChargeInterceptorTest.afterCompletion_5xx_opened_*`, and the manual
procedures (which require a temporary throw endpoint or a container
restart with a flag flipped) live in [`notes/PAYG_DESIGN.md` §7.5.2
"PAYG cucumber: manual-only
scenarios"](../tree/payg-s3-cucumber/notes/PAYG_DESIGN.md).
- **5xx first-step failure → REFUNDED + CLOSED.** No reliably-5xx-ing
endpoint exists; manual procedure adds a throw endpoint, runs, asserts,
removes.
- **Kill-switch (`PAYG_FILTER_ENABLED=false`).** Needs a container
restart mid-suite; manual procedure tears down, flips env, brings up,
asserts zero shadow rows.
If either gets a hot-reload path (test-only throw endpoint shipped
behind a profile gate, or admin endpoint for the kill switch), automate
it in a follow-up and drop the manual procedure.
## CI workflow
`.github/workflows/docker-compose-tests-saas.yml` (new) —
self-contained, not wired into `build.yml`'s `files-changed` matrix so
the saas-cucumber job fails and succeeds independently. Triggers only on
PAYG-relevant paths. No JaCoCo coverage in v1 (saas compose doesn't have
the coverage override; can add later).
## Test infrastructure (recap)
- **`testing/compose/docker-compose-saas.yml`** — Stirling-PDF backend
with `STIRLING_FLAVOR=saas` + Postgres holding the `stirling_pdf`
schema. Supabase JWT auto-config disabled; API-key auth via
`SECURITY_CUSTOMGLOBALAPIKEY` is the live path the cucumber tests
exercise.
- **`testing/compose/payg/saas-init.sql`** + **`saas-seed.sql`** —
schema bootstrap + idempotent seed (team / user / wallet_policy).
- **`testing/cucumber/features/payg/shadow_charges.feature`** — the 6
scenarios above.
- **`testing/cucumber/features/steps/payg_step_definitions.py`** — step
defs using `requests` (HTTP) + `psycopg` (direct DB inspection). Direct
DB reads are deliberate — we want to see the filter's side effects, not
relay them through another API layer.
- **`testing/test-payg.sh`** — companion runner to `testing/test.sh`.
Brings up the saas compose, waits for health, seeds, runs behave, tears
down.
- **`behave.ini`** excludes `features/payg` from the default behave run
(the saas-cucumber CI job invokes it explicitly).
## Why a separate harness from `testing/test.sh`
The existing `test.sh` covers the proprietary-flavour stack (no PAYG
tables, no saas profile). Coupling two CI matrices that fail and succeed
independently into one script is asking for trouble. Keep the
saas-cucumber job focused on its own concerns; once the harness is
mature, the wider team can decide whether to merge them.
## Tracked in
`notes/PAYG_DESIGN.md` §7.5 (PR-S3) + §7.5.2 (manual scenarios).
# Description of Changes
- Tool action button truncation - fixed by allowing Mantine <Button>
label to wrap (whiteSpace: normal, height: auto) instead of clipping
- Role badge truncation on People page - fixed by dropping the column's
fixed w={100} and letting the badge size to its content
- Settings nav item wraps to 3 lines - fixed by hiding the inline ALPHA
badge by default and revealing it on :hover/:focus-within/.active
- Zoom slider cramped on narrow desktop - fixed by removing the
toolbar's hardcoded minWidth: 30rem and giving the slider flexShrink: 0
+ minWidth: 6rem
- "Swipe left or right" hint on desktop - fixed by adding a useIsTouch()
hook (pointer: coarse) and gating the hint on isMobile && isTouch
- Logout doesn't redirect - fixed by replacing navigate('/login') with
window.location.assign('/login') in a finally block so auth context
fully re-bootstraps
- Viewer top toolbar clips icons on mobile - fixed by switching the
wrapped state to justify-content: flex-start + overflow-x: auto so the
icon strip is momentum-scrollable
- Mobile bottom toolbar overflows - fixed by gating layout on
useIsPhone() and reducing the inline bar to prev / page / next / ⋮ only
- Lost controls when shrinking mobile toolbar - fixed by adding a
Mantine <Menu> behind ⋮ that groups First/Last page, Zoom in/out (with
%), Dual-page, Dark/Sepia filter under Page navigation / Zoom / View
labels
- "Upload from computer" label clipped on hover - fixed by unmounting
the Add Files button entirely while Upload is hovered, so Upload claims
width: 100%
- Settings rows clip controls off-screen - fixed by adding flex: 1,
minWidth: 0 to the inner text-block <div> on 44 rows across 10 -files,
so labels shrink and wrap while controls stay anchored to the right
---
Screenshots
[report-before-after.html](https://github.com/user-attachments/files/28687621/report-before-after.html)
## 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.
# Description of Changes
Disallow warnings and errors from being thrown in the browser console
during tests unless explicitly expected in the test. Also adds a
Playwright test to prod around some main UI areas and checks that no
warnings/errors have been thrown.
# Description of Changes
The changes in
[#6279](https://github.com/Stirling-Tools/Stirling-PDF/pull/6279) broke
the desktop app because the wasm URL handling didn't deal with
`tauri://` paths. Also I noticed that `task desktop:build:dev:mac`
failed locally because it was attempting to sign the app with
credentials that developers won't have (and shouldn't need), so I fixed
that too.
# Description of Changes
Add a backend for running any multi-step PDF operations. This is
designed to be used for the upcoming Policies feature, along with
anything else that will require automated running of PDF operations,
like the Automate tool or Processing Folders.
The implementation is not complete. I've tried to get all the
infrastructure in there so that we can add in whichever triggers we need
in the future (like cron triggers or watching folders on disk) but
currently it just supports manual triggering of the policy.
The basis of this work was the operation running from the Stirling
Engine, which this PR removes in favour of this new system. The only
currently accessible frontend way to test this work is to ask the AI
chat to execute multiple operations on a PDF, but I've also extensively
tested with direct API calls to make sure that the policies work and
persist properly.
## Summary
Make the remaining static ENTERPRISE badges in the admin settings
clickable so they navigate the user to `/settings/adminPlan`, matching
the pattern already used by the PRO badges in Connections / Features /
General sections.
### Before
Two ENTERPRISE badges were inert text chips with no affordance:
- `AdminSecuritySection.tsx` - Audit Logging
- `AdminDatabaseSection.tsx` - Database section header
### After
Both now use the same pattern as the existing clickable PRO badges:
- `cursor: pointer`
- `onClick={() => navigate("/settings/adminPlan")}`
- `title` tooltip with the existing
`admin.settings.badge.clickToUpgrade` i18n key ("Click to view plan
details")
No new strings, no new components - just wiring up existing behavior to
the two badges that were missing it.
### Existing already-clickable badges (kept identical for reference)
- `AdminConnectionsSection.tsx:585-596` - SSO Auto Login PRO
- `AdminFeaturesSection.tsx:175-186` - Server Certificate PRO
- `AdminGeneralSection.tsx:920-931` - Custom Metadata PRO
## Summary
- Set `dragDropEnabled: false` on the Tauri window so HTML5 drag events
reach the WebView. Previously the default `true` made Tauri intercept
all drag-drop at the OS level, silently breaking in-page drag-to-reorder
(Pragmatic Drag and Drop in `FileEditorThumbnail` /
`useFileItemDragDrop`) in the desktop build. The Active Files tab
reorder, which feeds Merge ordering, was the user-visible symptom.
- Browser builds are unaffected (tauri.conf.json is desktop-only).
- The OS file-drop pipeline now flows through the existing Mantine
`Dropzone` in `FileEditor.tsx` via HTML5 events instead of the Rust
`WindowEvent::DragDrop` handler in `lib.rs:215`. Verified working.
## Test plan
- [x] Desktop: drag a thumbnail in Active Files past another - row goes
semi-transparent, order updates on drop.
- [x] Desktop: drag a PDF from File Explorer onto the window - file is
added.
- [x] Web build: drag-to-reorder still works (unchanged code path; flag
is desktop-only).
- [x] Merge tool: order set by drag in Active Files is the order used by
the merge output.
## Follow-up (not in this PR)
- `WindowEvent::DragDrop` arm in
`frontend/editor/src-tauri/src/lib.rs:215-229` is now unreachable for
window drops. The `forward_files_to_window` helper still serves the
macOS Finder "Open With" path (`RunEvent::Opened` at lib.rs:230), so
only the DragDrop arm can be deleted. Worth a small cleanup pass later.
## Summary
Audit + bulk fix of hard-coded English UI strings - `aria-label`,
`title`, `placeholder`, `label`, and raw JSX literals that bypassed i18n
entirely. Each literal now goes through `t("key", "English Default")`
from `react-i18next`, and every new key has a corresponding entry in
`en-GB/translation.toml` so translators can pick it up.
## What this fixes
Strings were rendered untranslated in every non-EN locale because they
never went through `t()` at all (not just "value not translated yet").
Affects screen-reader labels, tooltips, form placeholders, empty/loading
states, plan card content, and the entire workflow ParticipantView.
## Coverage (~143 keys / 50 files)
- **Viewer chrome** - search bar (close, clear, prev/next, "of N"
results), link/signature/redaction actions, viewer error state, zoom
labels
- **Page editor** - undo/redo/rotate/delete toolbar tooltips, empty
state, bulk selection operator chip tooltips
- **Shared primitives** - Tooltip close, InfoBanner dismiss, TextInput
clear, Toast dismiss/toggle, UpdateModal close, EditableSecretField
edit, DropdownListWithFooter search, FileCard/FileDropdownMenu actions,
EmptyFilesState + AddFileCard upload
- **Tools** - Image upload + hint, ColorControl eyedropper, sign Use
Signature, CompressSettings, OCR loading, PageLayout
margin/border/row/col placeholders, FormFill switch + save + re-scan
- **Proprietary admin** - OverviewHeader signed-in line + logout,
AdminPremiumSection moved-features list (via `<Trans>`),
AdminPlanSection no-data alert, AdminAdvancedSection temp-dir
placeholders, AdminEndpointsSection multiselect placeholders,
AdminMailSection + AdminDatabaseSection password placeholders
- **Onboarding** - MFASetupSlide QR loading + auth code label,
SecurityCheckSlide role select + options
- **ParticipantView** - entire sign-document UI (~30 strings: loading,
error, badges, headings, cert-type Select, all input labels and
placeholders, action buttons, completion + expired alerts) - file
previously imported `useTranslation` but only used `t()` for cert
validation
- **planConstants.ts refactor** - replaced `PLAN_FEATURES` /
`PLAN_HIGHLIGHTS` const exports with `usePlanFeatures()` /
`usePlanHighlights()` hooks. Service layer (`licenseService.getPlans`)
updated to accept feature/highlight maps so it stays hook-free. Callers
(`usePlans`, `CheckoutContext`) resolve the hooks at the React boundary
- **Previously catalogued offenders** - `FileSidebarFileItem`
open/close-viewer aria-labels, `quickAccessBar/ActiveToolButton` "Back
to all tools" tooltip + aria, `AppConfigModal` close button
## Notes
- One small refactor in `usePageSelectionTips.ts` was needed to resolve
a TOML key-shape conflict: the existing scalar keys
`bulkSelection.operators.{and,not,comma}` needed to become tables to
hold the new `.title` subkeys for OperatorsSection's chip tooltips. The
existing descriptions moved to `[bulkSelection.operators.descriptions]`
and the three i18n key paths in usePageSelectionTips were updated to
match.
- Viewer sidebar close buttons
(Bookmark/Layer/Thumbnail/Attachment/Comments) were on the audit list
but are NOT on main - they're added by the unmerged PR #6552
(feat/viewer-sidebar-ux). Those particular strings will need wrapping
when that PR lands.
- TOML hook (`toml-sort-fix`) ran and re-sorted the translation file.
## Test plan
- [ ] `task frontend:typecheck` passes (core + proprietary + desktop
variants)
- [ ] `task frontend:lint` passes
- [ ] Switching language to Deutsch / Русский: previously-English
`aria-label`s + tooltips + placeholders + plan card bullets now render
translated (when the locale has values) or fall back to the English
default (when it doesn't)
- [ ] Plan page bullet points in EN render unchanged
- [ ] Sign-document flow (ParticipantView) renders unchanged in EN
# Description of Changes
Reconfigure linting of `any` type to an opt-out instead of an opt-in
strategy now that we're close enough to everything supporting it. Also
slightly expands the scope of things included in the linting.
# Description of Changes
[#6474](https://github.com/Stirling-Tools/Stirling-PDF/pull/6474)
updated the IndexedDB schema number to v9, but a couple of Playwright
tests were explicitly creating a DB in v4 schema, which then caused
inconsistently failing tests because the DB upgrade process is
asynchronous and sometimes was too slow to upgrade, causing the test to
get into an invalid state.
Also fixes the screenshots directory exclusion since the frontend folder
was restructured.
## Summary
Addresses two review comments from #6507:
- **`timeUtils.ts`** — route relative time strings (`just now`, `Xm
ago`, `Xh ago`, `Xd ago`) through i18n by accepting a `TFunction`
parameter and using new `time.relative.*` keys in `en-GB`
- **`ChatPanel.tsx`** — replace `ReturnType<typeof useTranslation>["t"]`
with `TFunction` from `i18next`
## Test plan
- [x] `task frontend:check` passes (695 tests)
## Summary
## What changed
### 1. Google Drive Picker now renders above the FileManager modal
`frontend/editor/src/core/services/googleDrivePickerService.ts`
The picker is opened from inside the FileManager modal
(`Z_INDEX_FILE_MANAGER_MODAL = 1200`), but Google's Picker defaults to
z-index ~1001 - so it landed *behind* the modal that invoked it. Added
`setZIndex(Z_INDEX_OVER_FILE_MANAGER_MODAL)` to the builder.
### 2. `Z_INDEX_AUTOMATE_DROPDOWN` no longer collides with
`Z_INDEX_FILE_MANAGER_MODAL`
`frontend/editor/src/core/styles/zIndex.ts`
Both constants were `1200`. The automate dropdown only needs to sit
above the automate modal (1100), so dropped it to `1150`. This keeps
automate dropdowns above their parent modal but reliably below the file
manager scrim when the two overlap.
Confirmed callers - all are dropdowns inside automate-modal tool
settings:
- `DropdownListWithFooter.tsx`
- `AddPageNumbersAppearanceSettings.tsx`
- `AddPasswordSettings.tsx`
- `StampPositionFormattingSettings.tsx`
- and several other `*Settings.tsx` files
All keep working as intended (1150 > 1100).
### 3. Tooltip z-index honours the documented hierarchy again
`frontend/editor/src/core/components/shared/tooltip/Tooltip.module.css`
`Tooltip.tsx` sets `zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE` (1300)
inline, but the CSS module had a hardcoded `z-index: 9999` that overrode
it. Removed the stale CSS rule so tooltips render at the intended 1300
level rather than floating above almost everything.
### To test
- Ask the agent to “list all the things you can do and put them in a
markdown table”. I know we’re explicitly asking it for markdown, but I
don’t want to update the system prompt to ask it to make tables when
necessary because it’ll probably turn everything into a table, not sure
though, we can test in future.
- Notice how the loading is different
- Notice how the user chat is in a bubble but the agent chat is flat
(super standard design practice in AI tools, and looks much better when
the agent outputs mardown, expecially tables and needs room to do so)
- Ask it to do something different, then close the chat, and see that
the agent is marked as running and has a green outline and a green dot.
- Play around with resizing the chat to make it bigger/smaller
Open to any and all criticisms on any of the design choices, and of
course the usual, code etc.
Resizing
<img width="1572" height="812" alt="Screenshot 2026-06-01 at 2 47 53 PM"
src="https://github.com/user-attachments/assets/ec0ac1d0-01da-4025-bf7e-eea4eb544181"
/>
Loading (cool animation not visible through screenshot obviously)
<img width="559" height="141" alt="Screenshot 2026-06-01 at 2 53 41 PM"
src="https://github.com/user-attachments/assets/99f0b1f5-1719-4d78-8947-21b142293052"
/>
Removed bubbles for agent chat (maybe controversial, let me know) and
markdown now renders properly again
<img width="654" height="1060" alt="Screenshot 2026-06-01 at 2 55 01 PM"
src="https://github.com/user-attachments/assets/445f0889-a632-4751-9a16-f80ae388c632"
/>
## What this PR does
Wires the **B-1 shadow charging engine** into real HTTP request flow.
After this lands, flipping an internal team to ``PAYG_SHADOW`` via SQL
begins populating ``payg_shadow_charge`` automatically — with **zero
impact** on the legacy credit deduction path.
**This is the load-bearing PR for shadow mode.** Without it, B-1's
engine sits idle — nothing in the codebase calls
``JobChargeService.openProcess()`` from a real HTTP request.
Stacks on top of #6477 (PR B-1).
## Components
| Class | Role |
|---|---|
| ``PaygResponseBodyWrapperFilter`` | Servlet filter, installs tee'ing
response wrapper. Defers wrapper close to ``AsyncListener`` for
``DeferredResult`` / ``CompletableFuture`` controllers so the lifetime
spans the async window. |
| ``PaygResponseBodyWrapper`` | ``HttpServletResponseWrapper`` —
in-memory ``ByteArrayOutputStream`` up to 10 MiB; spills to ``TempFile``
above. ``materialisedPath()`` always returns a uniform ``Path``
interface. |
| ``PaygChargeInterceptor`` | ``AsyncHandlerInterceptor`` mirroring
``UnifiedCreditInterceptor`` shape. ``preHandle`` gates on
``@AutoJobPostMapping``, materialises multipart inputs, calls
``JobChargeService.openProcess``. ``afterCompletion`` branches on HTTP
status. |
| ``PaygOutputExtractor`` | Pulls PDFs out of the response body. Direct
``application/pdf`` returns body verbatim; ``application/zip`` iterates
entries and keeps each ``.pdf`` entry whose first bytes match the
``%PDF-`` magic. |
| ``PaygWebMvcConfig`` | Registers filter at end of Spring filter chain
(after security); interceptor after ``UnifiedCreditInterceptor``. |
| ``PaygFilterProperties`` | ``payg.filter.enabled`` master switch +
in-memory threshold + optional max-bytes ceiling. |
## Status branching in afterCompletion
| HTTP status | Action |
|---|---|
| **2xx** | Append OK step; extract PDFs from response;
``JobService.recordOutput`` per PDF |
| **4xx** | Append FAILED step with ``errorCode``. No refund — customer
paid for the attempt. No OUTPUT recording. |
| **5xx + OPENED** (first-step) |
``JobChargeService.markFirstStepFailed`` → shadow row flipped to
``REFUNDED``, process CLOSED. Refund counter incremented. |
| **5xx + JOINED** (mid-chain) | ``JobChargeService.decrementStepCount``
— step slot returned without resetting ``lastStepAt`` (workflow window
stays active for retry). |
## New ``JobChargeService`` methods
- **``markFirstStepFailed(jobId, reason)``** — flips shadow row to
``REFUNDED`` with ``refundedAt`` + ``refundReason``, closes the process.
Idempotent. Mimics the eventual Stripe
``meter_event_adjustment(cancel)`` flow that real-mode will invoke at
the same callsite. **Refund implies close** so a same-input retry can't
lineage-join into a refunded chain for free work.
- **``decrementStepCount(jobId)``** — defensive floor at 1; never drives
count negative.
## Schema
- Backend: ``V13__payg_shadow_charge_status.sql`` adds ``status``
(``CHARGED`` | ``REFUNDED``) + ``refunded_at`` + ``refund_reason``.
``DEFAULT 'CHARGED'`` so existing B-1 rows stay correct without
backfill.
- Supabase: matching migration in
[Stirling-PDF-SaaS#payg-shadow-charge-status](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/tree/payg-shadow-charge-status)
## Fail-open semantics in shadow
Any ``RuntimeException`` in ``preHandle`` / ``afterCompletion`` is
logged at WARN, increments ``payg.filter.errors``, and lets the
customer's tool call proceed unbilled. This **reverses to fail-closed**
when ``wallet_policy.engine = PAYG`` (real charging) — that reversal
lives inside ``JobChargeService`` and ships with the cap evaluator PR
(PR-C1 in PAYG_DESIGN.md).
## Observability
Micrometer metrics:
- ``payg.filter.errors`` Counter — internal failures (preHandle +
afterCompletion). Alert source.
- ``payg.filter.calls`` Counter, tagged ``disposition`` (``OPENED`` |
``JOINED`` | ``SHORT_CIRCUIT``)
- ``payg.filter.refunds`` Counter — first-step 5xx refunds
- ``payg.filter.duration`` Timer — preHandle + afterCompletion
wall-clock per request
## Test coverage (38 tests across 4 classes)
- **PaygResponseBodyWrapperTest** (12 tests) — in-memory, spill,
threshold crossing mid-chunk, writer vs outputStream exclusivity,
``resetBuffer`` with and without spill, close idempotency, single-byte
writes across threshold.
- **PaygOutputExtractorTest** (7 tests) — direct PDF, parametrised
content type, ZIP with mixed entries + magic-byte gate, corrupt ZIP
fail-open, empty ZIP.
- **PaygChargeInterceptorTest** (13 tests) — all preHandle
short-circuits, OPENED disposition stash, fail-open on chargeService
exception, 2xx recordOutputs path, 5xx OPENED → markFirstStepFailed, 5xx
JOINED → decrementStepCount, 4xx FAILED step append, max-bytes ceiling
skip, PIPELINE header detection.
- **JobChargeServiceTest extended** (+6 tests) — markFirstStepFailed
happy path, idempotency, missing-shadow-row case, long-reason trim;
decrementStepCount happy path, floor-at-1 defence, missing-job no-op.
## What's NOT in this PR (deliberate)
- **No SpringBootTest layer.** The saas module doesn't have bootstrap
test infrastructure (Supabase JWT config + H2 schema harness).
Integration confidence comes from the manual staging deploy + SQL-flip
of an internal team. Bootstrap-test infra is a focused follow-up if
needed.
- **No saas-mode Behave / docker-compose.** Per design §17 — deferred.
Existing ``testing/cucumber/`` infrastructure doesn't yet have a
saas-profile compose target; that's its own PR when warranted.
- **No CreditService wire-in** (per design §13 decision). Per-row
comparison data moves to the reconciliation report PR (PR-S2).
``legacy_credits_charged`` + ``diff_pct`` columns stay at 0 in shadow
rows.
- **No reconciliation report endpoint.** Direct SQL queries against
``payg_shadow_charge`` cover the data-access need until patterns emerge.
## Rollback levers
| Symptom | Lever |
|---|---|
| Some / all tool calls breaking due to filter |
``payg.filter.enabled=false`` + restart (~20s) |
| Shadow rows look wrong for a specific team | ``UPDATE wallet_policy
SET engine = 'LEGACY' WHERE team_id = ?`` |
| Mass shadow weirdness | ``UPDATE wallet_policy SET engine = 'LEGACY'``
|
| Memory exhaustion from response tee | Lower
``payg.filter.response.in-memory-threshold-bytes`` |
## Test plan
- [ ] CI green (build + tests)
- [ ] Aikido / Snyk / SonarCloud clean
- [ ] Manual: deploy to staging
- [ ] Manual: flip one internal team via ``UPDATE wallet_policy SET
engine = 'PAYG_SHADOW' WHERE team_id = ?``
- [ ] Manual: hit ``/api/v1/security/add-password`` with that team's
JWT; verify a ``payg_shadow_charge`` row appears with
``status='CHARGED'``
- [ ] Manual: trigger a 503 (e.g. via temporary backend kill
mid-request); verify the resulting row is ``status='REFUNDED'`` + the
process is ``CLOSED``
- [ ] Manual: hit ``/api/v1/general/split`` with a multi-page PDF;
verify one OUTPUT signature per inner PDF appears in
``job_artifact_hash``
- [ ] Manual: chain ``add-password`` → ``compress`` on the output;
verify the second call JOINS the first process (no new shadow row) and
the inner output OUTPUT signature is what drove the lineage join
## Stacks on / references
- Stacks on: #6477 (B-1 — shadow charging engine)
- Schema mirror: Stirling-PDF-SaaS#payg-shadow-charge-status branch
- Design doc: ``notes/PAYG_FILTER_DESIGN.md`` (all 19 decisions DECIDED)
> 📌 **Stacked on
[#6464](https://github.com/Stirling-Tools/Stirling-PDF/pull/6464)**
(lineage primitives, still in review). #6469 has merged so its commits
are no longer in this PR's diff. Once #6464 merges, a final rebase
collapses the lineage-primitives commits out of this diff too — leaving
only the B-1 work.
## What this is
Process tracking + shadow charging engine. Bundles PR-I7 service half
with the non-filter piece of PR-I7a so the pieces ship together — none
of them is useful in isolation.
**Review focus:** the new files in:
- \`app/saas/src/main/java/stirling/software/saas/payg/job/\`
(\`JobService\`, \`JobContext\`, \`JoinOrOpenResult\`,
\`StaleJobCloser\`)
- \`app/saas/src/main/java/stirling/software/saas/payg/charge/\`
(\`JobChargeService\`, \`ChargeContext\`, \`ChargeOutcome\`,
\`JobInput\`)
-
\`app/saas/src/main/java/stirling/software/saas/payg/lineage/LineagePruneScheduler.java\`
- their tests
The 8 files inherited from #6464 (lineage primitives) are unchanged from
there — they ride along in this diff until #6464 lands.
The remaining work for shadow-in-staging is the ingress/egress filter
that wires controllers into this engine — that's PR B-2.
## Scope
### \`JobService\` — persistence + lineage policy
- **\`joinOrOpen\`** — the multi-input "any-match-joins, newest wins"
rule. Hash every input via the lineage detector; if any matches an open
process in the workflow window, attach to the one with the freshest
\`lastStepAt\`. Step-limit overflow on the matched job spawns a fresh
process; the new job's input signatures are still recorded so
\`mostRecentMatchWins\` routes future calls forward.
- **\`recordOutput\`** — post-tool-success path. Records OUTPUT
signatures so the next call that takes this file as input
lineage-matches into the same process.
- **\`appendStep\`** — audit-trail step row written after a tool
completes.
- **\`close\`** — idempotent; safe to call from multiple paths
(explicit, FE on-unload, scheduler). Returns the same row on re-close,
no state mutation.
- **\`findStale\` / \`closeStale\`** — workflow-window-based stale
closure used by the scheduler.
### \`JobChargeService\` — the orchestrator (shadow variant)
\`openProcess\` resolves the effective policy via
\`PricingPolicyService\` (now in main via #6469), derives the step-limit
for the current \`JobSource\` (with a defensive fallback if the policy
is missing an entry), delegates to \`JobService.joinOrOpen\`, and on
OPENED runs the \`DocumentClassifier\` + writes a \`payg_shadow_charge\`
row. Applies the policy-level \`minChargeUnits\` floor per design § 3.4.
Shadow variant only — never debits the ledger, never posts a Stripe
meter event. The real-charging follow-up reuses the same orchestration
and swaps the side-effect. \`legacyCreditsCharged\` on the shadow row
stays \`0\` until the legacy \`CreditService\` is wired in (PR B-2),
where the comparison becomes meaningful.
### Schedulers (both plain \`@Scheduled\`)
- **\`StaleJobCloser\`** — fixed-rate 60 s. Closes \`OPEN\` jobs idle
past the workflow window. API users never have to call close explicitly
— this is the safety net.
- **\`LineagePruneScheduler\`** — hourly cron, retention 1 h. Deletes
\`job_artifact_hash\` rows older than the retention window.
- **No \`@SchedulerLock\` / no \`shedlock\` table** — consistent with
the 5 existing unguarded \`@Scheduled\` tasks in \`:saas\`
(\`CreditResetScheduler\` and friends, none of which are guarded today).
Cluster-correctness across all 7 saas schedulers is tracked in design §
9 as a separate focused cleanup. Underlying operations are idempotent —
duplicate firings on multi-pod would be wasted DB load, not data
corruption.
### Records (call-shape glue for PR B-2's filter)
- \`JobContext\` / \`JoinOrOpenResult\` — input/output for
\`JobService\`.
- \`ChargeContext\` / \`ChargeOutcome\` — input/output for
\`JobChargeService\`.
- \`JobInput\` — paired \`(MultipartFile, materialised Path)\` so the
upcoming ingress filter can pass both views without re-materialising.
## Tests
**26 new, all green.**
- 14 × \`JobServiceTest\` — no-match → opens new, single-match → joins
existing, multi-input any-match-joins, multi-match newest-wins (older
job never even looked up), step-limit hit spawns fresh job (and original
\`stepCount\` is NOT mutated), empty inputs reject, stale-signature
handling, recordOutput delegation, close idempotency, closeStale,
appendStep persistence.
- 7 × \`JobChargeServiceTest\` — JOINED skips classifier + shadow write
entirely, OPENED writes shadow row + classifies (single + multi file
paths), \`minChargeUnits\` floor applied, step-limit resolved
per-\`JobSource\` from policy, missing source entry falls back to
conservative default of 10.
- 2 × \`StaleJobCloserTest\`, 3 × \`LineagePruneSchedulerTest\` —
scheduler-wiring smoke + constructor-validation tests.
\`ENABLE_SAAS=true ./gradlew :saas:test\` — BUILD SUCCESSFUL.
## What's not in this PR (lands in PR B-2)
- **Tool ingress/egress servlet filter.** The highest-risk piece —
materialises the request body into a \`JobInput\`, calls
\`JobChargeService.openProcess\` from every \`@AutoJobPostMapping\`,
records OUTPUT after success. Edge cases to validate: multipart parts,
async controllers, streaming responses, errored 5xx paths, very large
files. Design decisions for the filter are being worked through in
\`notes/PAYG_FILTER_DESIGN.md\` before any code is written.
- **Wire shadow path into legacy \`CreditService\`.** Every legacy debit
writes a comparison row carrying both the PAYG would-be units and the
legacy actual credits, populating \`diffPct\`.
## Design doc
\`notes/PAYG_DESIGN.md\` — PR-I7 + PR-I7a status updated to reflect this
bundle. § 9 carries the cluster-correctness deferral note alongside the
existing LISTEN/NOTIFY trade-off note. § 7.5.1 readiness summary shows
the path now needs just **1 more PR** (the filter half + CreditService
wire-in, both bundled into PR B-2).
# Description of Changes
`mockServiceWorker.js` is a third-party managed file, which is included
in our `.prettierignore` file, and is rewritten to be in the module's
standard format whenever `msw` runs. At some point, it was reformatted
in our style, but shouldn't have been. This puts it back to `msw`'s
style, which should make it stop appearing in diffs.
# Description of Changes
Currently, it's not possible to develop the backend on Mac without
manually signing the JPDFium binaries yourself since macOS will reject
running the unsigned binaries. [We've now updated JPDFium to sign the
Mac binaries in
v1.0.2](https://github.com/Stirling-Tools/JPDFium/releases/tag/v1.0.2),
so update to use that version.
# Description of Changes
Change Stirling Engine to support deleting documents automatically. This
happens both on user logout and after an amount of time specified by the
Java when ingesting a document (allowing for personal documents to have
short lifetimes but org documents to be left in the db with no expiry
date). Also sets up an [ACL
policy](https://en.wikipedia.org/wiki/Access-control_list) for the
documents so the database knows which users have access to which
documents. This is not fully implemented in the Java, so currently all
docs are treated as having a single owner, the uploader, but
theoretically when we need to support org storage, we shouldn't need to
change the db schema.
# Description of Changes
Make zoom key command behave the same regardless of mouse position.
Previously only zoomed the editor if the mouse was over the editor.
## What this is
Three orthogonal interfaces — each with one production impl — for
detecting whether an incoming tool call should join an existing process
via content-hash lineage. Groundwork for PR-I7a: nothing in this PR
calls the detector yet; the ingress/egress filter that wires it into
every controller lands separately.
Built to be modular along three axes. Swapping any of them should not
require changes elsewhere:
| Axis | Interface | V1 impl | Plausible future impl |
|---|---|---|---|
| Hash algorithm | `LineageSignatureExtractor` |
`ByteHashSignatureExtractor` (SHA-256) | `PdfMetadataSignatureExtractor`
(PDF `/ID`, content-stream hash) |
| Storage backend | `JobLineageStore` | `JpaJobLineageStore` |
`RedisJobLineageStore` |
| Matching policy | `HashLineageDetector` | `DefaultHashLineageDetector`
| strategy-driven variant (any-match-joins for multi-input — lives in
JobService) |
## Interfaces
### `LineageSignatureExtractor` — what counts as a fingerprint
```java
public interface LineageSignatureExtractor {
Set<LineageSignature> extract(Path file) throws IOException;
String name();
}
```
File-based (not stream-based) so a future PDF-aware extractor can open
the same file via jpdfium / PDFBox and pull `/ID[0]` or a content-stream
hash. Multiple extractors compose at the detector layer — Spring
auto-wires all `LineageSignatureExtractor` beans, the detector unions
their results.
Production impl: **`ByteHashSignatureExtractor`** — SHA-256 over the
file via 64 KiB-buffered `DigestInputStream`. Hardware-accelerated by
the JVM (Intel SHA-NI, ARM SHA).
### `JobLineageStore` — where signatures live
```java
public interface JobLineageStore {
void record(UUID jobId, Set<LineageSignature> signatures, ArtifactKind kind);
Optional<LineageMatch> findOpenJobForSignatures(Long userId, Set<LineageSignature> candidates, Duration window);
int pruneOlderThan(Instant cutoff);
}
```
Knows nothing about storage technology. Production impl
**`JpaJobLineageStore`** runs a single joined query against
`job_artifact_hash` ⋈ `processing_job` — status + window filtering
happen at the database. The query is bounded by `Limit.of(1)` on the hot
path so a job set sharing a popular signature doesn't materialise
unwanted rows. A future `RedisJobLineageStore` (or write-through hybrid)
is a drop-in.
### `HashLineageDetector` — the high-level API
```java
public interface HashLineageDetector {
Optional<LineageMatch> detect(Long userId, Path inputFile) throws IOException;
void record(UUID jobId, Path file, ArtifactKind kind) throws IOException;
}
```
**`DefaultHashLineageDetector`** delegates extraction to every
registered `LineageSignatureExtractor`, storage to the configured
`JobLineageStore`, and reads `payg.lineage.workflow-window` (default
`PT5M`) from config. When a single extractor throws (e.g. a future
PDF-aware extractor against a malformed PDF), the other extractors still
contribute — failures don't block the byte-hash from landing.
## Profile gating
All three `@Component` beans (`JpaJobLineageStore`,
`ByteHashSignatureExtractor`, `DefaultHashLineageDetector`) are
`@Profile("saas")` — consistent with every other `:saas` bean. Without
this guard the JPA store would fail to wire against its profile-gated
repository in non-saas profiles that pull `:saas` onto the classpath.
## Tests
Run entirely in-memory; no database required.
- **`LineageSignatureTest`** — storage-key encoding round-trips, rejects
malformed `"type:value"` keys.
- **`ByteHashSignatureExtractorTest`** — identical bytes → identical
sigs; empty file hashes to the well-known SHA-256-of-empty constant; 10
MiB file streams without OOM.
- **`DefaultHashLineageDetectorTest`** — same-user / within-window /
status=OPEN filtering, multi-signature matching (one extractor sees
`pdf-id` and matches even when bytes differ), most-recent-job-wins,
record+detect round-trip, extractor-throwing-doesn't-break-others.
**`InMemoryJobLineageStore`** (in test sources) implements the same
`JobLineageStore` interface as the JPA impl, plus a `registerJob` hook
for tests to model job state. Same contract — proves the abstraction is
portable. When the Redis impl lands it gets the same contract tests.
## What's not in this PR (deliberate)
- The tool ingress/egress filter that wires the detector into every
controller — separate, focused review.
- `JobChargeService.openProcess()` — uses the detector, part of the
charging machinery, separate PR.
- Prune scheduler that calls `pruneOlderThan` — small follow-up
alongside the `shedlock` foundational table.
- PDF-aware extractor (`PdfMetadataSignatureExtractor`) — to be added
when we measure how often byte-hash-only misses real workflows.
- Multi-input "any-match-joins" lineage policy — that's a `JobService`
decision (PR-I7), not a primitive.
## Self-review pass applied
An independent code-review on this PR caught:
- **HIGH:** Missing `@Profile("saas")` on the three `@Component` beans →
fixed.
- **MEDIUM:** `pruneOlderThan` missing `@Transactional` (its
`@Modifying` query would have thrown
`InvalidDataAccessApiUsageException`) → fixed.
- **MEDIUM:** `findOpenJobsForSignatures` fetching the whole match set
just to `get(0)` → now takes `Limit`, JPA store passes `Limit.of(1)` on
the hot path.
- **LOW:** `InMemoryJobLineageStore` used both `synchronized` methods
and `ConcurrentHashMap` → dropped the redundant `ConcurrentHashMap`.
Deferred: project-wide UTC unification (`LocalDateTime.now()`
system-zone is the established convention; flipping one file mid-stack
caused a real test failure — proper fix needs its own audit).
## Rollback
Straight `git revert`. No callers yet; deleting these classes wouldn't
break anything.
---
## Checklist
- [x] Tests pass: `ENABLE_SAAS=true ./gradlew :saas:test`
- [x] No new warnings
- [x] Self-review performed (HIGH + MEDIUM findings addressed)
# Description of Changes
## What & why
This PR introduces the **Stirling developer portal** — a new
control-plane frontend that sits alongside the existing PDF editor —
plus the shared design system and workspace structure needed to host
both apps in one frontend.
The portal is the parent product surface: where users connect sources,
compose pipelines, wire agents, and manage usage / billing /
infrastructure, with the PDF editor as one capability inside it. This PR
lays the **foundation** — workspace reshape, design system, app shell,
navigation, and a mock-driven home — rather than wiring real backends
(those surfaces are placeholders for follow-up phases).
## What's in this PR
**1. Frontend repo reshape (`frontend/src/` → `frontend/editor/`)**
The existing editor app moved under `frontend/editor/`, so `editor`,
`portal`, and `shared` are siblings in one workspace. All references
were updated accordingly: `LICENSE`, `.dockerignore`, `.gitignore`,
build/sign shell scripts, the GH language-check script, the Taskfile,
and Docker config. **No editor source logic changed — path references
only.**
**2. New shared design system (`frontend/shared/`)**
- **Design tokens** in `tokens.css` as the single runtime source of
truth (light/dark, category accents, gradients). `tokens.ts` now holds
only the `Tier` type — the old JS palette mirror was removed (nothing
consumed it and it had drifted).
- ~30 framework-light **components** (Card, Button, Input, Select, Tabs,
Modal, Drawer, Toast, MetricCard, StatusBadge, Skeleton, EmptyState, …)
with Storybook stories.
- **Typed data catalogues**: `endpoints.ts` (10 verticals / 64
endpoints) and `ops.ts`.
**3. New developer portal app (`frontend/portal/`)**
- App shell: `Header`, `Sidebar`, `AssistantPanel`, search modal,
notifications, tier switcher, theme toggle, MSW toggle.
- **Tier-aware** home (free / pay-as-you-go / enterprise): KPI strip,
30-day usage chart, onboarding checklist, quick actions, recent
activity, region health, product grid, and a curated **"Popular use
cases"** teaser.
- **Documents** view hosting the full, tab-filterable endpoint
catalogue.
- Placeholder views for Sources / Pipelines / Agents / Editor /
Infrastructure / Usage & Billing / Developer Docs / Settings (follow-up
phases).
- **MSW-mocked** API layer: `api/*` issues real `fetch`, intercepted by
mocks in dev/Storybook; pointing at a real backend is just a matter of
not registering MSW. `react-router` URLs; Tier / View / UI contexts.
**4. Tooling & guardrails**
- ESLint extended to `portal` + `shared`, with **layering-boundary
rules**: `shared/` may depend only on third-party packages and itself
(no `@app` / `@portal` / `@core` / `@proprietary` / Tauri), so it stays
cleanly extractable into a standalone package later.
- `dpdm` circular-dependency check now walks editor + portal + shared
(the old glob matched only 2 files).
- New **devDependencies only** — Storybook (+ a11y/docs/themes addons),
MSW. No runtime dependencies added.
- New tasks: `frontend:dev:portal`, `frontend:build:portal`.
## Testing done locally
- `tsc` for both `portal` and `shared` projects — clean
- `eslint --max-warnings=0` across the whole frontend — clean
- `dpdm` circular-dependency check — no cycles
- Editor builds clean: `vite build editor --mode core` (✓ built, only
the pre-existing >500 kB chunk-size advisory)
- Editor runs in dev (core mode) with **zero console errors**; portal
runs in dev across all three tiers
## Notes for reviewers
- The change is overwhelmingly **additive**: `shared/` and `portal/` are
brand-new; the existing editor is path-reference changes only.
- The portal is intentionally **mock-driven** at this stage — real
backends and the remaining views land in follow-up phases.
---
## 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)
- [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
- [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: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## What this is
PR-I1 service half from `notes/PAYG_DESIGN.md`. Built on top of the data
model from #6460 — answers "what pricing policy applies to this team
right now?" with a fast cache and an admin write surface.
## Scope
| Piece | Where |
|---|---|
| `PricingPolicyService` — `getEffectivePolicy(teamId)` with 30s
Caffeine cache + admin write paths |
`app/saas/.../payg/policy/PricingPolicyService.java` |
| `PolicyChangedEvent` — published after admin writes for in-process
cache invalidation | `app/saas/.../payg/policy/PolicyChangedEvent.java`
|
| Admin REST — list / get / create / set-default / set team override /
get effective |
`app/saas/.../payg/policy/admin/PricingPolicyAdminController.java` +
DTOs |
| `PricingPolicyRepository.clearDefaultFlag()` — atomic clear for
set-default | repository update |
| `SaasJpaConfigScanTest` — drift guard against the JPA scan paths going
stale (carried over from the #6460 review concern) | new test |
| V12 default-policy seed (`v1-initial`, 25 pages/unit, 5 MiB/unit,
per-`JobSource` step limits) | `V12__seed_default_payg_policy.sql` |
## Lookup precedence
1. `PaygTeamExtensions.pricingPolicyId` set → return that policy
2. Else return the `pricing_policy` row with `is_default = TRUE`
3. Override row points at a deleted policy → log warn, fall back to
default (safety net for racing deletes)
4. No default → `IllegalStateException` (V12 seed guarantees one exists)
## Cache behaviour
- 30s `expireAfterWrite` Caffeine, max 10k entries, keyed by `teamId`.
- **Single correctness model: the TTL.** Cross-instance propagation is
at-most-30-seconds. The writer instance sees its own change immediately
via the `PolicyChangedEvent` after-commit publish. Other instances pick
it up on the next TTL expiry.
- Admin reads use `getEffectivePolicyUncached` so admins always see
their own write straight back.
**Why no LISTEN/NOTIFY runner.** An earlier cut of this PR included a
Postgres `LISTEN policy_changed` runner so cross-instance propagation
was instant. Dropped — admin policy changes are events-per-week and the
30s TTL is already the correctness floor; the listener was ~250 lines of
nontrivial code (raw JDBC outside HikariCP, daemon thread, reconnect
loop, lock-protected connection lifecycle) for a use case that isn't on
the hot path. Trade-off is documented in `notes/PAYG_DESIGN.md` §9 with
three concrete triggers that would justify reintroducing it (aggressive
cap enforcement, Redis landing for other reasons, real-time admin UI).
## Writes — transactional, fire `PolicyChangedEvent` after commit
- `create(draft)` — rejects pre-set `policy_id` or `is_default=true`
(promotion must go through `setDefault` so the partial unique idx is
freed first).
- `setDefault(id)` — atomically clears the existing default via
`clearDefaultFlag()` then flips the new row. Idempotent: silent no-op if
the row is already default.
- `setTeamOverride(teamId, policyId | null)` — validates the policy
exists before save; `null` clears the override.
`publishOnCommit` uses `TransactionSynchronizationManager.afterCommit`
so listeners never see pre-commit state. Outside a transaction (test
paths) falls through to immediate publish.
## Admin REST surface — `/api/v1/admin/payg/...`
All endpoints `@PreAuthorize("hasRole('ADMIN')")`:
- `GET /policies` — list all
- `GET /policies/{id}` — read one
- `POST /policies` — create new (non-default)
- `POST /policies/{id}/set-default` — atomic promote
- `PUT /teams/{teamId}/policy-override` — set or clear per-team override
- `GET /teams/{teamId}/effective-policy` — cache-bypassing live read
Validation errors → 400, unknown rows → 404.
## Counterpart Supabase PR
[`Stirling-PDF-SaaS#298`](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/298)
— seeds the same V1 default policy on the Supabase side via
`20260528000002_payg_seed_default_policy.sql`.
## Tests
- 17 × `PricingPolicyServiceTest` — lookup precedence, cache hit/miss,
invalidation on event, mutation paths publishing event, error cases.
- 14 × `PricingPolicyAdminControllerTest` — every endpoint's happy path
+ error mapping, DTO defensive-copy invariant.
- 2 × `SaasJpaConfigScanTest` — reflection-based guard that
`payg.repository` is in `@EnableJpaRepositories` and `payg` is in
`@EntityScan`. Without this, new sub-packages can silently fail to wire
at runtime — same class of bug that the #6460 review caught.
Full `:saas:test` BUILD SUCCESSFUL.
## Design doc
`notes/PAYG_DESIGN.md` §7.4 PR-I1 — completes the service half (the
schema half landed in #6460). §9 carries the 30s-TTL trade-off note.
# Description of Changes
The production SaaS is currently on v8 of IndexedDB due to various
schema changes for Smart Folders, which haven't made their way into OSS.
OSS is currently on v4 of IndexedDB, so if we release an OSS build to
the SaaS deployment, existing users will not be able to use it because
the DB version is 'too old'.
This PR updates the IDB version number to v9 so both OSS and SaaS users
will be able to upgrade to it. Theoretically both types of user should
be able to keep their IDB files without issue. SaaS previously actively
wiped the user's files in an old version (v6/v7) and users who haven't
used it since then will have their DBs wiped, but that'd happen anyway
if they use current SaaS so I don't think that matters.
# Description of Changes
Main fixes:
- Fix the display of the username in the bottom left
- Now displays as "User" when not logged in on self-hosted (desktop) and
"Guest" on SaaS when logged in anonymously
- Now updates properly when the user logs in/out in SaaS, desktop and
self-hosted
- Fix incremental build issues in the desktop app that have been here
since the start (I hope at least - I think the issue is that the JLink
is built read-only and then on subsequent builds you get OS errors when
trying to override the JLink with the new version. There's no real need
for it to be read-only that I know of, so we might as well just make it
R/W and ship like that)
# Description of Changes
Two layers — the `DocumentClassifier` utility plus the full data model
for the new billing engine. Nothing wires the entities into application
behaviour yet; services and controllers land in follow-up PRs.
**Companion PR:**
[Stirling-PDF-SaaS#296](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/296)
— Supabase migration for the v3 dev branch, schema-equivalent to the
Flyway migration in this PR.
## 1. DocumentClassifier (under `payg.docs`)
`DocumentClassifier` computes the doc-unit cost of an uploaded file (or
multi-file input) under a `PricingPolicy`. PDFs read page count via
`stirling.software.jpdfium.PdfDocument`; non-PDFs are bytes-only.
Formula: `max(ceil(pages / docPagesPerUnit), ceil(bytes /
docBytesPerUnit))` clamped to `[1, fileUnitCap]`. Multi-file is the sum
of raw per-file units capped at `fileUnitCap × file_count`.
Two floors, by design: the classifier returns `docUnits` with an
absolute `1` floor for non-empty input; the policy-level
`minChargeUnits` is intentionally applied later, at process-open time in
`JobChargeService`, per design § 3.4 (`unitsForProcess =
max(policy.min_charge_units, docUnits)`). Documented in the interface +
impl javadoc.
Upload bytes are materialised through
`TempFileManager.createManagedTempFile` so jpdfium gets a `Path`; the
temp file auto-deletes on close.
Twelve tests, all in-memory fixtures generated with PDFBox at test time
— no committed binary blobs.
## 2. PAYG data model (under `payg.*`)
JPA entities, repositories, and a Flyway migration covering the full
schema in §6 of the design.
**Enums** (`payg.model`):
`JobSource`, `ProcessType`, `JobStatus`, `JobStepStatus`,
`ArtifactKind`, `LedgerEntryType`, `LedgerBucket`, `ReferenceType`,
`EntitlementState`, `FeatureSet`, `FeatureGate`, `WalletEngine`,
`CapPeriod`, `AutoGroupStrategy`.
**Entities + repositories:**
| Entity | Table | Notes |
|---|---|---|
| `PricingPolicy` | `pricing_policy` | Promoted from a record.
`stepLimits` is `Map<JobSource, Integer>` persisted via normalised child
table `pricing_policy_step_limit`. `stripePriceIds` is `Set<String>`
persisted via `pricing_policy_stripe_price` — currency comes from
`stripe.prices` via Sync Engine, not stored locally. |
| `ProcessingJob` | `processing_job` | UUID PK. Tracks lineage window
via `step_count` and `last_step_at`. |
| `ProcessingJobStep` | `processing_job_step` | Per-tool-call audit. |
| `JobArtifactHash` | `job_artifact_hash` | Composite key `(job_id,
content_hash, kind)`. `content_hash VARCHAR(128)` so multiple signature
schemes coexist as `"type:value"` storage keys. Lineage detector queries
this. |
| `WalletLedgerEntry` | `wallet_ledger` | Append-only, signed
`amount_units`. Two unique indexes kill double-posting. |
| `WalletPolicy` | `wallet_policy` | Per-team engine + cap + degradation
rules + lineage strategy. No `@Version` — admin-only writes (documented
in javadoc). |
| `WalletEntitlementSnapshot` | `wallet_entitlement_snapshot` |
Composite key `(team_id, user_id)`; `user_id = 0` is the team-wide
sentinel. No `@Version` — full-row recompute via
`EntitlementService.recompute` (documented in javadoc). |
| `PaygShadowCharge` | `payg_shadow_charge` | Per-job diff while in
`PAYG_SHADOW` engine mode. |
| `PaygTeamExtensions` | `payg_team_extensions` | Sidecar 1:1 with
`teams` carrying `pricing_policy_id` (per-team override) +
`stripe_customer_id`. Sidecar pattern (mirrors `saas_team_extensions`)
so OSS Hibernate ddl-auto never sees PAYG columns on `teams`. |
**Column adds:**
- `team_memberships.cap_units` (optional per-member sub-cap)
**Width split (intentional, documented in V11):** per-row deltas
(`wallet_ledger.amount_units`, `processing_job.charged_units`) are
`INTEGER` because no single charge realistically approaches 2B units.
Cap and period-rollup columns (`team_memberships.cap_units`,
`wallet_policy.cap_units`,
`wallet_entitlement_snapshot.period_spend_units / period_cap_units`) are
`BIGINT` because they accumulate across a billing period and admins may
legitimately set headroom-cap values into the millions.
**JPA wiring:** `SaasJpaConfig` was updated to include
`stirling.software.saas.payg.repository` in
`@EnableJpaRepositories.basePackages` and `stirling.software.saas.payg`
in `@EntityScan` (covers `payg.policy` / `payg.job` / `payg.wallet` /
`payg.entitlement` / `payg.shadow` recursively). New
`SaasJpaConfigScanTest` reads the annotations reflectively and asserts
every expected package is wired — catches the next time someone adds a
new sub-package without updating the scan paths.
**Migration:** `V11__saas_payg_model.sql` (purely additive).
Schema-equivalent to the Supabase migration in the companion PR —
including the `VARCHAR(128) content_hash` width that's needed for the
multi-signature-scheme storage encoding the lineage layer uses.
## 3. Smoke tests
`PaygEntitiesSmokeTest` exercises each entity via the no-arg ctor JPA
requires, plus getter/setter round-trips and composite-key equality —
catches Lombok/annotation regressions without needing a database.
Real-DB integration coverage lands alongside the services that consume
each entity.
## Why this is safe to land now
- All schema changes are additive — no existing rows modified, no
columns dropped.
- The entities are not yet referenced from any production code path;
they exist for the next PRs to build on.
- The v3 Supabase dev branch picks up the schema via the companion PR;
the main repo's Flyway migration applies the same shape when an instance
boots against a freshly-migrated v3 database.
## Open decisions made
- **Step-limits keyed by `JobSource`** rather than by `ProcessType`.
Captures the "self-hosted gets a different knob" framing in earlier
feedback. Trivially overridable per pricing policy version.
- **Step limits + Stripe price IDs normalised into child tables** rather
than JSONB on `pricing_policy` (per Connor's review on #296). Typed
columns, queryable directly, no JSON parsing.
- **Currency dropped from `pricing_policy_stripe_price`** — it lives on
`stripe.prices.currency` and is resolved via Sync Engine. App is
currency-blind.
## Rollback
Straight `git revert` on this PR. The Supabase migration in #296 is
additive and can be left in place safely — the running app ignores
tables it doesn't reference.
---
## Checklist
- [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)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
- [x] I have run `task check` (via `./gradlew :saas:test` with
`ENABLE_SAAS=true`) — passes
# Description of Changes
Set CI backend & engine comments to auto-delete once the CI has passed.
Also redesign the engine CI to call `task engine:check` like it should
have been, and make it post a comment when the tool models need to be
updated.
Also makes the comment wording more consistent between the three
languages.
# Description of Changes
Two narrowly-scoped hardening changes to the credits engine.
## 1. CreditService — move Stripe meter call to `afterCommit`
The Stripe metered-usage call sits inside the surrounding
`@Transactional`, holding the `user_credits` row lock for the duration
of an HTTP round-trip to Supabase. Under load this starves concurrent
debits; a transient Stripe blip rolls back a (correct) free-credit
consumption and forces the caller to retry.
The Stripe call now runs in a `TransactionSynchronization.afterCommit`
hook — DB commits first, Stripe fires immediately after. If Stripe fails
after commit, we log + increment a new `credits.stripe_report.failures`
counter; the idempotency key is stable, so a manual replay recovers
without double-charging.
Applied to both `consumeCreditBySupabaseId` and
`consumeCreditWithWaterfall`.
**Dead-code removed:**
- Unreachable UUID fallback for MDC `requestId` — `CorrelationIdFilter`
already guarantees the key on every request.
- The `"Unable to report usage to Stripe"` `RuntimeException` and its
catch block — the afterCommit refactor eliminates the throw path.
- `StripeRollbackOnFailureTest` — pinned the rollback-on-Stripe-fail
behaviour this refactor replaces.
## 2. `@AutoJobPostMapping` — build-time lint for `resourceWeight`
`UnifiedCreditInterceptor` multiplies `resourceWeight` into the per-call
charge. An endpoint that falls through to the annotation default
produces a charge derived from a value nobody chose.
- Annotation default flipped from `1` to `Integer.MIN_VALUE` (sentinel).
Both runtime readers (`UnifiedCreditInterceptor`, `AutoJobAspect`)
already clamp into `[1, 100]` so behaviour is unchanged.
- New `AutoJobPostMappingWeightTest` scans the classpath and fails the
build if any method leaves the sentinel.
- Initial run caught 11 endpoints relying on the default. Explicit
weights now declared, chosen by comparing to peer endpoints:
- `EditTextController` — LARGE
- `EmailController#sendEmailWithAttachment` — SMALL
- `ConvertPDFToMarkdown` — MEDIUM
- `AttachmentController` (extract/list/rename/delete) — SMALL × 4
- `ConvertImgPDFController` (cbr/cbz ↔ pdf) — MEDIUM × 2, LARGE × 2
## Tests
- `StripeUsageIdempotencyKeyTest` — pins the `(supabaseId, overage,
requestId)` idempotency key shape so Stripe always dedupes a retry.
- `StripeAfterCommitOrderingTest` — pins that `afterCommit` fires after
commit and NOT on rollback.
- `AutoJobPostMappingWeightTest` — the lint itself, plus a self-check
that the classpath scan finds at least 10 `@AutoJobPostMapping` methods
(guards against the lint passing vacuously).
Build verified: `ENABLE_SAAS=true ./gradlew :stirling-pdf:test
:saas:test`.
---
## 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) — no translation changes
- [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/)
— internal-billing change, no public docs impact
- [ ] 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)
— N/A
### Translations (if applicable)
- [ ] Not applicable
### UI Changes (if applicable)
- [ ] Not applicable
### Testing (if applicable)
- [x] I have run `task check` (via `./gradlew :stirling-pdf:test
:saas:test` with `ENABLE_SAAS=true`) — passes
- [x] I have tested my changes locally
## Summary
Regression from #6404 (Restructure/frontend editor). Two CI workflows
copy the built installers to the wrong directory, so installer artifacts
(MSI / DMG / DEB / RPM / AppImage) silently vanish:
- **`tauri-build.yml`** (PR/desktop smoke builds) - uploads zero
installer artifacts.
- **`multiOSReleases.yml`** (production releases) - the empty artifacts
are downloaded by `create-release` and fed to `action-gh-release`, so a
release would publish **only the JARs, no desktop installers**.
## Root cause
#6404 moved the Tauri project from `frontend/` to `frontend/editor/` and
updated every **absolute** path (`projectPath`, `cd`, `Get-ChildItem`)
to add the `editor/` segment - but left the **relative** copy targets
`../../../dist`. Those resolve against the (now one level deeper)
working dir after `cd ./frontend/editor/src-tauri/target`:
| | resolves to |
|---|---|
| before #6404 (`frontend/src-tauri/target`) | repo-root `dist/` ✅ |
| after #6404 (`frontend/editor/src-tauri/target`) | `frontend/dist/` ❌
(missing) |
The `cp` fails, repo-root `dist/` (from `mkdir -p ./dist`) stays empty,
and the upload finds nothing. `find -exec cp` failing is non-fatal, so
jobs still report success - that's why it went unnoticed. No release has
shipped broken yet: the last release (v2.11.0, 2026-05-19) predates
#6404 (2026-05-22).
## Fix
Copy to an absolute `$GITHUB_WORKSPACE/dist` in both workflows so the
`cd` can't drift the destination again. This matches where the upload /
signature-verify steps already read from.
## Evidence (run 26574078559, all 3 OS legs)
```
cp: cannot create regular file '../../../dist/Stirling-PDF-windows-x86_64.msi': No such file or directory
##[warning]No files were found with the provided path: ./dist/*. No artifacts will be uploaded.
```
The Tauri builds themselves succeeded - only the copy/upload was broken.
## Test plan
- [ ] `tauri-build` on this PR uploads non-empty `Stirling-PDF-<name>`
artifacts on Windows/macOS/Linux.
- [ ] Next release (or a `workflow_dispatch` of multiOSReleases)
attaches MSI/DMG/DEB/RPM/AppImage to the release.
# Description of Changes
#6402 introduced a Rust test `refresh_token_fallback.rs`, but it wasn't
moved properly after the restructure of the `frontend/` folder in #6404.
This PR moves the file to the right place, and also hooks up Task and CI
rules for `cargo test` since nothing was actually running the test in
the first place.
# Description of Changes
Various fixes and improvements I made while testing the SaaS code:
- Changes the new `.env.saas` file to live in `app/` and match the
semantics of the other `.env` files
- Adds top-level `task dev:saas` command to spawn SaaS frontend &
backend
- Deletes dead SaaS code and improves some overriding logic
- Fixes refreshing issue when coming back to the tab
- Fix the Compare tool's selection logic
- Make Compare handle error cases properly
- Fixes the location of the "Dismiss All Errors" button (was rendering
on top of the top-bar with a transparent background previously so it
looked rubbish)
- Fixes file selection in PDF Editor
# Description of Changes
Adds a cancel button to the AI chat to allow the user to abort
long-running AI tasks. Just disconnects the SSE stream (all the backend
code already interrupts when it notices the stream is dead).
# Description of Changes
## What & why
Customers using ADFS (or any generic OIDC provider that doesn't emit
`email`) hit `Attribute value for 'email' cannot be null` during OAuth2
login with no visibility into what claims the provider actually sent.
The only available remedy was guessing at
`security.oauth2.useAsUsername` until something worked.
This PR adds a new opt-in `security.oauth2.debugLogging` flag (default
`false`). When enabled, `CustomOAuth2UserService` logs:
- All ID token claims (sorted, with values)
- All UserInfo endpoint claims (if any)
- The merged attribute key set Spring exposes to `getAttribute()`
- The value the configured `useAsUsername` actually resolved to
- A **`Hint:`** line listing the claim keys present in the token that
map to a valid `UsernameAttribute` enum value — i.e. exactly what the
operator could put in `useAsUsername` to make login work
Logged at `INFO` on the success path and `ERROR` on failure (inside the
existing `catch (IllegalArgumentException)` block that throws
`OAuth2AuthenticationException`). The block is wrapped with a `[OAUTH2
DEBUG] ... [/OAUTH2 DEBUG]` banner and ends with a PII warning so
operators don't leave it on in production.
Default off → zero observable change for anyone not actively
troubleshooting.
## Files changed
| File | Why |
|---|---|
| `app/common/.../ApplicationProperties.java` | New `debugLogging` field
on the `OAUTH2` config class with javadoc warning about PII |
| `app/core/src/main/resources/settings.yml.template` | Documents
`oauth2.debugLogging` so it appears on next startup |
| `app/proprietary/.../security/service/CustomOAuth2UserService.java` |
Emits the claim dump + suggestion hint when the flag is on |
|
`app/proprietary/.../security/service/CustomOAuth2UserServiceDebugLoggingTest.java`
(new) | Unit test: mocks the OIDC delegate, asserts off-path is silent
and on-path emits the dump with the right Hint contents |
## End-to-end verification
Ran the bundled `testing/compose/docker-compose-keycloak-oauth.yml`
Keycloak realm, configured `security.oauth2.useAsUsername: mail`
(Keycloak emits `email`, not `mail`) and `provider: demarest` (matches
the original customer bug report). Triggered the OAuth flow at
`http://localhost:8080/oauth2/authorization/demarest` and confirmed:
- The ERROR-level dump fires with the full 19-claim ID token decoded
- `-- Value at 'mail' : <NULL — this is why login fails>` correctly
identifies the missing claim
- `-- Hint:` correctly suggests `[email, family_name, given_name,
preferred_username]` (the four keys present that map to valid
`UsernameAttribute` values)
- Auth still fails with the original `OAuth2AuthenticationException` —
no change to control flow, just added diagnostic logging
Unit test (`CustomOAuth2UserServiceDebugLoggingTest`) covers both
branches.
## Reviewer notes
- **No new public APIs.** The flag is config-only; no servlet endpoints
exposed.
- **PII is logged when the flag is on.** This is the whole point —
operators need to see the claims to fix their config — but it's gated,
defaults off, and the dump self-documents with a `WARNING: ... Set
security.oauth2.debugLogging=false once troubleshooting is complete.`
footer.
- **Why log everything, not just sub/email?** Because the operator
doesn't know in advance which claim they actually want. ADFS uses `upn`
in some configs and `preferred_username` in others; Azure AD uses `oid`;
the customer here had neither. Dumping the full set is the only way to
make the diagnostic self-service.
- **Out of scope for this PR (follow-ups):**
- The `UsernameAttribute` enum doesn't include `upn` / `unique_name`
(common ADFS claims). If the customer's token only has `upn`, the Hint
will be empty even though the operator can see `upn` in the dump. Worth
a separate PR to extend the enum.
- The known-provider validator in `Provider.java` (rejects e.g.
`useAsUsername: mail` for `provider: keycloak` at startup) bypasses our
diagnostic for those provider names. ADFS customers using `provider:
<name>` fall into the `default` branch so are not affected — but it's a
sharp edge worth documenting.
---
## 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) — N/A, backend-only change
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] Doc-repo update (if functionality has heavily changed) —
diagnostic flag is self-documenting via the `settings.yml.template`
comment and the in-log warning; happy to add a doc-repo entry if
reviewers want one
- [ ] Translation tags — N/A
### UI Changes (if applicable)
- [ ] N/A — backend-only
### Testing (if applicable)
- [x] Unit test added (`CustomOAuth2UserServiceDebugLoggingTest`)
covering on/off paths and Hint correctness
- [x] End-to-end verified locally against bundled Keycloak compose with
intentionally misconfigured `useAsUsername`
- [x] Full `:proprietary:test` suite passes
`app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java:175`
section comment said "Health Endoints" → "Health Endpoints".
Comment-only.
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Bumps the pip group with 1 update in the /testing/cucumber directory:
[idna](https://github.com/kjd/idna).
Updates `idna` from 3.12 to 3.15
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/kjd/idna/blob/master/HISTORY.md">idna's
changelog</a>.</em></p>
<blockquote>
<h2>3.15 (2026-05-12)</h2>
<ul>
<li>Enforce DNS-length cap on individual labels early in
<code>check_label</code>,
short-circuiting contextual-rule processing for oversized input
while staying compatible with UTS 46 usage.</li>
<li>Tidy core helpers: hoist bidi category sets to module-level
frozensets (avoiding per-codepoint list construction), simplify
length checks, and reuse the shared <code>_unicode_dots_re</code> from
<code>idna.core</code> in the codec module.</li>
<li>Use <code>raise ... from err</code> for proper exception chaining
and
switch internal string formatting to f-strings.</li>
<li>Allow <code>flit_core</code> 4.x in the build backend.</li>
<li>Expand the ruff lint set (flake8-bugbear, flake8-simplify,
pyupgrade, perflint) and apply the surfaced fixes; pin lint CI
to Python 3.14.</li>
<li>Add Dependabot configuration for GitHub Actions.</li>
<li>Convert README and HISTORY from reStructuredText to Markdown.</li>
<li>Reference CVE-2026-45409 for the 3.14 advisory in place of the
initial GHSA identifier.</li>
</ul>
<p>Thanks to Felix Yan, Stan Ulbrych, and metsw24-max for
contributions to this release.</p>
<h2>3.14 (2026-05-10)</h2>
<ul>
<li>Removed opportunity to process long inputs into quadratic
time by rejecting oversize inputs up-front. Closes a bypass
of the CVE-2024-3651 mitigation. [CVE-2026-45409]</li>
</ul>
<p>Thanks to Stan Ulbrych for reporting the issue.</p>
<h2>3.13 (2026-04-22)</h2>
<ul>
<li>Correct classification error for codepoint U+A7F1</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/kjd/idna/commit/af30a092e158181d0b35ac66dfa813788126bdd8"><code>af30a09</code></a>
Release 3.15</li>
<li><a
href="https://github.com/kjd/idna/commit/30314d4628744ca14cf2b5820564e5127a9f86f2"><code>30314d4</code></a>
Pre-release 3.15rc0</li>
<li><a
href="https://github.com/kjd/idna/commit/05d4b219aa9eddc47371fcbd2000f0301016f3e9"><code>05d4b21</code></a>
Merge pull request <a
href="https://redirect.github.com/kjd/idna/issues/237">#237</a> from
kjd/convert-docs-to-markdown</li>
<li><a
href="https://github.com/kjd/idna/commit/2987fdba1962bbb2358399e0084ba062b98a0bee"><code>2987fdb</code></a>
Convert README and HISTORY from reStructuredText to Markdown</li>
<li><a
href="https://github.com/kjd/idna/commit/59fa8002d514bf4a5ce7b58f67b9ec587d53fa9c"><code>59fa800</code></a>
Merge pull request <a
href="https://redirect.github.com/kjd/idna/issues/236">#236</a> from
kjd/dependabot/github_actions/actions-f3e34333ea</li>
<li><a
href="https://github.com/kjd/idna/commit/def69834ced5d4b3c50439d8b99c4c856ec19ca2"><code>def6983</code></a>
Merge branch 'master' into
dependabot/github_actions/actions-f3e34333ea</li>
<li><a
href="https://github.com/kjd/idna/commit/bbd8004a797185d8c56bb555cd5c88fde05e0631"><code>bbd8004</code></a>
Merge pull request <a
href="https://redirect.github.com/kjd/idna/issues/234">#234</a> from
StanFromIreland/patch-1</li>
<li><a
href="https://github.com/kjd/idna/commit/edd07c05024344a6ccb517414ccb36683aee99fc"><code>edd07c0</code></a>
Bump github/codeql-action from 3.35.2 to 4.35.2 in the actions
group</li>
<li><a
href="https://github.com/kjd/idna/commit/5557db030c11bdec50d62aa5f631d705d33ba123"><code>5557db0</code></a>
Merge branch 'master' into patch-1</li>
<li><a
href="https://github.com/kjd/idna/commit/f11746cf4981d25123ef7830d3ee60f07de8ae3d"><code>f11746c</code></a>
Merge pull request <a
href="https://redirect.github.com/kjd/idna/issues/235">#235</a> from
StanFromIreland/patch-2</li>
<li>Additional commits viewable in <a
href="https://github.com/kjd/idna/compare/v3.12...v3.15">compare
view</a></li>
</ul>
</details>
<br />
[](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 <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>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
## Summary
Adds a new AI specialist that finds **textual contradictions** across
one or more PDFs — conflicting claims, recommendations, points of view,
contested facts — built entirely in Python on top of the new
`DocumentService` + `ChunkedReasoner` stack from #6314. Replaces the
closed#6304, which was started before #6314 landed and therefore
over-engineered (Java orchestrator, two-round handshake, resume
artifact, discriminated-union lift).
Two commits:
1. **`refactor(engine): extract ChunkedMapper[T] from ChunkedReasoner`**
— pure refactor, public API of ChunkedReasoner unchanged. New
`ChunkedMapper[T: BaseModel]` is a generic parallel-chunk primitive
(slicing, semaphore, time-bounded extraction, cancellation drain,
progress events) that's now a peer to ChunkedReasoner rather than locked
inside it. The compression loop stays on ChunkedReasoner where it
belongs.
2. **`feat(ai): add Contradiction Agent on ChunkedMapper`** — the agent
itself, plus integrations into `PdfReviewAgent` and `PdfQuestionAgent`.
## Architecture
- **Python-only.** No Java code. No `AgentToolId.CONTRADICTION_AGENT`.
No dedicated HTTP endpoint. No resume artifact, no discriminated-union
lift in `contracts/common.py`. Detector runs inside the Python engine
and the Python engine alone.
- **Review path** (`PdfReviewAgent`): a new
`ContradictionIntentClassifier` fires on contradiction-flavoured
prompts; agent runs detection synchronously and emits a single
`EditPlanResponse(steps=[ADD_COMMENTS])`. Single-turn flow — no resume.
- **Question path** (`PdfQuestionAgent`): a new
`ContradictionCapability` joins `RagCapability` and
`WholeDocReaderCapability` in the smart-model toolset, exposing
`find_contradictions(query)`. The smart model picks it from the toolset
alongside `search_knowledge` and `read_full_document`.
## Inside `ContradictionDetector.detect()`
1. `DocumentService.read_pages(file_id)` → ordered `list[Page]`.
2. `ChunkedMapper[_ExtractedClaims].map_pages(...)` — char-budgeted
multi-page slicing; each slice runs the claim-extractor LLM in parallel
under a semaphore.
3. Page-traceability: the extractor returns `_ExtractedClaim.page`
(which `[Page N]` marker the claim came from). The wrapper validates
`page ∈ chunk.pages`; if not, mechanical fallback searches the chunk's
page text for the verbatim quote and reassigns. If still no match, drop
the claim.
4. `Claim.anchor_quality: Literal[\"verbatim\", \"paraphrased\"]` is set
by a substring check against the declared page's text. Verbatim quotes
feed `anchor_text` for snap-to-quote add-comments placement; paraphrased
ones fall back to margin geometry.
5. Subject canonicalisation: ONE fast-model LLM call collapses synonyms
across the document. Fails open to lexical bucketing.
6. Pre-filters: drop identical-quote pairs; drop same-page same-polarity
paraphrases.
7. Per-bucket pair detection in parallel (separate semaphore, cap 5).
Buckets > 12 claims chunk into windows of 12 with overlap 2; pairs
deduped across overlapping windows by frozen `(i, j)` index pair.
8. Summary fast-model call with fallback string on error.
## Prompt-injection hardening
Every prompt that interpolates user-supplied or PDF-extracted text wraps
content in `<user_message>` / `<verdict>` / `<content>` tags with an
explicit SECURITY preamble instructing the model to treat tagged content
as data only.
## Limitations
- **Combined math + contradiction intent**: when both intent classifiers
fire on the same prompt, contradiction takes precedence and the math
intent is silently dropped. Documented in the Review module docstring
and pinned by
`test_review_integration.py::test_contradiction_precedence_over_math`.
- **Cross-window contradiction reach**: within a subject bucket, pairs
more than ~10 claim indices apart in the same chunked window may be
missed by the overlap-2 strategy. Documented in `test_detector.py`.
Acceptable for v1.
## Settings (engine/src/stirling/config/settings.py)
```python
contradiction_detect_concurrency = 5 # per-bucket detector semaphore
contradiction_bucket_chunk_size = 12 # max claims per detector call
contradiction_bucket_chunk_overlap = 2 # overlap for >threshold buckets
```
`chars_per_slice` and extraction concurrency are reused from the
existing `chunked_reasoner_*` settings.
## Test plan
- [x] `uv run pytest tests/ -v` — **245/245 pass** (210 pre-existing +
35 new)
- [x] `uv run ruff check src/ tests/` — clean
- [x] `uv run pyright src/stirling/agents/contradiction/
src/stirling/contracts/contradiction.py` — 0 errors
- [x] `./gradlew :proprietary:test` — green; no Java was touched, but
verified untouched
- [x] Page-traceability tests cover: valid page kept, hallucinated page
dropped, mechanical-reassign on misattribution, anchor-quality verbatim
vs paraphrased
- [x] Review integration: ADD_COMMENTS plan with two paired CommentSpecs
per contradiction; NeedIngestResponse precheck; precedence vs math
intent pinned
- [x] Question integration: all three capabilities wired into
smart-model toolset; `find_contradictions` returns formatted report text
- [x] ChunkedMapper standalone: slicing, multi-chunk ordering, worker
failures, timeouts, cancellation drain, semaphore saturation
- [x] ChunkedReasoner regression: all pre-existing tests pass unchanged
after the internal split
## Relationship to closed#6304#6304 was closed in favour of this PR. The closed PR predated #6314 and
modelled the agent as a Java-orchestrated two-round examine/deliberate
flow with its own HTTP endpoint and a discriminated-union resume
artifact. With #6314 making full ordered page text available to the
engine via `DocumentService.read_pages`, none of that is needed. Net
effect: drop ~600 lines of Java, drop the two-round handshake, drop the
`ToolReportArtifact` lift, while ending up with a more scalable agent
(chunk-based instead of page-based extraction; tested to
ChunkedReasoner-equivalent scale).
## Summary
`#6145` (port picker) and `#6244` (gradle unification) combined to break
`task dev` / `task dev:all` on Windows. Two independent regressions,
both addressed here.
### 1. `find-free-port.ps1` panic (`#6145`)
```
$ task --dry dev
The argument 'scriptsfind-free-port.ps1' to the -File parameter does not exist.
panic: ended up with a non-nil exitStatus.err but a zero exitStatus.code
```
- **Backslash stripped.** `Taskfile.yml` had
`scripts\find-free-port.ps1`. go-task pipes the `sh:` block through
mvdan/sh, which treats `\` as a POSIX escape and silently drops it,
leaving `scriptsfind-free-port.ps1`. PowerShell can't find the file,
exits non-zero, mvdan/sh panics on the inconsistent exit status.
Switched to a forward slash.
- **`-Preferred 8080,5173` is brittle.** Relies on PowerShell parsing
the comma-list into `[int[]]`. Dropped the named flag; switched the
script's `param` to `[Parameter(ValueFromRemainingArguments =
$true)][int[]]$Preferred`; pass each port as its own positional token
(matching how the bash variant is called).
### 2. `bash gradlew` can't find Java on Windows (`#6244`)
```
[backend:dev] ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
```
`#6244` unified all gradle invocations under `bash gradlew` on the
assumption that Git-Bash inherits Windows-side env. It doesn't (and
neither does WSL bash, which can also shadow `bash` on a developer's
PATH). The standard Adoptium/Temurin installer sets `JAVA_HOME` in the
Windows env only, so `bash gradlew` fails before Spring Boot even loads.
Restored the per-platform branch for every backend task: `cmd /c
".\gradlew.bat ..."` on Windows, `./gradlew ...` on Linux/macOS. Two
Windows-specific gotchas to be aware of for future edits:
- mvdan/sh strips `\` outside of double quotes, so `cmd /c
.\gradlew.bat` ends up as `.gradlew.bat`. The entire payload must be
wrapped in double quotes (`cmd /c "..."`).
- Modern Windows excludes cwd from cmd.exe's search path, so the leading
`.\` is required — bare `cmd /c gradlew.bat` errors with "is not
recognized" even from the repo root.
## Test plan
- [x] `task --dry dev` / `task --dry dev:all` on Windows resolve ports
cleanly and dispatch the inner tasks
- [x] `powershell -NoProfile -File scripts/find-free-port.ps1 8080 5173
5001` prints three ports
- [x] `task backend:dev PORT=8081` on Windows starts Spring Boot in ~9s;
`/api/v1/info/status` returns `{"status":"UP"}`
- [x] `task dev:all` on Windows brings up all three services healthy:
backend `/api/v1/info/status` 200, engine `/health` 200, frontend `/`
200
- [ ] Linux / macOS path unaffected (only the Windows branches changed
in behaviour)
## Summary
The frontend was rerendering excessively across many interactions —
typing, clicking tools, opening modals, toggling the sidebar — because
**multiple compounding ref-instability cascades defeated `memo()` checks
in hot paths**. This PR fixes the cascades structurally.
Seven focused commits, low-to-high blast radius:
1. `perf(contexts): memoize BannerContext provider value`
2. `perf(contexts): memoize CommentAuthor and ActiveDocument provider
values`
3. `perf(contexts): memoize AppConfigContext provider value`
4. `perf(useToolManagement): stop spreading tool entries to keep refs
stable` — **root-cause fix**
5. `refactor(ToolPicker): hoist module-scope styles and helpers`
6. `feat(ToolWorkflowContext): add ref-stable Actions and Data subset
contexts` — additive
7. `perf(tools): migrate hot consumers to slim contexts and wrap in
memo()`
(Plus `style: apply prettier formatting` for CI.)
## What was wrong
Whenever something high-up in the tree caused a render, a chain of
unstable references propagated downward and forced every `ToolButton` to
re-execute its full body (hooks, derived computations, hook
subscriptions to other contexts). The chain:
- **4 unstable Context providers** (`Banner`, `CommentAuthor`,
`ActiveDocument`, `AppConfig`) were passing fresh `value={{ … }}`
objects on every render. Every consumer rerendered on every ancestor
render.
- **`useToolManagement.toolRegistry`** spread `{...baseTool, name,
description}` — a no-op spread that manufactured a new tool object
identity on every memo recompute.
- **The big `ToolWorkflowContext`** (25+ fields including
`state.searchQuery`) rebuilt its entire value on every
keystroke/click/toggle, forcing every `useToolWorkflow()` consumer (~36
files) to rerender.
- **`useToolNavigation`** transitively subscribed every `ToolButton` to
the full workflow context.
- **`ToolButton` & `ToolPicker`** weren't `memo()`-wrapped, so nothing
checked.
- **`ToolPanel`** passed inline `onSelect={(id) =>
handleToolSelect(...)}` — fresh ref every render, defeats child
memoization.
- **`ToolPicker`** allocated inline styles / `[]` / `toTitleCase` inside
the function body — churned `useToolSections`'s internal memo.
## Interaction matrix — what improves
The PR fixes the underlying ref-stability problem; the same fix benefits
*every* interaction that previously triggered the cascade:
| Interaction | Before | After |
|---|---|---|
| **Typing in tool search** | All visible buttons rerender per keystroke
| Only buttons whose matched-text changes rerender |
| **Clicking a tool** | All 36 `useToolWorkflow()` consumers rerender |
Only previously-selected and newly-selected buttons rerender (via
`isSelected` prop) |
| **Toggling sidebar / panel mode / reader mode** | Every tool button
rerenders | Tool components stay still (slim context doesn't see UI
state) |
| **Switching workbench / navigation** | `handleToolSelect` identity
changes → cascades through `onSelect` props | Ref-stabilized in Actions
context. Identity stable. Children's memo bails |
| **Modal/dialog open/close** | AppConfig churns → every `useAppConfig`
consumer rerenders (ToolButton reads `premiumEnabled`) | AppConfig
memoized; consumers rerender only when config changes |
| **Banner show/hide** | BannerProvider value churns → every consumer
rerenders on any ancestor render | Memoized; AppLayout rerenders only
when banner content changes |
| **Any state update high in the tree** | Compounding cascade defeats
memo everywhere | Stable subscriptions; memo bails out |
## Evidence
Per-keystroke prop instability on `ToolButton` (cleanest measurable
signal, captured via custom memo comparators logging which prop refs
differ):
| | `tool` ref diffs | `onSelect` ref diffs | `matchedSynonym` value
diffs | Total |
|---|---|---|---|---|
| Before | 18 | 18 | 6 | **42** |
| After | 0 | 0 | 6 | **6 (all legitimate)** |
→ **86% reduction** in spurious per-keystroke prop instability. The 6
remaining matched-synonym diffs are correct (different substring
highlighted per keystroke).
Context value rebuild counts during a keystroke (verified with
instrumented `useMemo` factories): `useToolWorkflowData=0`,
`useToolWorkflowActions=0`, `AppConfigContext=0`.
The same stabilization applies to click/toggle/modal interactions — they
were all driven by the same cascading invalidations.
## Honest caveat on render-count metrics
`React.Profiler` counts and function-body execution counts in **dev
mode** came back identical before vs after (StrictMode + concurrent
rendering + Mantine internal commits dominate the numbers). The PR's
value is measured against the **prop-stability signal** above, not
Profiler counts. Production builds — where StrictMode doesn't
double-render and Mantine internals aren't constantly committing — will
show memo bail out properly.
## Risk × benefit
| # | Commit | Risk | Benefit |
|---|--------|------|---------|
| 1 | BannerContext memo | ⬛ Trivial | 🟦 Small |
| 2 | CommentAuthor + ActiveDocument memo | ⬛ Trivial | 🟦 Small |
| 3 | AppConfig memo | ⬛ Trivial | 🟦 Moderate (wide consumer base) |
| 4 | useToolManagement spread removal | ⬛ Trivial | 🟥 **High (root
cause)** |
| 5 | ToolPicker hoist | ⬛ Trivial | 🟦 Small |
| 6 | ToolWorkflowContext split | 🟧 Low-Med | 🟥 **High (foundation)** |
| 7 | Hot consumer migration + memo | 🟧 Low-Med | 🟥 **High
(actualization)** |
Commit 6 introduces an invariant: ref-stabilized callbacks in the
Actions context must only be invoked from event handlers (post-commit),
never during render. All current call sites comply.
## Test plan
- [x] `npx playwright test --project=stubbed` — 145 / 6 skipped / 0
failed before and after.
- [x] Targeted regression: `main-dashboard`, `tool-search`, `navigation`
— 11/11 passing.
- [x] CI passing on commits (one infrastructure flake on
`docker-compose-tests` — "No space left on device" — unrelated;
rerunning).
- [ ] Manual sanity check in a dev build after merge.
## What this enables
The same Actions + Data subset-context pattern can be applied to
`FileContext`, `NavigationContext`, and other big contexts. The
foundation is in place.
# Description of Changes
Give Edit Agent access to descriptions of the request from the Java API.
This opens the door to us better documenting our Java APIs to give the
stirling engine better knowledge of what the various tools are and how
to use them.
Also improves the tool selection sub-agent to get the tool parameters
and descriptions so it can more intelligently decide which operations
should be used to fulfil the user's request. Also provides it more
encouragement to string together multiple operations if necessary.
# Description of Changes
Adds storage in the database for full document content alongside the RAG
content (and changes the service to `DocumentService` instead of
`RagService`). Then adds a generic capability that should be usable by
any agent (currently just used by the Question Agent) which allows the
agent to pull out the full contents of the doc, chunks it into various
sections that will fit in the context window, and then processes them in
parallel to create an intermediate result, and then processes the
intermediate result into a final answer. It will re-chunk as many times
as necessary to get the content small enough for the actual answer to be
analysed (I've tested on PDFs ~3500 pages long, which is well above the
context limit and requires maybe 3 rounds of compression to get an
answer).
The new full doc analysis stuff is heavier than the RAG lookup so both
remain. The agents should use RAG for targeted info and the chunked
reasoner for info that requires reading the full doc.
# Description of Changes
#6312 reformatted `tauri.conf.json` via the Gradle script, which
reformats the entire file to not match the Prettier style. This PR
reformats the file back to Prettier format and changes the script to
update the version number without reformatting the entire file.
To be honest I'm not a huge fan of updating the version number with
regexes but it'd be a fool's errand to try and get Gradle to output JSON
in Prettier format, and this seems simpler than shelling out to run
Prettier over the file after the version string has been updated. Any
better ideas, let me know.
Auto-generated by stirlingbot[bot]
This PR updates the backend license report based on dependency changes.
---------
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
## Description
Consolidates Playwright running under cohesive Task namespaces, isolates
Playwright state from the developer's local working tree, and swaps CI's
frontend webserver from `vite` dev to `vite preview` against a pre-built
`dist/`.
### `e2e:*` namespace
Renames `.taskfiles/testing.yml` to `.taskfiles/e2e.yml` and
consolidates everything Playwright-related under one `e2e:` namespace:
- `e2e:stubbed` / `e2e:live` / `e2e:enterprise` / `e2e:cross-browser`:
project-specific runners
- `e2e:check` (no-Docker subset) and `e2e:check:all` (full)
- `e2e:oauth:up` / `:down`, `e2e:saml:up` / `:down`: symmetric lifecycle
for the keycloak compose stacks
- `e2e:install`: Playwright browser install
- `docker:test`: full Docker integration suite
The redundant `frontend:test:e2e:*` project shortcuts are removed. CI
workflows (`e2e-stubbed.yml`, `e2e-live.yml`, `build-enterprise.yml`,
`nightly.yml`) are updated to call the new task names.
### Isolated Playwright state
New `STIRLING_BASE_PATH` (and `-Dstirling.base-path=`) override in
`InstallationPathConfig` redirects the entire state tree (configs,
backups, customFiles, pipeline, logs) at startup. `task e2e:live` points
it at `.test-state/playwright/` (purged on every invocation) so the
suite never touches the developer's local DB, settings.yml or backups.
`task e2e:live` auto-spawns gradle, waits for `/api/v1/info/status` to
come up, runs Playwright, then tears down the whole backend process
tree.
### CI runs Playwright against `vite preview`
Builds the frontend up-front with `VITE_BUILD_FOR_PREVIEW=1` (forces
absolute base so deep SPA routes resolve `/assets/...`) and the
playwright `webServer` now uses `vite preview --port 5173 --strictPort`
in CI. Avoids the per-page on-demand transform cost that was blowing the
30s navigation timeout under `--workers=3` on
`all-tool-pages-load.spec.ts`. Local dev keeps `vite` dev for HMR.
### OAuth/SAML compose helpers
`start-oauth-test.sh` and `start-saml-test.sh` gain a `--license-key
<KEY>` (`-k`) flag so CI and scripted runs can skip the interactive
license prompt. `start-oauth-test.sh` also moves from `for arg in "$@"`
to a `while`-with-`shift` arg loop to support multi-arg flags
consistently with the SAML script.
### Backend gradlew unification
Drops the per-platform `cmd /c gradlew.bat` branches from `backend.yml`
and routes every gradle invocation through `bash gradlew`. Works
uniformly on Linux/macOS and Windows-with-Git-Bash.
### Compare.tsx flake fix (re-land of
[#6316](https://github.com/Stirling-Tools/Stirling-PDF/pull/6316))
Piggybacks Anthony's never-merged fix from #6316. Without it,
`e2e:stubbed` continues to flake under `--workers=3` on
`compare.spec.ts`'s second-upload case via a React "Maximum update depth
exceeded" infinite loop in the Compare auto-fill effect. CI traces from
recent failed runs match exactly; 10 local runs of `compare.spec.ts`
with `CI=1 --workers=3` pass cleanly with the fix applied.
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
# Description of Changes
Hooks up the (alpha) PDF Editor backend to the AI engine Edit Agent via
an intermediary API which is easier for the agent to call. It suffers
from all the same issues that the PDF Editor does in actually editing
the text, but should also benefit from any fixes to that.
It also adds protection against the underlying tools misbehaving by
hanging, and fixes a hanging bug in the PDF Editor.
---------
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Bumps [reportlab](https://www.reportlab.com/) from 4.4.10 to 4.5.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)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps commons-io:commons-io from 2.21.0 to 2.22.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)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
# Description of Changes
Save indicator stopped showing up after #6050, which fixed the missing
truncation on filenames, but accidentally bypassed the save indicator
component at the same time. This PR puts the component back in and makes
it support truncation so we can have both.
<img width="586" height="166" alt="image"
src="https://github.com/user-attachments/assets/529c3dcb-ee00-4a6d-ae53-ef8657204369"
/>
# Description of Changes
Vite currently warns that when it's bundling our code that the chunk
size is way too high because most of the imports are static so it can't
split them into smaller chunks. This PR changes a few key areas to use
lazy imports to try and make the chunks as small as possible with
minimal code changes.
Vite's warnings kick in at minified chunks being >500kB, and we've got a
little way to go still to reach that, but we can keep chipping away at
this and I'd rather get the biggest wins done now. I've also included
Lighthouse scores because there's been discussion about improving ours
recently. It's not the aim of this PR to improve it, but it's nice that
it makes it a little better.
## Current main chunks
Build split into 12 chunks. Largest chunk in build is:
```
[frontend:build] dist/assets/index-B6JiWDxZ.js 5,175.51 kB │ gzip: 1,495.85 kB
```
<img width="1442" height="775" alt="image"
src="https://github.com/user-attachments/assets/b0e8a3fa-4ef3-4ccd-8c1d-bfed2d99bd27"
/>
Lighthouse score:
<img width="423" height="146" alt="before"
src="https://github.com/user-attachments/assets/c62056e8-2e77-49a6-a1ae-f08ec8021fb3"
/>
## This PR's chunks
Build split into 176 chunks. Largest chunk in build is:
```
[frontend:build] dist/assets/index-qCgeCY4B.js 2,878.54 kB │ gzip: 861.03 kB
```
<img width="1447" height="776" alt="image"
src="https://github.com/user-attachments/assets/8d0c3cf0-cc25-41c3-b114-4940d3e99349"
/>
Lighthouse score:
<img width="402" height="145" alt="after"
src="https://github.com/user-attachments/assets/99a26eb3-bd15-4b92-bf22-82b58b458f52"
/>
---------
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
# Description of Changes
Have the Java send a list of enabled endpoints to the AI engine so it
can intelligently respond to the user that the tool does exist but is
disabled on the server so it can't acutally run the operation, instead
of the current behaviour where it sends the API call back and then 503
errors because the execution fails when the URL is disabled.
<img width="380" height="208" alt="image"
src="https://github.com/user-attachments/assets/5842fb2e-2e55-45a5-8205-25515636daae"
/>
---------
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
# Description of Changes
Flesh out the RAG system and connect it to the PDF Question Agent so it
can respond to questions about PDFs of an extremely large size.
I'd expect lots more work will need to be done to finish off the RAG
system to really be what we need, but this should be a reasonable start
which will let us connect it to tools and have the ingestion mostly
handled automatically. I'm leaving file deletion and proper file ID
management to be done in a future PR. We also need to consider whether
all tools should retrieve content exclusively via RAG, or whether it's
beneficial to have tools sometimes fetch the direct content and other
times fetch it from RAG.
A diagram of the expected interaction is as follows:
```mermaid
sequenceDiagram
autonumber
actor U as User
participant FE as Frontend<br/>(ChatPanel)
participant J as Java<br/>(AiWorkflowService)
participant O as Engine:<br/>OrchestratorAgent
participant QA as Engine:<br/>PdfQuestionAgent
participant RAG as Engine:<br/>RagService + SqliteVecStore
participant V as VoyageAI<br/>(embeddings)
participant L as LLM<br/>(Claude / etc.)
U->>FE: types "Summarise this PDF"<br/>(PDF already uploaded)
FE->>J: POST /api/v1/ai/orchestrate/stream<br/>multipart: fileInputs[], userMessage
Note over J: ByteHashFileIdStrategy<br/>id = sha256(bytes)[:16]
J->>O: POST /api/v1/orchestrator<br/>{ files:[{id,name}], userMessage }
O->>L: route via fast model
L-->>O: delegate_pdf_question
O->>QA: PdfQuestionRequest
loop for each file
QA->>RAG: has_collection(file.id)
RAG-->>QA: false
end
QA-->>O: NeedIngestResponse(files_to_ingest)
O-->>J: { outcome:"need_ingest", filesToIngest:[...] }
Note over J: onNeedIngest
loop per file
J->>J: PDFBox: extract page text
J->>O: POST /api/v1/rag/documents<br/>(long-running timeout)
O->>RAG: chunk + stage documents
O->>V: embed_documents (batches of 256)
V-->>O: embeddings
O->>RAG: add_documents
O-->>J: { chunks_indexed: N }
end
Note over J: retry with resumeWith=pdf_question
J->>O: POST /api/v1/orchestrator
Note over O: fast-path to PdfQuestionAgent
O->>QA: PdfQuestionRequest
Note over QA: build RagCapability<br/>pinned to file IDs
QA->>L: run(prompt) with search_knowledge tool
loop up to max_searches
L->>QA: search_knowledge(query)
QA->>V: embed_query
V-->>QA: query vector
QA->>RAG: search(vector, collections=[file.id])
RAG-->>QA: top-k chunks
QA-->>L: formatted chunks
end
Note over QA: once budget spent,<br/>prepare() hides the tool
L-->>QA: PdfQuestionAnswerResponse
QA-->>O: answer
O-->>J: { outcome:"answer", answer, evidence }
J-->>FE: SSE "result"
FE->>U: assistant bubble
```
# Description of Changes
Fixes share-link navigation for SSO users. Reported on v2.9.2 with
`SSOAutoLogin: true`: clicking a `/share/<token>` link in an email
redirected the user to the home page after SSO instead of the shared
file.
## Root cause
Three compounding issues had to be fixed together; the first was the
initial symptom but the other two only surfaced during live
verification.
1. **Spring Security blocked `/share/<token>` for unauthenticated
users.** The route wasn't in `RequestUriUtils.isPublicAuthEndpoint`, so
the server 302'd straight to `/login` before React could load
`ShareLinkPage`. The share URL was lost because `NullRequestCache` is
configured and never persisted the original destination.
2. **`httpErrorHandler` full-page-redirected to `/login?from=<path>` on
any unhandled 401** (fired by `LicenseContext`, `AppConfig`, etc. during
normal ShareLinkPage mount). That *did* preserve the return path — but
**Spring Security strips query strings from `/login`** (302 to bare
`/login`), so `?from=` never reached React. Confirmed via `curl -i
http://localhost:8080/login?from=xyz` → `Location: /login`.
3. **`AuthCallback.tsx` unconditionally `navigate("/")`** after the
SAML/OAuth round-trip, discarding any intended destination.
## Fix
**Backend** — make `/share/<token>` a public SPA bootstrap, data APIs
stay protected:
- `RequestUriUtils.isPublicAuthEndpoint` — permits `^/share/[^/]+/?$`
(tight regex, single token segment only; `/share/<token>/anything` stays
protected).
- `ReactRoutingController` — dedicated `@GetMapping("/share/{token}")`
mirroring `/auth/callback`.
- `/api/v1/storage/share-links/**` remains behind Spring Security with
its existing `canAccessShareLink` check.
**Frontend** — persist the return path across full-page redirects via
`sessionStorage` (same-origin, survives the SSO round-trip):
- `httpErrorHandler.ts` — stashes current pathname to
`stirling_post_login_path` before the 401 → `/login` redirect.
- `springAuthClient.ts` — new `isSafePostLoginRedirect` /
`setPostLoginRedirectPath` / `consumePostLoginRedirectPath` helpers
(rejects protocol-relative URLs and auth-plumbing paths to guard against
open-redirect abuse).
- `Login.tsx` — on explicit user sign-in, read path from
`location.state` or `?from=` query and stash it; don't clobber an
already-stashed value.
- `AuthCallback.tsx` — consume the stashed path (single-use) and
`navigate(target)` instead of always `/`.
---
## 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: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
# Description of Changes
Adds the ability for the Edit agent to request the content of the
document before it decides which parameters it needs. This makes it able
to process requests like `Split the document after the page containing
the "My Section" section`, allowing for document context-based requests
for all[^1] tools.
I had to make a few changes elsewhere to make this work, including:
- Moving the requesting of content out of the Question Agent and into a
common location
- Added specific API docs for the Split param because the generic ones
were not specific enough for the AI to be able to reliably perform the
correct operation
- Fixed an issue in the tool models generator which caused the Redact
params to only be half-generated (causing Pydantic to crash when the AI
tried to run Redact)
- Added missing logging to a bunch of tools and hooked it up properly so
it'll print to stderr
- Made the limits for the max pages/chars to extract from PDFs
configurable via env var
[^1]: Many of the tools can't actually do anything useful with the
context at this stage, but will just need the tool API to be extended
with new features like page-specific operations to be automatically able
to do smart operations without needing to change the Edit agent itself.
# Description of Changes
We keep adding stuff to `engine/config/.env.example` and have to
manually update `.env` because of it, which is really clunky, especially
when working on multiple worktrees at once. This PR changes it so that
we just have a committed `.env` file and have an `.env.local` override
to put the actual private keys into, which should make it a bit easier
to manage.
> [!warning]
>
> After this goes in, be very careful for a little while not to
accidentally commit any keys that you've got inside your `.env` file!
# Description of Changes
Add an extra parameter to every agent to receive the conversation
history in addition to the current message. This will make it possible
to answer followup questions from the AI without needing to give full
context in your message.
# Description of Changes
Redesign AI engine so that it autogenerates the `tool_models.py` file
from the OpenAPI spec so the Python has access to the Java API
parameters and the full list of Java tools that it can run. CI ensures
that whenever someone modifies a tool endpoint that the AI enigne tool
models get updated as well (the dev gets told to run `task
engine:tool-models`).
There's loads of advantages to having the Java be the one that actually
executes the tools, rather than the frontend as it was previously set up
to theoretically use:
- The AI gets much better descriptions of the params from the API docs
- It'll be usable headless in the future so a Java daemon could run to
execute ops on files in a folder without the need for the UI to run
- The Java already has all the logic it needs to execute the tools
- We don't need to parse the TypeScript to find the API (which is hard
because the TS wasn't designed to be computer-read to extract the API)
I've also hooked up the prototype frontend to ensure it's working
properly, and have built it in a way that all the tool names can be
translated properly, which was always an issue with previous prototypes
of this.
---------
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
# Description of Changes
Follow on from #5949, expanding any type usage ban to the `desktop/`
folder
Also gets rid of a bunch of really verbose desktop logging that I don't
think we really need anymore (or ever needed tbh, most of it doesn't
make sense) because it was using a bunch of `any` typing and wasn't
worth fixing.
# Description of Changes
When I added Prettier formatting in #6052, my aim was to use just the
default settings in Prettier. Turns out, Prettier looks _really hard_
for any config files if it's not explicitly given one, which means that
if a developer has some sort of Prettier config file lying around on
their system, Prettier might find it and use it. Also, Prettier changes
its defaults based on stuff in `.editorconfig` without any good way of
disabling that behaviour explicitly in its config file.
To solve both of these issues, I've introduced a `.prettierrc` file
which sets Prettier's defaults explicitly, and then reformatted all our
code _again_ in Prettier's actual default settings. This should achieve
the aim of #6052 and remove the possibility for it breaking on different
dev computers.
# Description of Changes
Adds a streaming endpoint to the Java AI orchestrator
(`/api/v1/ai/orchestrate/stream` in addition to the existing
`/api/v1/ai/orchestrate`). This allows the caller to get updates of what
stage of orchestration is being run at the time so UIs can give the user
feedback.
Also contains some dubious Gradle changes to suppress errors coming from
Spotless, when it crashes in Google stuff. I'm not sure if that's
appropriate to add, feel free to ask for changes in review.
## Add Taskfile for unified dev workflow
### Summary
- Introduces [Taskfile](https://taskfile.dev/) as the single CLI entry
point for all development workflows across backend, frontend, engine,
Docker, and desktop
- ~80 tasks organized into 6 namespaces: `backend:`, `frontend:`,
`engine:`, `docker:`, `desktop:`, plus root-level composites
- All CI workflows migrated to use Task
- Deletes `engine/Makefile` and `scripts/build-tauri-jlink.{sh,bat}` —
replaced by Task equivalents
- Removes redundant npm scripts (`dev`, `build`, `prep`, `lint`, `test`,
`typecheck:all`) from `package.json`
- Smart dependency caching: `sources`/`status`/`generates`
fingerprinting, CI-aware `npm ci` vs `npm install`, `run: once` for
parallel dep deduplication
### What this does NOT do
- Does not replace Gradle, npm, or Docker — Taskfile is a thin
orchestration wrapper
- Does not change application code or behavior
### Install
```
npm install -g @go-task/cli # or: brew install go-task, winget install Task.Task
```
### Quick start
```
task --list # discover all tasks
task install # install all deps
task dev # start backend + frontend
task dev:all # also start AI engine
task test # run all tests
task check # quick quality gate (local dev)
task check:all # full CI quality gate
```
### Test plan
- [ ] Install `task` CLI and run `task --list` — verify all tasks
display
- [ ] Run `task install` — verify frontend + engine deps install
- [ ] Run `task dev` — verify backend + frontend start, Ctrl+C exits
cleanly
- [ ] Run `task frontend:check` — verify typecheck + lint + test pass
- [ ] Run `task desktop:dev` — verify jlink builds are cached on second
run
- [ ] Verify CI passes on all workflows
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.
This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.
> [!note]
>
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
Upgrade fastmcp, aiohttp, cryptography, and anthropic to fix critical
SSRF/path traversal, header injection, OAuth confused deputy, and DoS
vulnerabilities.
<details>
<summary>✅ 16 CVEs resolved by this upgrade, including 2 critical 🚨
CVEs</summary>
<br>
This PR will resolve the following CVEs:
| Issue |
Severity |
Description |
| --- | --- | --- |
|
<pre>[CVE-2026-32871](https://app.aikido.dev/issues/25944204/detail?groupId=70007#CVE-2026-32871)</pre>
| <pre>🚨 CRITICAL</pre> | [fastmcp] Path traversal vulnerability in URL
construction allows attackers to bypass API prefix restrictions and
access arbitrary backend endpoints using unencoded path parameters,
enabling authenticated SSRF attacks. |
|
<pre>[CVE-2026-27124](https://app.aikido.dev/issues/25944204/detail?groupId=70007#CVE-2026-27124)</pre>
| <pre>HIGH</pre> | [fastmcp] OAuthProxy fails to validate user consent
when receiving authorization codes from GitHub, allowing attackers to
exploit GitHub's consent-skipping behavior to gain unauthorized access
to FastMCP servers through a Confused Deputy attack. |
|
<pre>[CVE-2025-64340](https://app.aikido.dev/issues/25944204/detail?groupId=70007#CVE-2025-64340)</pre>
| <pre>MEDIUM</pre> | [fastmcp] Server names with shell metacharacters
can cause command injection on Windows when passed to install commands,
allowing arbitrary code execution through cmd.exe interpretation of .cmd
wrapper files. |
|
<pre>[CVE-2026-34520](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34520)</pre>
| <pre>🚨 CRITICAL</pre> | [aiohttp] is an asynchronous HTTP
client/server framework for asyncio and Python. Prior to version 3.13.4,
the C parser (the default for most installs) accepted null bytes and
control characters in response headers. This issue has been patched in
version 3.13.4. |
|
<pre>[CVE-2026-34516](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34516)</pre>
| <pre>HIGH</pre> | [aiohttp] A response with an excessive number of
multipart headers can consume more memory than intended, leading to a
denial of service (DoS) vulnerability through resource exhaustion. |
|
<pre>[CVE-2026-22815](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-22815)</pre>
| <pre>MEDIUM</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, insufficient
restrictions in header/trailer handling could cause uncapped memory
usage. This issue has been patched in version 3.13.4. |
|
<pre>[CVE-2026-34515](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34515)</pre>
| <pre>MEDIUM</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, on Windows
the static resource handler may expose information about a NTLMv2 remote
path. This issue has been patched in version 3.13.4. |
|
<pre>[CVE-2026-34525](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34525)</pre>
| <pre>MEDIUM</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, multiple Host
headers were allowed in aiohttp. This issue has been patched in version
3.13.4. |
|
<pre>[CVE-2026-34513](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34513)</pre>
| <pre>LOW</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, an unbounded
DNS cache could result in excessive memory usage possibly resulting in a
DoS situation. This issue has been patched in version 3.13.4. |
|
<pre>[CVE-2026-34514](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34514)</pre>
| <pre>LOW</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, an attacker
who controls the content_type parameter in aiohttp could use this to
inject extra headers or similar exploits. This issue has been patched in
version 3.13.4. |
|
<pre>[CVE-2026-34517](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34517)</pre>
| <pre>LOW</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, for some
multipart form fields, aiohttp read the entire field into memory before
checking client_max_size. This issue has been patched in version 3.13.4.
|
|
<pre>[CVE-2026-34518](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34518)</pre>
| <pre>LOW</pre> | [aiohttp] When following redirects to a different
origin, the framework fails to drop the Cookie and Proxy-Authorization
headers alongside the Authorization header, potentially leaking
sensitive authentication credentials to untrusted domains. |
|
<pre>[CVE-2026-34519](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34519)</pre>
| <pre>LOW</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, an attacker
who controls the reason parameter when creating a Response may be able
to inject extra headers or similar exploits. This issue has been patched
in version 3.13.4. |
|
<pre>[CVE-2026-39892](https://app.aikido.dev/issues/25637201/detail?groupId=70007#CVE-2026-39892)</pre>
| <pre>MEDIUM</pre> | [cryptography] Non-contiguous buffers passed to
cryptographic APIs can cause buffer overflows, potentially leading to
memory corruption and arbitrary code execution. |
|
<pre>[CVE-2026-34452](https://app.aikido.dev/issues/25944200/detail?groupId=70007#CVE-2026-34452)</pre>
| <pre>MEDIUM</pre> | [anthropic] A time-of-check-time-of-use (TOCTOU)
vulnerability in the async filesystem memory tool allows local attackers
to escape the sandbox directory via symlink manipulation, enabling
arbitrary file read/write operations outside the intended memory
directory. |
|
<pre>[CVE-2026-34450](https://app.aikido.dev/issues/25944200/detail?groupId=70007#CVE-2026-34450)</pre>
| <pre>MEDIUM</pre> | [anthropic] The local filesystem memory tool
created world-readable and potentially world-writable files, allowing
local attackers to read persisted agent state or modify memory files to
influence model behavior. |
</details>
Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
Upgrade axios to fix critical proxy bypass and SSRF vulnerabilities in
hostname normalization that could allow attackers to reach protected
internal services.
✅ There are no breaking changes
<details>
<summary>✅ 1 CVE resolved by this upgrade, including 1 critical 🚨
CVE</summary>
<br>
This PR will resolve the following CVEs:
| Issue |
Severity |
Description |
| --- | --- | --- |
|
<pre>[CVE-2025-62718](https://app.aikido.dev/issues/26490690/detail?groupId=70007#CVE-2025-62718)</pre>
| <pre>🚨 CRITICAL</pre> | [axios] Axios fails to properly normalize
hostnames when checking NO_PROXY rules, allowing requests to loopback
addresses (localhost., [::1]) to bypass proxy protections and reach
internal services. This enables proxy bypass and SSRF attacks against
protected loopback or internal endpoints. |
</details>
Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
## Description of Changes
Adds two new user preferences to the General settings panel, addressing
#5908.
**Default view on launch** - a segmented control (Tools / Reader /
Automate) that controls which left-column tab is active when the app
starts. Previously the app always opened on the Tools tab with no way to
change this. Users who spend most of their time reading PDFs had to
manually switch to the Reader tab on every launch.
**Default reader zoom** - a dropdown (Auto / Fit width / Fit page /
50%–200%) that sets the initial zoom level whenever a PDF is opened in
the reader. Previously the app always applied an automatic
fit-to-viewport calculation.
Both settings are non-breaking. The defaults (`Tools` and `Auto`)
reproduce the existing behaviour exactly, so existing users see no
difference until they change a preference.
### What changed
- `preferencesService.ts` - added `StartupView` and `ViewerZoomSetting`
types plus the two new fields to `UserPreferences` with safe defaults
- `ToolWorkflowContext.tsx` - one-time startup effect that navigates to
the preferred tab on first render (mirrors the existing
`defaultToolPanelMode` sync pattern)
- `ZoomAPIBridge.tsx` - respects the zoom preference before falling back
to auto-zoom logic when a document loads
- `GeneralSection.tsx` - two new controls added below "Default tool
picker mode"; the Select uses `comboboxProps={{ withinPortal: true }}`
so the dropdown renders above the settings modal
- `en-GB/translation.toml` - new keys for labels, descriptions, and
option values
Closes#5908
---
## 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/devGuide/DeveloperGuide.md)
(if applicable)
- [x] 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)
- [x] 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)
<img width="1023" height="747" alt="Screenshot 2026-04-05 185718"
src="https://github.com/user-attachments/assets/6a8bc35a-d813-4ab8-b303-55bdce747a6a"
/>
<img width="1026" height="755" alt="Screenshot 2026-04-05 185620"
src="https://github.com/user-attachments/assets/d2c45134-ed32-4332-a193-1a96837ba2a3"
/>
### Testing (if applicable)
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
# Description of Changes
Add prototypes folder to test new functionality in. This build of the
app is spawnable with `npm run dev:prototypes`.
Currently just contains a very developer-y chat interface to help us
develop & explore the AI backend before we make the frontend for it for
real.
# Description of Changes
Add Java orchestration layer which can connect and go back and forth
with the AI engine to get results for the user. It's expected that the
AI engine will not be publicly available and this Java layer will always
be in front of it, to manage sessions and auth etc.
## Description
Fixes#6029 - Additional selection in windows client no longer necessary
## Problem
When opening PDF files in the Windows desktop client using "Open with",
the file displays properly but users had to manually select it again in
the workbench before any PDF tools (merge, compress, crop, compare,
etc.) become functional.
## Root Cause
Files opened via "Open with" were added to FileContext but **not
selected** (missing `selectFiles: true`). Without selection, the file
wasn't marked as active, preventing tool access.
Additionally, `AppInitializer` was placed outside
`ToolWorkflowProvider`, causing a context error.
## Solution
### Changes:
1. **frontend/src/desktop/hooks/useAppInitialization.ts**
- Added `{ selectFiles: true }` when calling `addFiles()`
- Files now immediately marked as active in FileContext
2. **frontend/src/core/components/AppProviders.tsx**
- Moved `AppInitializer` inside `ToolWorkflowProvider`
- Ensures context availability for initialization
## Testing
- Open PDF via "Open with" on Windows
- File now immediately usable with all tools
- No manual reselection needed
## Screenshot
<img width="1920" height="1080" alt="Screenshot (3)"
src="https://github.com/user-attachments/assets/9ceacadf-eb12-42a6-86f9-bca6188bfbb9"
/>
---------
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
## Fix 1 — Viewer bug (8 tools)
8 tools called `useFileSelection()` directly instead of routing through
`useBaseTool`. In the viewer, this meant they operated on **all selected
files**
instead of only the one being viewed. For example: 10 files loaded,
viewing
file 3, running Add Stamp — all 10 files got stamped.
**Root cause:** These tools had no view-scope awareness.
`useFileSelection()`
returns the raw workbench selection with no knowledge of which file is
active in
the viewer.
**Fix:** A new hook `useViewScopedFiles` was introduced:
```ts
// Viewer → only the active file
// Everywhere else → all loaded files
const selectedFiles = useViewScopedFiles();
```
The 8 tools were updated to call this instead of `useFileSelection()`.
**Tools fixed:** Add Stamp, Add Watermark, Add Password, Add Page
Numbers,
Add Attachments, Reorganize Pages, OCR, Convert
---
## Fix 2 — Page selector / active files context (all tools)
`useBaseTool` returned `selectedFiles` (checked files only) in
non-viewer
contexts. In the page selector this is typically empty or stale — not
the full
set of loaded files that tools should operate on.
**Fix:** `useBaseTool` was updated to use `useViewScopedFiles`, which
returns
all loaded files in non-viewer contexts. This affected every tool via
`useBaseTool`.
---
## Workarounds for Compare & Merge
Two tools intentionally need all loaded files regardless of view, so
they use
`ignoreViewerScope: true` in `useBaseTool`.
**Compare** — needs exactly 2 files for its Original/Edited slots.
Scoping to
one file would break the comparison entirely. `ignoreViewerScope: true`
is set
and `disableScopeHints: true` hides the "(this file)" button label hint.
The
slot auto-mapping logic was also improved alongside this fix.
**Merge** — needs 2+ files; merging a single file is meaningless. Rather
than
leaving the button silently disabled, Merge now:
- Auto-redirects to the active files view on first open from the viewer
- If the user navigates back to the viewer, shows a disabled button with
a hint
and a "Go to active files view" shortcut button
---
## How to Test
---
## Fix 1 — 8 tools (viewer scoping)
### Test steps (same for each)
1. Load 3 PDFs into workbench
2. Open viewer, navigate to file 2
3. Open the tool, configure settings, run
4. ✅ Only file 2 is in the results
5. ✅ Button label shows **"[Action] (this file)"**
6. ✅ A note below the button reads **"Only applying to: [filename]"**
| Tool | What to configure |
|---|---|
| **Add Stamp** | Enter any text stamp or upload an image stamp |
| **Add Watermark** | Select text watermark, enter any text |
| **Add Page Numbers** | Leave defaults |
| **Add Password** | Enter any owner + user password |
| **Add Attachments** | Attach any small file |
| **Reorganize Pages** | Enter a page range e.g. `1,2` |
| **OCR** | Leave default language |
| **Convert** | Convert PDF → any format |
---
## Fix 2 — All tools (page selector context)
### Test steps
1. Load 3 PDFs into workbench
2. Open the page selector view
3. Open any tool from the sidebar, run it
4. ✅ All 3 files are processed (not zero or a stale subset)
---
## Compare (intentionally ignores view scope)
**A — Auto-fill with exactly 2 files**
1. Load exactly 2 PDFs
2. Open Compare from either the viewer or active files view
3. ✅ Both slots are filled automatically (Original + Edited)
4. ✅ No scope hint appears on the button
**B — Manual selection with 3+ files**
1. Load 3+ PDFs
2. Open Compare
3. ✅ The first 2 files fill the slots
4. ✅ A 3rd file does not add a 3rd slot (capped at 2)
**C — File removed mid-session**
1. Load 2 PDFs, let Compare auto-fill both slots
2. Remove one file from the workbench
3. ✅ The corresponding slot clears; the other slot is unchanged
**D — Viewer mode**
1. Load 2 PDFs, open viewer
2. Open Compare from the viewer sidebar
3. ✅ Both files are still available for slot selection (not scoped to
current file)
---
## Merge (intentionally ignores view scope, disabled in viewer)
**A — Auto-redirect on first open from viewer**
1. Load 2+ PDFs, open the viewer
2. Open Merge from the viewer sidebar
3. ✅ Immediately redirected to the active files view
**B — Viewer mode disabled state (after navigating back)**
1. From the active files view, open Merge, then navigate back to the
viewer
2. ✅ Execute button is **disabled** with tooltip "Switch to the file
editor to select multiple files"
3. ✅ A note appears: *"Merge needs 2 or more files. Head to the file
editor to select them."*
4. ✅ A **"Go to active files view"** button is shown; clicking it
navigates back
**C — Active files view works normally**
1. Load 3 PDFs, open Merge from the active files view
2. ✅ All 3 files appear in the merge list
3. ✅ Button shows **"Merge (3 files)"**
4. Run the merge
5. ✅ Output is a single PDF containing all 3 files
---
## Button label behaviour (all tools)
| Context | Expected button text |
|---|---|
| Viewer, 1 file loaded | `[Action]` (no suffix) |
| Viewer, 2+ files loaded | `[Action] (this file)` |
| Active files view, 1 file loaded | `[Action]` (no suffix) |
| Active files view, 2+ files loaded | `[Action] (N files)` |
| Merge in viewer | disabled — no suffix |
| Compare | never shows scope suffix (`disableScopeHints: true`) |
---------
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
# Description of Changes
Preserve the translated zh-TW tags while restoring the English aliases
used by frontend tool search.
This keeps common English technical queries such as permissions or
access control discoverable in the zh-TW locale.
---
## Checklist
### General
- [x] 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/devGuide/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)
- [x] 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 tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
## GitHub Copilot Pull Reuqest summary
> This pull request significantly expands the keyword tags for a wide
range of PDF-related tools and actions in the Traditional Chinese
(`zh-TW`) translation file. The main goal is to improve searchability
and discoverability of features by including a comprehensive set of
English and Chinese keywords, synonyms, and related phrases for each
tool.
>
> The most important changes include:
>
> **Localization and Search Optimization:**
>
> * Expanded the `tags` fields for all tools and actions under the
`[home.*]` sections in `frontend/public/locales/zh-TW/translation.toml`
to include a broad set of English and Chinese keywords, synonyms, and
common search phrases. This enhances feature discoverability for users
searching in either language.
[[1]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L3885-R3925)
[[2]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L3934-R4039)
[[3]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L4054-R4209)
[[4]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L4218-R4218)
>
> **Consistency and Coverage:**
>
> * Ensured that each tool/action now has a rich set of tags that cover
various ways users might refer to the feature, including technical
terms, synonyms, and related concepts (e.g., "merge", "combine", "join"
for PDF merging).
[[1]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L3885-R3925)
[[2]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L3934-R4039)
[[3]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L4054-R4209)
[[4]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L4218-R4218)
>
> **Internationalization Improvements:**
>
> * Added English keywords alongside Chinese ones to support bilingual
search and better serve users who may search using English terms in a
localized interface.
[[1]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L3885-R3925)
[[2]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L3934-R4039)
[[3]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L4054-R4209)
[[4]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L4218-R4218)
>
> These changes collectively make it easier for users to find the
features they need, regardless of the language or terminology they use.
## Summary
- Added "Alpha" badge to the File Storage & Sharing nav item in the
settings sidebar
- Added "Alpha" badge to the File Storage & Sharing page title
- Removed the old inline "(Alpha)" text from the Enable Group Signing
label
- Restructured all toggle cards so the switch is anchored to the right
of each row
- Tightened spacing between cards for a more compact layout
- Extended `ConfigNavItem` interface with optional `badge` and
`badgeColor` fields for reuse elsewhere
<img width="1696" height="1057" alt="image"
src="https://github.com/user-attachments/assets/77ac8276-ed65-4cae-8470-65de8f56dd74"
/>
## Summary
This PR adds full desktop (Tauri) support for the shared signing feature
when connected to a self-hosted server, and fixes several bugs
discovered during that work.
### Feature gating
Shared signing, file sharing, and share links are proprietary server
features that require an authenticated self-hosted session. Previously
these were read directly from `config` with no awareness of connection
mode or auth state, meaning the UI could appear in SaaS/local mode or
when logged out.
- Introduce `useGroupSigningEnabled` and `useSharingEnabled` hooks with
core implementations (web behaviour unchanged) and desktop overrides
that require `selfhosted` mode + an active authenticated session
- Extract shared subscription logic into `useSelfHostedAuth` (connection
mode + auth state + config refetch)
- `QuickAccessBar` now derives all three flags from the hooks instead of
raw config
### Config timing fix
When a user logs in via the SetupWizard, the `jwt-available` event fires
a config fetch *before* the mode is switched to `selfhosted`. This meant
the config was fetched from the local bundled backend (port ~59567)
which has no knowledge of `storageGroupSigningEnabled`, causing the
group signing button to stay hidden until a full page refresh.
`useSelfHostedAuth` detects the mode transition and triggers a fresh
config fetch at the correct moment, after the self-hosted URL is active.
### Bug fixes
**`SignPopout.tsx`** — Manually setting `Content-Type:
multipart/form-data` on two `FormData` POST requests stripped the
auto-generated boundary, causing a `400 bad multipart` from the server.
Removed the explicit headers so Axios sets them correctly.
**`tauriHttpClient.ts`** — `response.json()` was called before
`response.ok` was checked. A plain-text error body from the server (e.g.
`"Cannot sign..."`) caused a `SyntaxError` that fell into the network
error catch block and was reported as `ERR_NETWORK`, hiding the real
failure. The fix checks `response.ok` first, reads error bodies as text,
and handles empty 200 bodies (returning `null` instead of throwing).
---
## Testing
### Prerequisites
- Desktop app running in self-hosted mode pointed at a local
Stirling-PDF instance (`http://localhost:8080`)
- The self-hosted instance has group signing and storage enabled in
settings
- At least two user accounts on the self-hosted instance
### 1. Feature gating — group signing button
| Step | Expected |
|---|---|
| Open the desktop app in **local mode** (no server configured) | Group
signing button absent from QuickAccessBar |
| Switch to self-hosted mode but **do not log in** | Group signing
button absent |
| Log in to the self-hosted server | Group signing button appears
without requiring a page refresh |
| Log out | Group signing button disappears immediately |
| Log back in | Group signing button reappears without a page refresh |
### 2. Feature gating — file sharing
Repeat the same steps above, verifying the share and share-link buttons
in the file manager follow the same visibility rules.
### 3. Create a signing session
1. Log in, open the group signing panel from QuickAccessBar
2. Select a PDF, add a participant, configure signature defaults and
submit
3. Verify the session is created successfully (no `400 bad multipart`
error)
### 4. Participant signing
1. As the invited participant, open the signing request from
QuickAccessBar
2. Upload or draw a signature and submit
3. Verify signing completes successfully (no `ERR_NETWORK` error)
### 5. Error surfacing
1. Attempt an action that the server rejects (e.g. sign a document with
an invalid certificate)
2. Verify the actual server error message is shown rather than a generic
network error
# Description of Changes
Adds an eslint rule to disallow importing any Tauri APIs outside the
desktop folder to help hint to developers that they should be following
the frontend architecture.
While doing this, I also discovered that you can provide a custom
message in the `no-restricted-imports` rule, which is nicer than the
comments that I'd previously added to the eslint config file to explain
why they weren't allowed:
```text
/Users/jamesbrunton/Dev/spdf1/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx
19:1 error 'src/core/contexts/PreferencesContext' import is restricted from being used by a pattern. Use @app/* imports instead of absolute src/ imports no-restricted-imports
20:1 error '../../../../../core/contexts/AppConfigContext' import is restricted from being used by a pattern. Use @app/* imports instead of relative imports no-restricted-imports
21:1 error '@tauri-apps/core' import is restricted from being used by a pattern. Tauri APIs are desktop-only. Review frontend/DeveloperGuide.md for structure advice no-restricted-imports
```
## Fix Playwright E2E tests and expand CI to run full suite
### Problem
The full Playwright suite was broken in two ways:
1. **`ConvertE2E.spec.ts` crashed at import time** —
`conversionEndpointDiscovery.ts` imported a React hook at the top level,
which pulled in the entire component tree. That chain eventually
required `material-symbols-icons.json` (a generated file that didn't
exist), crashing module resolution before any tests ran.
2. **CI only ran cert validation tests** — both `build.yml` and
`nightly.yml` hardcoded `src/core/tests/certValidation` as the test
path, silently ignoring everything else.
### Changes
**`ConvertE2E.spec.ts` — complete rewrite**
The old tests were useless in practice: all 9 dynamic conversion tests
were permanently skipped unless a real Spring Boot backend was running
(they called a live `/api/v1/config/endpoints-enabled` endpoint at
module load time). Replaced with 4 focused tests that use `page.route()`
mocking — no backend required, same pattern as
`CertificateValidationE2E`.
New tests cover:
- Convert button absent before a format pair is selected
- Successful PDF→PNG conversion shows a download button (mocked API
response)
- API error surfaces as an error notification
- Convert button appears and is enabled after selecting valid formats
**`conversionEndpointDiscovery.ts` — deleted**
Only existed to support the old tests. The `useConversionEndpoints`
React hook it exported was never imported anywhere else.
**`ReviewToolStep.tsx`**
Added `data-testid="download-result-button"` to the download button —
required for the happy-path test assertion.
**CI workflows (`build.yml`, `nightly.yml`)**
- Added a `Generate icons` step before Playwright runs (`node
scripts/generate-icons.js`) — the icon JSON is generated by `npm run
dev` locally but skipped by `npm ci` in CI
- Removed the `src/core/tests/certValidation` path filter so the full
suite runs
## PR: Certificate Pre-Validation for Document Signing
### Problem
When a participant uploaded a certificate to sign a document, there was
no validation at submission time. If the certificate had the wrong
password, was expired, or was incompatible with the signing algorithm,
the error only surfaced during **finalization** — potentially days
later, after all other participants had signed. At that point the
session is stuck with no way to recover.
Additionally, `buildKeystore` in the finalization service only
recognised `"P12"` as a cert type, causing a `400 Invalid certificate
type: PKCS12` error when the **owner** signed using the standard
`PKCS12` identifier.
---
### What this PR does
#### Backend — Certificate pre-validation service
Adds `CertificateSubmissionValidator`, which validates a keystore before
it is stored by:
1. Loading the keystore with the provided password (catches wrong
password / corrupt file)
2. Checking the certificate's validity dates (catches expired and
not-yet-valid certs)
3. Test-signing a blank PDF using the same `PdfSigningService` code path
as finalization (catches algorithm incompatibilities)
This runs on both the participant submission endpoint
(`WorkflowParticipantController`) and the owner signing endpoint
(`SigningSessionController`), so both flows are protected.
#### Backend — Bug fix
`SigningFinalizationService.buildKeystore` now accepts `"PKCS12"` and
`"PFX"` as aliases for `"P12"`, consistent with how the validator
already handles them. This fixes a `400` error when the owner signed
using the `PKCS12` cert type.
#### Frontend — Real-time validation feedback
`ParticipantView` gains a debounced validation call (600ms) triggered
whenever the cert file or password changes. The UI shows:
- A spinner while validating
- Green "Certificate valid until [date] · [subject name]" on success
- Red error message on failure (wrong password, expired, not yet valid)
- The submit button is disabled while validation is in flight
#### Tests — Three layers
| Layer | File | Coverage |
|---|---|---|
| Service unit | `CertificateSubmissionValidatorTest` | 11 tests — valid
P12/JKS, wrong password, corrupt bytes, expired, not-yet-valid, signing
failure, cert type aliases |
| Controller unit | `WorkflowParticipantValidateCertificateTest` | 4
tests — valid cert, invalid cert, missing file, invalid token |
| Controller integration | `CertificateValidationIntegrationTest` | 6
tests — real `.p12`/`.jks` files through the full controller → validator
stack |
| Frontend E2E | `CertificateValidationE2E.spec.ts` | 7 Playwright tests
— all feedback states, button behaviour, SERVER type bypass |
#### CI
- **PR**: Playwright runs on chromium when frontend files change (~2-3
min)
- **Nightly / on-demand**: All three browsers (chromium, firefox,
webkit) at 2 AM UTC, also manually triggerable via `workflow_dispatch`
# Description of Changes
Redesign the Python AI engine to be properly agentic and make use of
`pydantic-ai` instead of `langchain` for correctness and ergonomics.
This should be a good foundation for us to build our AI engine on going
forwards.
# Description of Changes
Improves PDF rendering in the viewer by adding digital signature field
support,
cleaning up overlay rendering, and migrating the contrast tool off
pdf-lib to PDFium WASM.
### Signature Field Overlay
- Added `SignatureFieldOverlay` component that renders digital signature
form fields
- Renders appearance streams when present; shows a fallback badge for
unsigned fields
- Uses PDFium WASM for bitmap extraction
### Overlay Rendering
- Integrated `SignatureFieldOverlay` and `ButtonAppearanceOverlay` into
`LocalEmbedPDF`
- Overlays are now clipped to page boundaries
- Clarified in `EmbedPdfViewer` that frontend overlays use PDFium WASM,
backend overlays use PDFBox
### Contrast Tool Migration
- Replaced pdf-lib with PDFium WASM in `useAdjustContrastOperation`
- PDF page creation and image embedding now go through PDFium APIs
directly
- Updated bitmap handling and memory management accordingly
### Cleanup
- Fixed import ordering in viewer components
- Removed stale comments in the contrast operation hook
<!--
Please provide a summary of the changes, including:
- What was changed
- Why the change was made
- Any challenges encountered
Closes #(issue_number)
-->
---
## 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/devGuide/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 tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
---------
Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
# Description of Changes
Currently, cmd-r is set to rotate the PDF in the viewer instead of
perform refresh in the browser. This is unintuitive and confusing for
Mac users, and for Windows users (who are less used to doing ctrl-r for
refresh) it only works some of the time, if the Viewer is active, so
removing the override is no great loss.
# Description of Changes
Add frontend developer guide describing the path alias architecture.
There's probably more needed in here which we should flesh out over
time, but this is a start.
## Description
Adds an explicit **“Save As”** button to the desktop viewer so users can
always save a copy of the current PDF to a different location, even if
the original file already has a local path.
This complements the existing smart **Save/Download** behavior:
- The existing download button continues to either save back to the
original path (when available) or prompt for a path when needed.
- The new **Save As** button always opens a save dialog to choose a
location/name for a new copy.
## Changes
- **RightRail (viewer controls)**
- Added a new **Save As** action icon in the right rail settings
section.
- The button:
- Uses `viewerContext.exportActions.saveAsCopy()` to get the current
viewer state as a PDF.
- Calls `downloadFile` without a `localPath`, ensuring the desktop app
shows a **Save As** dialog.
- Picks the first selected file (if any) or the first active file as the
source for the filename.
- **Desktop / Web behavior**
- In the desktop app (Tauri), clicking **Save As**:
- Opens a native save dialog so the user can choose a different folder
and filename.
- Writes a new copy without changing the existing file’s `localFilePath`
or dirty state.
- In the web app, the button behaves like a standard download of a copy
(browser-controlled save dialog / download).
## Motivation
- Users often want to apply operations on a PDF while **keeping the
original unmodified**.
- The existing smart Save behavior chooses between Save and Save As
automatically, but there was no way to explicitly request **Save As**.
- This change gives desktop users a clear, dedicated **“Save As”**
control while preserving the current Save/Download behavior.
## Notes
- No backend changes.
- No changes to the existing Save / Download button behavior.
- The new button uses existing viewer export and download utilities,
minimizing new logic.
---------
Co-authored-by: James Brunton <james@stirlingpdf.com>
Tested:
* Can sign in on saas -> can run local tools with or without credits->
can run saas only tools (if credits) -> can't run saas only tools
without credits
* Can sign in self-hosted -> can run all tools on remote if available ->
can run local when self-hosted unavailable
Clouds show on saas tools when connected
Tools are disabled when connected to self-hosted but cannot find server.
You also get banner
#cantwaitforplaywritetests
# Description of Changes
Ages ago I made #4835 to try and fix all the `any` type usage in the
system but never got it finished, and there were just too many to review
and ensure it still worked. There's even more now.
My new tactic is to fix folder by folder. This fixes the `any` typing in
the `saas/` folder, and also enables `no-unnecessary-type-assertion`,
which really helps reduce pointless `as` casts that AI generates when
the type is already known. I hope to expand both of these to the rest of
the folders soon, but one folder is better than none.
# Description of Changes
Fix#5164
As I mentioned on the bug
https://github.com/Stirling-Tools/Stirling-PDF/issues/5164#issuecomment-4045170827,
it's impossible to print on Mac currently because
`iframe.contentWindow?.print()` silently does nothing in Tauri on Mac,
but [it seems unlikely that this will be
fixed](https://github.com/tauri-apps/tauri/issues/13451#issuecomment-4048075861).
Instead, I've linked directly to the Mac `PDFKit` framework in Rust to
use its printing functionality instead of Safari's. I believe that
`PDFKit` is what `Preview.app` is using and the print UI that it
generates seems to perform identically, so this should solve the issue
on Mac. Hopefully one day the TS iframe print API will be fixed and
we'll be able to get rid of this code, or [there'll be an official Tauri
plugin for printing which we can use
instead](https://github.com/tauri-apps/plugins-workspace/issues/293).
This implementation should be entirely Mac-specific. Windows & Linux
will continue to use their TS printing (which comes from EmbedPDF)
unless we have a good reason to change them to use a native solution as
well.
* Text box/notes movement improvements ✅
* Fix the issue where hiding, then showing annotations looses progress ✅
* Fix the issue where hidig/showing annotations jumps you back up to the
top of your open document ✅
* Support ctrl+c and ctrl+v and backspace to delete ✅
* Better handling when moving to different tool from annotate ✅
* Added a color picker eyedropper button ✅
* Auto-switch to Select after note/text placement, so users can quickly
place and type ✅
# Description of Changes
Previously, `VITE_*` environment variables were scattered across the
codebase with hardcoded fallback values inline (e.g.
`import.meta.env.VITE_STRIPE_KEY || 'pk_live_...'`). This made it
unclear which variables
were required, what they were for, and caused real keys to be silently
used in builds where they hadn't been explicitly configured.
## What's changed
I've added `frontend/.env.example` and `frontend/.env.desktop.example`,
which declare every `VITE_*` variable the app uses, with comments
explaining each one and sensible defaults where applicable. These
are the source of truth for what's required.
I've added a setup script which runs before `npm run dev`, `build`,
`tauri-dev`, and all `tauri-build*` commands. It:
- Creates your local `.env` / `.env.desktop` from the example files on
first run, so you don't need to do anything manually
- Errors if you're missing keys that the example defines (e.g. after
pulling changes that added a new variable). These can either be
manually-set env vars, or in your `.env` file (env vars take precedence
over `.env` file vars when running)
- Warns if you have `VITE_*` variables set in your environment that
aren't listed in any example file
I've removed all `|| 'hardcoded-value'` defaults from source files
because they are not necessary in this system, as all variables must be
explicitly set (they can be set to `VITE_ENV_VAR=`, just as long as the
variable actually exists). I think this system will make it really
obvious exactly what you need to set and what's actually running in the
code.
I've added a test that checks that every `import.meta.env.VITE_*`
reference found in source is present in at least one example file, so
new variables can't be added without being documented.
## For contributors
New contributors shouldn't need to do anything - `npm run dev` will
create your `.env` automatically.
If you already have a `.env` file in the `frontend/` folder, you may
well need to update it to make the system happy. Here's an example
output from running `npm run dev` with an old `.env` file:
```
$ npm run dev
> frontend@0.1.0 dev
> npm run prep && vite
> frontend@0.1.0 prep
> tsx scripts/setup-env.ts && npm run generate-icons
setup-env: see frontend/README.md#environment-variables for documentation
setup-env: .env is missing keys from config/.env.example:
VITE_GOOGLE_DRIVE_CLIENT_ID
VITE_GOOGLE_DRIVE_API_KEY
VITE_GOOGLE_DRIVE_APP_ID
VITE_PUBLIC_POSTHOG_KEY
VITE_PUBLIC_POSTHOG_HOST
Add them manually or delete your local file to re-copy from the example.
setup-env: the following VITE_ vars are set but not listed in any example file:
VITE_DEV_BYPASS_AUTH
Add them to config/.env.example or config/.env.desktop.example if they are required.
```
If you add a new `VITE_*` variable to the codebase, add it to the
appropriate `frontend/config/.env.example` file or the test will fail.
# Description of Changes
Inspired by https://github.com/pydantic/pydantic-ai/pull/4169, this PR
moves our `CLAUDE.md` advice to the more generic `AGENTS.md` file (which
works on Codex, Gemini, etc). It also adds a symlink from `CLAUDE.md` to
`AGENTS.md`, which Claude follows properly, so all AIs should get the
same advice and we only need to keep one file up-to-date.
# Description of Changes
Adds the code for the SaaS frontend as proprietary code to the OSS repo.
This version of the code is adapted from 22/1/2026, which was the last
SaaS version based on the 'V2' design. This will move us closer to being
able to have the OSS products understand whether the user has a SaaS
account, and provide the correct UI in those cases.
* Adds a fallback mechanism so the desktop app routes tool operations to
the local bundled backend when the user's self-hosted Stirling-PDF
server goes offline, and disables tools in the UI that aren't supported
locally.
* `selfHostedServerMonitor.ts` independently polls the self-hosted
server every 15s and exposes which tool endpoints are unavailable when
it goes offline
* `operationRouter.ts` intercepts operations destined for the
self-hosted server and reroutes them to the local bundled backend when
the monitor reports it offline
* `useSelfHostedToolAvailability.ts` feeds the offline tool set into
useToolManagement, disabling affected tools in the UI with a
selfHostedOffline reason and banner warning
- `SelfHostedOfflineBanner `is a dismissable (session-only) gray bar
shown at the top of the UI when in self-hosted mode and the server goes
offline. It shows:
PostHog is now initialized with persistence: 'memory' so no cookies are
written on first load. Consent is handled in a PostHogConsentSync
component that switches to localStorage+cookie persistence only when the
user accepts, using the official @posthog/react package (cherry-picked
from 14aaf64)
---------
Co-authored-by: James Brunton <james@stirlingpdf.com>
If you have any additional information, comments, or resources you think would support or be relevant to your feature request, include them here.
- type:textarea
id:sample-files
attributes:
label:Example Files
description:|
If the feature request depends on specific PDFs or other example files, attach them here when available. Remove any sensitive information before sharing.
- [ ] 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/devGuide/DeveloperGuide.md) (if applicable)
- [ ] 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
- [ ] Every comment I added says something the code does not ([guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/CODE_COMMENTS.md))
- [ ] My changes generate no new warnings
### Documentation
@@ -37,4 +38,5 @@ Closes #(issue_number)
### Testing (if applicable)
- [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
- [ ] 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.
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/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/editor/public/locales/en-US/translation.toml)"
const praise = `## 🤖 AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\n✅ No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`;
if (existing) {
await github.rest.issues.updateComment({
owner, repo,
comment_id: existing.id,
body: praise
});
console.log("Replaced suggestion with praise.");
} else {
console.log("Rating > 5 and no existing comment – skipping comment.");
}
}
- name:is not repo dev
if:steps.actor.outputs.is_repo_dev != 'true'
run:|
exit 0 # Skip the AI title review for non-repo developers
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/public/locales/*/translation.toml`) to reflect changes in the reference file `en-GB/translation.toml`.
- Updated translation files (`frontend/editor/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.
This file provides guidance to AI Agents when working with code in this repository.
## Taskfile (Recommended)
This project uses [Task](https://taskfile.dev/) as a unified command runner. All build, dev, test, lint, and docker commands can be run from the repo root via `task <command>`. Run `task --list` to see all available commands.
Task `desc:` fields should describe **what** the task does, not **how** it does it. Keep them generic and stable: don't reference implementation details like aliases, internal helpers, mode flags, or which other task delegates to which. The description is for users picking a command from `task --list`, not a changelog of refactors.
-`task docker:build` — build standard Docker image
-`task docker:up` — start Docker compose stack
## Comments
A comment must carry information the code cannot. If a reader could derive it from the code in front of them, delete it.
Comment the current state. Not what the code used to do, not what changed, not why it changed: git holds that. Where history explains the shape, state the reason instead, so "this used to reimplement the modal internals" becomes "thin wrapper over the shared Modal: duplicating its portal and focus trap is how dialogs drift apart". Future state goes in a TODO with an issue.
Write a comment when it does one of these four jobs:
- **Contract.** What a caller must know that the signature cannot say: preconditions, invariants, units, ownership and lifetime, thread-safety, error semantics, side effects. Document the contract of everything a caller outside the file can reach, and nothing else. Goes on the type/method/module as Javadoc, JSDoc, or a docstring.
- **Why.** The constraint the code satisfies, the bug it avoids, the alternative rejected and the reason.
- **Hazard.** "Must stay in sync with X", "order matters because Y", "do not remove, it prevents Z".
- **Map.** A short orientation at the top of a genuinely complex file: what it owns, and what it deliberately does not.
Never write:
- A comment that restates the next line. `// Handle drag start` above `handleDragStart` is noise.
- Section banners or position markers: `// --- Types ---`, `// Helpers`, `// =====`.
- Step narration in a function body (`// Step 1:`, `// Then we`). If the steps need labels they need names: extract functions. Numbering a genuinely numbered thing, like a wizard step, is fine.
- Commented-out code. Delete it.
- Doc tags that restate the signature. `@param blob - The blob` says nothing; omit the tag rather than pad it.
- Docs on self-explanatory members with no constraint to state.
Two tests before keeping a comment:
- **Delete it.** Is any information lost? If not, it stays deleted.
- **Could a name carry it instead?** A better identifier, an extracted function, or a named constant beats a comment. Prefer the code change.
A comment at the end of a line usually decodes that line, and that is worth keeping: `{0x25, 0x50} // "%PDF"`, `50L * 1024 * 1024 // 50 MB`. The rules that compare a comment against the code below it do not apply there, but a trailing TODO or a trailing bit of history is judged like any other.
A reference is supplementary, never load-bearing: the comment must survive deleting it. `// See #1234` is a dead end; `// saving first loses every annotation (#6865)` is not. Prefer a spec (`RFC 3161`) or CVE where one applies.
A TODO needs an issue, not an owner: `// TODO(#1234): re-enable the gate once account syncing lands`. If it is not worth an issue, it is not worth a TODO. A question is not a TODO.
A comment block over ~12 lines outside a file or type header usually means the code needs restructuring, or that the prose is product documentation and belongs in the docs repo.
`task comment-lint` checks the mechanical part of this on the lines you add, and runs inside `task pre-commit`. Reasoning, worked examples and the linter's own rules: @devGuide/CODE_COMMENTS.md
- **Full quality gate**: `task check` (runs lint + typecheck + test across all components)
After modifying any files in the project, you must run the relevant `task check` command that covers that area of the code. For example, when editing frontend files run `task frontend:check`; for Python engine files run `task engine:check`; for Java backend files run `task backend:check`.
- **Example compose files**: Located in `exampleYmlFiles/` directory
### Security Mode Development
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
### Python Development (AI Engine)
The engine is a Python reasoning service for Stirling: it plans and interprets work, but it does not own durable state, and it does not execute Stirling PDF operations directly. Keep the service narrow: typed contracts in, typed contracts out, with AI only where it adds reasoning value. The frontend calls the Python engine via Java as a proxy.
#### Python Commands
All engine commands run from the repo root using Task:
-`task engine:check` — run all checks (typecheck + lint + format-check + test)
-`task engine:fix` — auto-fix lint + formatting
-`task engine:install` — install Python dependencies via uv
-`task engine:dev` — start FastAPI with hot reload (localhost:5001)
-`task engine:test` — run pytest
-`task engine:lint` — run ruff linting
-`task engine:typecheck` — run pyright
-`task engine:format` — format code with ruff
-`task engine:tool-models` — generate `tool_models.py` from the Java OpenAPI spec
The project structure is defined in `engine/pyproject.toml`. Any new dependencies should be listed there, followed by running `task engine:install`.
#### Python Code Style
- Keep `task engine:check` passing.
- Use modern Python when it improves clarity.
- Prefer explicit names to cleverness.
- Avoid nested functions and nested classes unless the language construct requires them.
- Prefer composition to inheritance when combining concepts.
- Avoid speculative abstractions. Add a layer only when it removes real duplication or clarifies lifecycle.
- Comments follow the repo-wide rules in the "Comments" section above.
#### Python Typing and Models
- Deserialize into Pydantic models as early as possible.
- Serialize from Pydantic models as late as possible.
- Do not pass raw `dict[str, Any]` or `dict[str, object]` across important boundaries when a typed model can exist instead.
- Avoid `Any` wherever possible.
- Avoid `cast()` wherever possible (reconsider the structure first).
- All shared models should subclass `stirling.models.ApiModel` so the service behaves consistently.
- Do not use string literals for any type annotations, including `cast()`.
#### Python Configuration
- Keep application-owned configuration in `stirling.config`.
- Only add `STIRLING_*` environment variables that the engine itself truly owns.
- Do not mirror third-party provider environment variables unless the engine is actually interpreting them.
- Let `pydantic-ai` own provider authentication configuration when possible.
#### Python Architecture
**Package roles:**
-`stirling.contracts`: request/response models and shared typed workflow contracts. If a shape crosses a module or service boundary, it probably belongs here.
-`stirling.models`: shared model primitives and generated tool models.
-`stirling.agents`: reasoning modules for individual capabilities.
-`stirling.api`: HTTP layer, dependency access, and app startup wiring.
-`stirling.services`: shared runtime and non-AI infrastructure.
-`stirling.config`: application-owned settings.
**Source of truth:**
-`stirling.models.tool_models` is the source of truth for operation IDs and parameter models.
- Do not duplicate operation lists if they can be derived from `tool_models.OPERATIONS`.
- Do not hand-maintain parallel parameter schemas when the generated tool models already define them.
- If a tool ID must match a parameter model, validate that relationship explicitly in code.
**Boundaries:**
- Keep the API layer thin. Route modules should bind requests, resolve dependencies, and call agents or services. They should not contain business logic.
- Keep agents focused on one reasoning domain. They should not own FastAPI routing, persistence, or execution of Stirling operations.
- Build long-lived runtime objects centrally at startup when possible rather than reconstructing heavy AI objects per request.
- If an agent delegates to another agent, the delegated agent should remain the source of truth for its own domain output.
#### Python AI Usage
- The system must work with any AI, including self-hosted models. We require that the models support structured outputs, but should minimise model-specific code beyond that.
- Use AI for reasoning-heavy outputs, not deterministic glue.
- Do not ask the model to invent data that Python can derive safely.
- Do not fabricate fallback user-facing copy in code to hide incomplete model output.
- AI output schemas should be impossible to instantiate incorrectly.
- Do not require the model to keep separate structures in sync. For example, instead of generating two lists which must be the same length, generate one list of a model containing the same data.
- Prefer Python to derive deterministic follow-up structure from a valid AI result.
- Use `NativeOutput(...)` for structured model outputs.
- Use `ToolOutput(...)` when the model should select and call delegate functions.
#### Python Testing
- Test contracts directly.
- Test agents directly where behaviour matters.
- Test API routes as thin integration points.
- Prefer dependency overrides or startup-state seams to monkeypatching random globals.
### Frontend Development
- **Frontend dev server**: `task frontend:dev` — requires backend on localhost:8080
- **Web Server**: `task frontend:build` then serve dist/ folder
- **Development**: `task desktop:dev` for desktop dev mode
#### 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)
- 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
-`task frontend:prepare` creates empty `.local` override files on first run; pass `MODE=saas` or `MODE=desktop` to also create the mode-specific `.local` file
- Prepare runs automatically as a dependency of all `dev*`, `build*`, and `desktop*` tasks
- See `frontend/README.md#environment-variables` for full documentation
#### 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
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`.
- Building layer-specific override that wraps a lower layer's component
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/saas/desktop/cloud) and handles the fallback cascade — see "Frontend `cloud/` Layer" below for the full per-flavor order.
#### Frontend `cloud/` Layer
`@app/*` resolves through a per-flavor cascade — first existing file wins (shadow/override):
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (all enforced by the linter). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
**Cloud feature flags on desktop.** The local `AppConfigContext` reads `/api/v1/config/app-config` from the LOCAL bundled backend, so cloud-only flags (`aiEngineEnabled`, `premiumEnabled`, …) are never seen on desktop. To read the cloud's view, use `useSaasAppConfig()` (`desktop/hooks/useSaasAppConfig.ts`, backed by the general `saasAppConfigService` — SaaS-mode-only, public endpoint, native HTTP, 5-min cache). It returns `null` outside SaaS mode, so cloud features stay off in local/self-hosted and the server keeps the on/off switch (no desktop release needed to flip a flag). Gate a feature behind a per-platform seam — e.g. `useAiEngineEnabled()` (core reads `useAppConfig()`, desktop reads `useSaasAppConfig()`) — rather than hardcoding the flag on.
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
**How it works:**
1. Core defines stub component (returns null or no-op)
2. Desktop/proprietary overrides with same path/name
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
- **Example compose files**: Located in `exampleYmlFiles/` directory
### Security Mode Development
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
### Frontend Development
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
- Building layer-specific override that wraps a lower layer's component
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
**How it works:**
1. Core defines stub component (returns null or no-op)
2. Desktop/proprietary overrides with same path/name
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.