# 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.
2026-07-03 08:55:07 +00:00
2928 changed files with 267620 additions and 55635 deletions
'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.',
'',
'Run `task engine:tool-models` to regenerate, then commit the updated file.',
'The generated `frontend/editor/src/core/types/toolApiTypes.ts` and/or `engine/src/stirling/models/tool_models.py` are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.',
'',
'Run `task tool-models` to regenerate both, then commit the updated files.',
description:"Depot runner vCPU count (used in runs-on). Override for benchmarking. Tuned to 8 to match the other playwright workflows; bench showed flat scaling above 8."
@@ -139,7 +139,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Environment Variables
- All `VITE_*` variables must be declared in the appropriate committed env file:
-`frontend/editor/.env` — core, proprietary, and shared vars
-`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
@@ -154,6 +155,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
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`.
```typescript
// ✅ CORRECT - Use @app/* for all imports
import{AppLayout}from"@app/components/AppLayout";
@@ -452,6 +455,7 @@ The frontend is organized with a clear separation of concerns:
- **CRITICAL**: Always update translations in `en-US` only - all other languages (including `en-GB`) are handled separately
- Translation files are located in `frontend/editor/public/locales/`
- After changing any translation file, run `task pre-commit:fix`
@@ -92,7 +92,7 @@ Visit the [Lombok website](https://projectlombok.org/setup/) for installation in
5. Add environment variable
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
5. **Frontend Setup (Required for Stirling 2.0)**
6. **Frontend Setup (Required for Stirling 2.0)**
Navigate to the frontend directory and install dependencies using npm.
### Verify Setup
@@ -275,7 +275,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
1. Set the security environment variable:
```bash
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
export DISABLE_ADDITIONAL_FEATURES=true # or false to enable login and security features for builds
```
2. Build the project:
@@ -305,7 +305,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. However, to improve build times these can often be removed depending on your usecase
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.