# Prepaid usage bundles
Teams on pay‑as‑you‑go can **buy a year of PDF processing up front, at a
discount** — *"12 months for the price of 10."* You pre‑buy a pool of
credits; they're spent **before** any metered billing and sit
**outside** the monthly spend limit; unused capacity expires after 12
months.
---
## What this PR delivers
**Buy → quote → invoice → pay (Stripe‑Quotes‑native).**
- A team lead sizes the pool in the calculator (persisted as a quote
row), which doubles as the quote page with a **"Download quote (PDF)"**
— the PDF is **Stripe's own rendered quote** (same mechanism procurement
uses), not an app‑generated document.
- **Finalise** turns the accepted quote into an invoice; the lead can
**download the invoice** or **pay online** (Stripe hosted invoice).
**Card and bank‑transfer / PO** are both supported (payment‑method
fork), on net terms.
- The billing page loads the in‑flight quote/invoice on open, so the CTA
resumes the right step (**View quote** / **Pay invoice to complete**)
and offers **Cancel purchase** (voids the invoice + quote and restarts).
**Prepaid is usable on its own — no subscription required.** The
entitlement gate honours a live prepaid pool in both cases:
- *Unsubscribed*: once the one‑time free grant is spent, a live pool
keeps the team **fully entitled** (all feature gates) rather than
degraded.
- *Subscribed*: a team **at/over its metered cap** but holding a live
pool stays fully entitled — prepaid draws are netted out of metered
spend, so the pool genuinely sits outside the cap.
Only when the free grant **and** the prepaid pool are both empty do
billable categories stop.
**Coordinated SaaS change (ships with this — `Stirling-PDF-SaaS` `v3`
branch):** the `invoice.paid` webhook credits the pool idempotently
(keyed on the invoice id) and settles the quote. Metered‑subscription
provisioning is best‑effort and **classified** — a permanent Stripe 4xx
(the single‑use hosted‑invoice card can't be attached) is a claimed
no‑op (HTTP 200, no retry) so Stripe doesn't redeliver forever; only
transient errors (5xx / connection / rate‑limit) retry. A failed credit
now retries rather than silently dropping a paid bundle.
### Flow
1. Lead sizes the pool and agrees to the terms → the browser sends team
+ capacity + consent, **never a price**.
2. A leader‑gated server function looks up the price and creates a
Stripe **quote** (line quantity = capacity).
3. Lead **finalises** → the quote becomes a Stripe **invoice**; download
it or pay online (card or bank transfer).
4. On `invoice.paid`, the webhook **credits the prepaid pool**
(idempotent) and settles the quote.
5. Usage then draws **free grant → prepaid pool → meter**; the pool is
usable with no subscription.
<img width="1280" height="920" alt="01-activation-fork"
src="https://github.com/user-attachments/assets/e8981dc7-809d-4fc5-bfde-71e619096b7b"
/>
<img width="1280" height="920" alt="02-calculator"
src="https://github.com/user-attachments/assets/81f6b886-1797-4c54-ba18-97d126be78e2"
/>
<img width="1120" height="600" alt="03-free-plan"
src="https://github.com/user-attachments/assets/c4057fde-d965-47dd-93e8-f2f0f612aa73"
/>
<img width="1105" height="1285" alt="04-subscribed-prepaid"
src="https://github.com/user-attachments/assets/9d1d0ef1-29d0-4bd4-9c17-ac5f68f88ca8"
/>
---
## In a follow‑up (not this PR)
1. **Authoritative price via an inline fixed‑amount coupon** *(in
progress in a separate PR).* Replace the percentage 12‑for‑10 coupon
with an edge‑function‑computed **`amount_off`** coupon: the invoice
shows a concrete "−$X.00" discount line, the total is deterministic (no
percentage‑rounding drift), and the persisted price becomes
**server‑authoritative**. Money‑mechanism change — needs validation
against the Stripe test env, so it warrants its own testable PR.
2. **Metered auto‑resume when the pool empties.** Save the paying card
at invoice time (`setup_future_usage`) for card payers → real
`charge_automatically`; a cardless `send_invoice` subscription for
bank‑transfer / PO. This makes the "processing continues at the metered
rate" promise true for everyone.
3. **Provisioning idempotency hardening** (SaaS repo). Idempotency key
on subscription creation + a conditional link RPC, so a webhook
redelivery or link‑RPC failure can't create duplicate or orphaned
subscriptions.
4. **Repo‑wide "credits" copy** across *all* of usage & billing (this PR
only makes its own additions consistent).
---
## Known edges (current state)
- **Cardless teams degrade when the pool empties.** An unsubscribed
bundle team that runs the pool dry hits DEGRADED (metered paused), not
automatic metered continuation — because no metered subscription gets
provisioned off a hosted‑invoice card. The consent copy states
processing "continues at the metered rate"; that promise is
intentionally **ahead of the mechanism** (follow‑up 2), and the 12‑month
term is the runway to deliver it. The prepaid capacity itself stays
fully usable in the meantime.
- **In‑app total vs charge can differ by ≤1¢** until follow‑up 1 lands.
The **shared approval document (the Stripe quote PDF) and the actual
invoice are already Stripe‑authoritative**; the persisted price shown
in‑app is still a front‑end estimate (percentage‑coupon rounding), so it
can differ from Stripe by a rounding cent. Resume‑time drift is fixed
(frozen to persisted); exact‑to‑the‑penny parity arrives with the
authoritative‑price follow‑up.
- **Provisioning idempotency is latent, not live.** The
duplicate/orphan‑subscription window only becomes reachable once
card‑linking (follow‑up 2) makes provisioning actually run; hardening is
tracked as follow‑up 3.
- **One job can overshoot the spend cap via a near‑empty pool.** A
subscribed team that has hit its metered cap but still holds a
*nearly‑exhausted* pool is let through (the pool overrides the cap
gate); if a job needs more than the pool has left, the pool drains to
zero and the **remainder meters**, so that single job's remainder can
bill just past the "never past your spend limit" ceiling. Bounded to one
job's overshoot and only at the pool's tail; the alternative — blocking
the job — would strand paid‑for capacity, so this is a deliberate trade.
---
## Testing
- **Java** — `EntitlementServiceTest` (18) incl.
unsubscribed‑live‑pool‑stays‑FULL,
subscribed‑over‑cap‑with‑pool‑stays‑FULL, and lazy‑read guards.
- **Frontend** — `useBundleFlowState` + `Usage` render tests; portal &
SaaS `tsc`; i18n audit; `lint:colors`; toml‑sort; prettier.
- **SaaS webhook** (`v3`) — Deno tests for terminal‑vs‑transient
provisioning classification (rate‑limit treated as retryable),
credit‑error‑retries, and an end‑to‑end no‑storm assertion on the
unusable‑card path.
*Preview:* the checkout runs in a Supabase function in
`Stirling-PDF-SaaS` (`v3`); a live V2 preview is linked in the
auto‑deploy comment below. Screenshots to be refreshed — the checkout
modal changed since the originals.
## Why
The processor/portal loads slowly because every view fetches its data on
mount with no client-side cache — navigating away and back refetches
everything, and shared data (policies, sources, roster, fleet stats) is
fetched repeatedly. This adopts **TanStack Query** so the portal caches,
dedupes, and revalidates instead.
Follows the Users-page proof-of-concept (kept as the reference A/B
example behind a dev flag); DevTools before/after confirmed revisiting a
cached view now costs zero network calls.
## What
**Shared per-resource query layer** (`portal/queries/`) — the mechanism
for both in-view and cross-view sharing:
- `keys.ts` (flavor-agnostic queryKey factory), `adapters.ts`
(`toAsyncState` → the existing `AsyncState` shape, so view bodies barely
change)
- One **base hook per endpoint**; **derived hooks**
(`usePoliciesOverview`, `useProcessorFlow`, `useOnboardingProgress`)
compose them
- The bundle functions (`fetchPolicies`, `fetchProcessorFlow`,
`useOnboardingProgress`) are decomposed into base queries — otherwise
the caches wouldn't dedupe against each other
**Migrated:** Documents, Policies, Pipelines, Sources + all of Home's
fetching cards. Mutations use `invalidateQueries` (Policies' `version`
bump removed; Source/Pipeline builders invalidate-then-navigate;
ConnectionsTab + S3 picker share one cache).
**SaaS `/team/my` collapse:** `resolveTeam()` reads through the shared
cache (`ensureQueryData`), so roster + teams resolve it once (2→1), with
a direct-fetch fallback when no provider is mounted.
`QueryClientProvider` is mounted once at the portal root (`PortalApp`),
above the router, so the cache survives navigation.
## Impact on duplicate fetches
- **In-view:** Home `/policies` ×3, `/policies/runs` ×3, `/sources` ×2,
`/v1/editor/deployment` ×2 → **1× each** per mount
- **Cross-view:** Policies / Sources / Users / EditorAdmin /
Infrastructure reuse Home's warmed cache within `staleTime` (no refetch
on navigation)
- **SaaS Users:** `/team/my` 2× → **1×**
## Testing
- Portal typecheck + SaaS typecheck, ESLint (`--max-warnings=0`),
Prettier — all green
- **224 portal tests pass** (existing component tests wrapped in a
shared `QueryClient` test provider)
- New: `queries/sharing.test.tsx` (in-view: 3 consumers → 1 fetch each;
cross-view: remount → 0 refetch) and a `/team/my` collapse assertion in
`UsersReactQuery.test.tsx`
## Notes for reviewers
- Keys are intentionally flavor-agnostic (local vs SaaS routing lives
inside the api fns), so one key addresses whichever backend the flavor
build resolves.
- `staleTime` 30s / `gcTime` 5m defaults; tier-dependent resources key
on tier.
- Users view keeps its dev flag/legacy path deliberately as the
documented reference.
# Description of Changes
Adds implicitly defined folders to the list of locations that folder
sources can look in, including the legacy watchedFolder folders, and the
server storage location (if enabled). Also adds a settings UI for
defining the list of allowed folders instead of having to manually edit
`settings.yml` (please excuse the styling, that's the standard styling
of the Processor, hoping it gets fixed by one of the styling PRs).
<img width="888" height="786" alt="image"
src="https://github.com/user-attachments/assets/cf6d0705-adcf-463c-8e80-6901a652068b"
/>
<img width="1103" height="713" alt="image"
src="https://github.com/user-attachments/assets/b7244592-249a-4149-994f-3a2b750f25f9"
/>
Follow-up to #7009, which built the theme token layer (`primitives` →
`colors` → `compat`). This PR moves the whole app onto it, removes
hardcoded colours, turns on enforcement so they can't come back, and
adds a user-selectable accent.
## What this does
- **Semantic tokens everywhere** — legacy colour aliases and raw hex are
rewritten to `--c-*` tokens (`--c-surface*`, `--c-text*`, `--c-primary`,
…). Straight rename, no visual change. Genuine literals (brand/OAuth,
colour pickers, data-viz) are left as-is.
- **Fixes missing colours** — some tokens the migration referenced were
never defined, so a few surfaces (login button, auth banners, badges,
procurement view) silently lost their colour. All defined now, and they
adapt to light/dark and the accent automatically.
- **Blocking colour lint** — CI now fails on hardcoded colours,
undefined tokens, or unreadable low-contrast status colours.
- **User-selectable accent** — light and dark each get their own accent
from Settings → Appearance, contrast-clamped so text stays legible.
"Default" keeps the standard blue.
## Still to come
Remaining inline-style hex, the legacy token-definition files
(`theme.css`, `tokens.css`), and folding `zIndex.ts` onto the dimension
tokens.
## Testing
`task frontend:check:all` green; light/dark and accent switching
spot-checked.
# Description of Changes
Multiple named **personal** API keys per user, replacing the single
opaque per-user key.
- Create (name + one-time secret), list, and revoke named keys from the
portal Infrastructure → API Keys tab. Works self-hosted and SaaS
(`X-API-KEY`).
- Per-key usage stats (today / trailing 30 days / lifetime);
API-processed documents are attributed to the specific key in the
processor's Documents feed.
- The legacy single per-user key keeps working and is lazily represented
as a named key. Rotating it revokes its migrated shadow row so the old
secret stops authenticating.
- Per-user (not per-key) rate limiting plus a per-user active-key cap,
so minting keys can't multiply the daily quota. Name-length cap;
race-safe migration and usage recording.
Keys are strictly personal: one owner, full access, no sharing.
Team-shared / scoped keys and per-key access levels were intentionally
left out of this PR to keep it small and easy to review; they can follow
as a separate, focused change.
> Note: the screenshots from the original revision showed an earlier
team-scoped design and need refreshing.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---------
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Simplifies the processor home page:
- Removes everything below the processor flow (processing status strip,
recent activity, quick actions, policy summary), leaving just the
onboarding hero and processor flow.
- Changes the policy "Set up" buttons (Security/Classification) from
primary to secondary variant.
<img width="2056" height="1047" alt="Screenshot 2026-07-20 at 7 27
56 PM"
src="https://github.com/user-attachments/assets/8625b274-b52a-47be-9052-80ac3d32dd93"
/>
## What was wrong
Opening the procurement **Schedule a call** modal had two problems:
1. **Blank the first time, works the second time.** The first open
showed nothing; closing and reopening eventually loaded Calendly.
2. **Slow to load** even when it did work.
## Why
1. Our script loader treated a script as "ready" the moment its
`<script>` tag was added to the page — not when it had actually finished
downloading. On the first open, two loads overlap (React re-runs the
effect in dev), and the second one returned "ready" too early, before
Calendly's code existed, so nothing rendered. Reopening worked because
by then the script had finished.
2. Nothing was loaded until you clicked, so the first open waited on a
cold download of Calendly's script and then its booking page.
## The fix
- Make the script loader wait for the script to **actually finish
loading**, and have overlapping loads share the same wait. This fixes
the blank-first-open (and helps every other lazy-loaded script too).
- **Warm up Calendly early**: open the connection and start fetching its
script as soon as the "Schedule a call" button appears, so the modal
opens quickly instead of downloading everything on click.
- If Calendly still can't load (e.g. blocked by an extension), show the
existing "open in a new tab" link instead of an empty modal.
## Testing
Added a unit test proving the loader only reports "ready" after the
script truly loads. Type-check, lint, and formatting all pass.
Note: I couldn't click through the live modal here (needs a linked
procurement deal running locally) — happy to do a manual open/close/open
pass before merge if you'd like.
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
# Description of Changes
This change centralizes the Java toolchain language version into a
single `buildJavaLanguageVersion` variable and reuses it across all Java
compilation tasks to ensure consistent toolchain selection.
### What was changed
- Introduced a shared `buildJavaLanguageVersion` variable derived from
the optional `javaVersion` project property, defaulting to Java 25.
- Updated the root project's Java toolchain configuration to use the
shared variable.
- Updated all subproject Java toolchain configurations to reference the
same shared variable instead of a hardcoded language version.
- Explicitly configured the `compileRestartHelper` task to use a
`javaCompiler` resolved from the same shared toolchain version.
### Why the change was made
- Eliminate duplicated Java language version definitions.
- Ensure all compilation tasks use the same Java toolchain
configuration.
- Allow the `javaVersion` project property to consistently affect the
root project, subprojects, and the restart helper compilation task.
- Simplify future Java version upgrades by requiring changes in only one
location.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
This PR resolves deprecation warnings and addresses compiler errors
resulting from the transition to Spring Security 7.x., as well Jackson 3
and general Java.
* Replaced all usages of `asText()`/`isTextual()` with
`asString()`/`isString()` in JSON parsing logic across
`FormPayloadParser.java`, `ApiEndpoint.java`, and
`KeygenLicenseVerifier.java` to ensure consistent and type-safe string
* Updated `CustomSaml2AuthenticatedPrincipal` to implement
`Saml2ResponseAssertionAccessor`, added a `responseValue` field, and
provided additional getter methods and type-safe attribute accessors.
* Switched from constructing `URL` objects directly from strings to
using `URI.create(...).toURL()` in `UIDataTessdataController.java` for
improved URL safety and parsing.
<!--
Please provide a summary of the changes, including:
- What was changed
- Why the change was made
- Any challenges encountered
Closes #(issue_number)
-->
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
Paired with https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/320
Fixes the following bugs we found when testing the SaaS release:
- Existing users couldn't join teams - this was because they were the
last leader of their team, so it'd be left orphaned). Users now have a
'home team', which can have no members if they join another team, but
they can then go back to it later.
- Existing leaders didn't have unlimited seats - `saas_teams_extensions`
had no row for them, so the app fell back to `max_seats=1`. The
migration script fixes it.
- Members without Processor access could still access the Processor - It
was just checking "Are you the leader of **any** team", instead of the
user's active team.
## What this PR does
Removes the **Flyway** migration system from the SaaS build:
- drops `flyway-core` + `flyway-database-postgresql` from
`app/saas/build.gradle`
- deletes all `Vxx__*.sql` files under
`app/saas/src/main/resources/db/migration/`
- removes the `spring.flyway.*` config from
`application-saas.properties`
- clears the now-inert `SPRING_FLYWAY_ENABLED` override and stale Flyway
comments in `testing/compose/docker-compose-saas.yml`
`ddl-auto` is **left on `update`** (unchanged) — that's a separate
decision (see "Not in this PR").
## Why — Flyway never actually ran anywhere
From a full schema-management review
(`notes/FLYWAY_MIGRATION_REVIEW.md`), verified against the live
databases:
- **Prod & dev (v3):** no `flyway_schema_history` table exists in any
schema → Flyway has never executed. Schema is authored by the Supabase
migrations in `Stirling-PDF-SaaS` and applied by that repo's **GitHub
integration** (merge to `main` → prod).
- **Tests:** the saas module has zero `@SpringBootTest`; the only
real-DB integration tests (in `proprietary`) use `ddl-auto=create-drop`
and don't have Flyway on the classpath.
- **The mock-DB harness** (`testing/compose/docker-compose-saas.yml`,
PAYG cucumber) *explicitly disabled* Flyway, because the `Vxx`
migrations can't run against a clean Postgres — they assume Supabase has
already provisioned `users`/`teams` (`V2` ALTERs `users`, `V5`
references `teams`).
So Flyway was dead weight, and its 4 duplicate versions
(`V25/V26/V30/V31`) were a latent trap: re-enabling it would crash boot
on the collision. Everything it contained (schema + seeds like the
default pricing policy) is already mirrored by the Supabase migrations,
and by `saas-seed.sql` for the cucumber stack.
## ⚠️ Required follow-up (item #1) — capture the Flyway-only tables into
Supabase migrations
**This is documentation of the next step, not done in this PR.**
Seven tables were defined in Flyway with **no matching Supabase
migration**. They exist in prod today only because `ddl-auto=update`
created them from their entities. Before `ddl-auto` is ever tightened to
`validate` (see #3 below), and so any fresh Supabase branch is complete,
they must be added as Supabase migrations in
`Stirling-PDF-SaaS/supabase/migrations/`.
**Capture (CREATE) — 6 live tables** (definitions are visible in the
deleted files in this PR's diff):
| Table | Source (deleted here) | Backing entity |
|---|---|---|
| `resource_grants` | `V25__resource_grants.sql` | `ResourceGrant` |
| `integration_configs` | `V26__integration_configs.sql` |
`IntegrationConfig` |
| `policy_sources` | `V22__policy_engine_tables.sql` | `SourceEntity` |
| `policy_source_doc_counts` | `V23__policy_source_doc_counts.sql` |
`SourceDocCountEntity` |
| `policy_source_doc_totals` | `V23__policy_source_doc_counts.sql` |
`SourceDocTotalEntity` |
| `saas_user_extensions` | `V9__saas_user_team_extensions.sql` |
`SaasUserExtensions` |
Write them as `CREATE TABLE IF NOT EXISTS stirling_pdf.<name> (...)`
(idempotent — no-op against the existing prod/v3 tables). Preserve
column types/defaults/constraints from the deleted `Vxx` files.
**Drop (do NOT recreate) — 1 orphaned table:**
- `classification_labels` — created by `V30` and dropped by `V39` within
Flyway; its `ClassificationLabel` is now a plain `record`, not a JPA
entity. It lingers in prod only because Flyway's `V39` drop never ran.
The follow-up should emit a `DROP TABLE IF EXISTS
stirling_pdf.classification_labels` (mirroring `V39`'s intent), after
confirming nothing reads it.
## Not in this PR (deliberately)
- **#3 — flip saas `ddl-auto` `update` → `validate`.** Held pending team
confirmation; it has boot-risk and should be gated by a CI
"validate-boot against a fresh Supabase branch" first. Self-hosted stays
on `update` regardless.
- **#4 — `billing_subscriptions` split-brain** (prod `public` has 23,164
rows, `stirling_pdf` has 0, Java reads the empty one). Tracked
separately.
## Verification
- `:saas:compileJava` succeeds with Flyway removed (no code imports
`org.flywaydb.*`).
- No test or ArchUnit rule references Flyway or the migration files.
- No runtime/data impact: Flyway never ran against any live database.
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Bumps com.diffplug.spotless from 8.5.0 to 8.8.0.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
# Description of Changes
This change updates multiple frontend dependencies to their latest
compatible releases by refreshing the `package-lock.json`. The update
includes dependency version bumps across the frontend toolchain and
runtime libraries while removing obsolete transitive dependencies
introduced by newer package versions.
### What was changed
- Updated Babel packages to the latest 7.29.x releases.
- Upgraded Vite from 7.3.2 to 7.3.6.
- Upgraded Vitest packages from 3.2.4 to 3.2.6.
- Updated React Router and React Router DOM from 7.13.2 to 7.18.1.
- Updated Axios from 1.15.0 to 1.18.1.
- Updated PostHog packages to newer releases.
- Updated additional frontend dependencies including Preact, Web Vitals,
FormData, HasOwn, Brace Expansion, and other transitive packages.
- Removed obsolete OpenTelemetry and Protobuf-related transitive
dependencies that are no longer required by the updated dependency
graph.
- Refreshed the lockfile to reflect the new dependency tree.
### Why the change was made
- Keep frontend dependencies up to date.
- Incorporate upstream bug fixes, performance improvements, and security
updates.
- Reduce unnecessary transitive dependencies where newer package
versions no longer require them.
- Maintain compatibility with the current frontend toolchain.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
Bumps ubuntu from `c4a8d55` to `4fbb8e6`.
> **Note**
> Automatic rebases have been disabled on this pull request as it has
been open for over 30 days.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
## Summary
Follow-up polish to the home **PDF Processor** flow visualiser (base
landed in #7014). Tunes the particle animation to react to real volume
and adds a Storybook playground to tune it live.
## Changes
### Particle emission — `useFlowParticles.ts`, `flowTypes.ts`
- Emission rate now scales ~linearly with a source's 24h volume (**2×
volume ≈ 2× dots**) instead of the flat `rate / 86400 × SPEED`, capped
at **one dot / 250ms** (`MAX_EMIT_PER_SEC`) for busy sources (~≥
800/24h).
- Bounded the spread so the busiest source emits at most **5×** the
quietest (`EMIT_SPREAD_CAP`) — a dominant source can't starve the
others.
- Wider departure jitter (`[0.4×–1.7×]` the mean, still floored at the
per-source min-gap) and ~**2× faster travel** so the flow reads
livelier.
- Replaced the single `SPEED` constant with `EMIT_DIVISOR` /
`MAX_EMIT_PER_SEC` / `EMIT_SPREAD_CAP`. Weighted round-robin outcome
split is unchanged (e.g. 3 failed / 30 delivered → ~1 red dot in 11).
### Storybook Playground — `ProcessorFlow.stories.tsx`,
`ProcessorFlow.tsx`
- New **Playground** story with live controls: per-input rate sliders,
the delivered/failed split (drives the red-dot ratio), and a
Classification-active toggle.
- Added an optional `dataOverride` prop (prod-inert testing seam) so the
story renders a supplied flow model instead of fetching — changes apply
instantly.
### Housekeeping
- Condensed authored comments across the feature to ≤ 2 lines.
## Testing
- `task frontend:check` green — lint, typecheck, 1353 tests.
- Verified emission numerically (proportionality, 250ms ceiling, 5×
spread cap) and confirmed live animation in a focused Storybook tab.
# Description of Changes
There's currently a column size inconsistency between the SaaS v3 DB and
the main Java code which causes the backend to fail to start up when
connected to a fresh DB. This is because the column previously was width
255, but now it's officially width 50, but the Java type is still
implicitly `varchar(255)` because there's no length attribute. If it's a
fresh DB, Postgres throws an error that it can't expand the column (this
doesn't error on an existing DB because the column is already wide
enough behind the scenes).
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Small follow-up to #6957 addressing the three non-blocker findings from
its review. **Draft / stacked on #6957** — the diff shows #6957's
changes until it merges, then auto-narrows to just these 5 files. Mark
ready + rebase onto `main` once #6957 lands.
### 1. avg-per-PDF no longer blends unsynced units over synced-only docs
`avgCostMinor` now divides **synced** units (`spendUnitsThisPeriod`) by
synced docs, so numerator and denominator cover the same population.
Combined-billing `pendingUnits` (units-only, no doc count) previously
inflated the average for linked-instance teams. The "meter units" figure
still shows synced+pending (total current usage) — only the *average* is
synced-only.
### 2. Empty-state: unsynced-only reads cleanly
When `docs == 0` but there are pending meter units (combined-billing,
nothing synced yet), the card showed a bare **"0 PDFs"** headline with a
count-less summary and no split. It now shows a **"{n} meter units
pending sync from linked instances"** note instead. New `unitsPending`
i18n key + a `UnsyncedOnly` story. (Only reachable on the
combined-billing path; pure-SaaS teams are unaffected.)
### 3. uniquePdfs wording is now accurate
`document_fingerprint` is a hash of a charge's whole **input set**, so
the same file reused across *different* groupings (standalone, then
later in a merge `{A,B}`) counts per grouping — a close approximation of
"unique PDFs", exact for the single-input common case. Softened the FE
type doc + the `WalletLedgerEntry.document_fingerprint` javadoc to say
so (no behaviour change; counting model unchanged).
### Verification
FE typecheck / test / lint / format all clean; `:saas:compileJava`
green. No behaviour change beyond #1 (avg) and #2 (empty-state copy); #3
is doc-only.
# Description of Changes
- Moved the signature status-to-color mapping from `signatureStatus.ts`
to `pdfPalette.ts`.
- Removed the PDF palette dependency from the pure signature status
calculation module.
- Updated the PDF signature report to import the color mapping from the
palette module.
- Prevented signature status unit tests from initializing
browser-dependent CSS colors unnecessarily.
- Eliminated fallback warnings caused by unavailable theme CSS variables
in the Vitest environment.
- Preserved the existing signature status calculation and PDF report
color behavior.
```sh
[frontend:test:editor] stderr | src/core/hooks/tools/validateSignature/utils/signatureStatus.test.ts
[frontend:test:editor] CSS variable --pdf-light-header-bg not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-accent not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-text-primary not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-text-muted not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-box-bg not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-box-border not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-warning not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-danger not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-success not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-neutral not found, using fallback
```
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
### Motivation
- Automatically detect and mark pull requests that have merge conflicts
so maintainers can triage them quickly.
- Ensure both new and existing open PRs are covered by running on PR
events, a schedule, and manual dispatch.
### Description
- Add workflow `: .github/workflows/pr-conflict-labeler.yml` that
triggers on `pull_request_target`, a recurring `schedule`, and
`workflow_dispatch` for manual runs.
- The job uses the repository `stirling-bot`
(`.github/actions/setup-bot`) and `actions/github-script` to poll
`pull.mergeable` until GitHub computes mergeability and then add or
remove the `has conflicts` label when `mergeable === false &&
mergeable_state === 'dirty'`.
- The workflow idempotently ensures the `has conflicts` label exists
(creates it if missing) and the repository label config `
.github/labels.yml` is updated to include `has conflicts` with an
appropriate color and description.
### Testing
- Parsed both ` .github/workflows/pr-conflict-labeler.yml` and `
.github/labels.yml` with Ruby `YAML.load_file`, which succeeded.
- Installed and ran `actionlint` via `go install
github.com/rhysd/actionlint/cmd/actionlint@latest` and validated the new
workflow file with `actionlint`, which succeeded.
------
[Codex
Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a58b7061c2c8325b024980a4af8f632)
# Description of Changes
This change upgrades the project from Gradle **9.6.0** to **9.6.1**
across all build environments to keep the toolchain consistent and
aligned.
### What was changed
- Updated the Gradle Wrapper to **9.6.1**.
- Updated all GitHub Actions workflows using
`gradle/actions/setup-gradle` to install Gradle **9.6.1**.
- Updated all Docker build stages to use the `gradle:9.6.1-jdk25` image
with the corresponding pinned image digest.
- Regenerated the Windows Gradle wrapper script, resulting in minor
comment updates (`Gradle` → `gradlew`).
### Why the change was made
- Keep the project up to date with the latest Gradle patch release.
- Ensure all local, CI, and Docker build environments use the same
Gradle version.
- Benefit from the latest bug fixes and maintenance improvements
included in Gradle 9.6.1.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
- Upgraded google-java-format from 1.28.0 to 1.35.0.
- Removed the broad `suppressLintsFor` workaround for the
`google-java-format` step.
- Ensured the shared `gradle/spotless.gradle` configuration is
recognized by the relevant CI path filters and repository automation.
- Kept the shared formatter configuration available to all backend
modules.
- Verified that google-java-format 1.35.0 runs successfully on JDK 25
for the Common, Core, and SaaS modules.
- Confirmed that the previous claim about a general Guava 32.x crash on
JDK 24/25 no longer justifies suppressing all formatter lint failures.
### Verification
Verified with Temurin JDK 25.0.3 and google-java-format 1.35.0. The
formatter still depends on Guava 32.1.3-jre, and no `suppressLintsFor`
configuration is present.
```bash
./gradlew \
:common:spotlessJavaCheck \
:stirling-pdf:spotlessJavaCheck \
--rerun-tasks
```
Result:
```text
> Task :common:spotlessJava
> Task :common:spotlessJavaCheck
> Task :stirling-pdf:spotlessJava
> Task :stirling-pdf:spotlessJavaCheck
BUILD SUCCESSFUL in 26s
4 actionable tasks: 4 executed
```
Using `--rerun-tasks` ensured that the formatter was executed and that
the result did not come from the Gradle task cache.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
# Description of Changes
This PR fixes PNG signature application issues in the PDF signing
workflow.
## What was changed
- Reworked signature application to create locked and printable PDFium
stamp annotations with dedicated appearance streams.
- Removed the use of `FPDFPage_GenerateContent()` from the signature
workflow.
- Preserved the signature's original position and dimensions when
converting from the viewer's top-left coordinate system to PDF
coordinates.
- Added CropBox-aware coordinate conversion for PDFs whose visible page
origin differs from the MediaBox origin.
- Improved signature image extraction to handle internal EmbedPDF asset
references and nested image data.
- Refactored PDFium bitmap creation so image objects can safely be
transferred to annotations.
- Corrected PDFium bitmap ownership and cleanup to prevent duplicate
destruction.
- Added a PDFium WASM integration test covering:
- Existing page-content preservation
- Stamp appearance generation
- Signature coordinates and dimensions
- Printable, read-only, and locked annotation flags
- Persisted image data taking precedence over internal asset references
## Why the change was made
Applying a PNG signature previously regenerated the complete page
content through PDFium. This could corrupt existing vector or font-based
page elements, including the university logo reported in the linked
issue.
The previous coordinate conversion also relied only on the page height
and did not account for CropBox offsets, allowing the applied signature
to move from its preview position.
Creating a PDFium stamp annotation with its own appearance stream avoids
regenerating existing page content while retaining the selected
signature position and size.
Closes#7083
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
### What was changed
- Updated `BASE_VERSION` references in:
- `docker/backend/Dockerfile`
- `docker/embedded/Dockerfile`
- `docker/embedded/Dockerfile.fat`
- Pinned `stirlingtools/stirling-pdf-base:1.0.2` to a specific SHA256
digest.
- Pinned the `eclipse-temurin:25-jre-noble` image used in the
`jar-extract` stage to a specific SHA256 digest.
- Pinned the `ghcr.io/astral-sh/uv:python3.13-bookworm-slim` image in
`engine/Dockerfile.dev` to a specific SHA256 digest.
- Removed reliance on mutable image tags alone for these build stages.
### Why the change was made
- Ensure deterministic and reproducible Docker builds.
- Prevent unexpected changes caused by upstream image tag updates.
- Improve supply chain integrity by explicitly defining the exact image
artifacts used during builds.
- Align container build practices with security and compliance
recommendations.
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
- What was changed
- Fixed an invalid HTML nesting issue in `AdminGeneralSection` by
changing the affected Mantine `Text` wrapper from the default `<p>`
element to `component="div"`.
- This prevents a `<div>` from being rendered inside a `<p>` when the
`Group` for the "Logo Style" label is displayed.
- Why the change was made
- Firefox reported a hydration error in the settings modal because the
rendered DOM was invalid.
- The warning was triggered in the Admin General settings section and
affected the settings modal experience.
Firefox 152.0.3 (64-Bit)
```
In HTML, <div> cannot be a descendant of <p>.
This will cause a hydration error.
...
<AdminGeneralSection>
<div className="settings-s...">
<@mantine/core/Stack gap="lg" className="settings-s...">
<@mantine/core/Box ref={null} className="settings-s..." style={{...}} variant={undefined}>
<div ref={null} style={{...}} className="settings-s..." data-variant={undefined} data-size={undefined} ...>
<LoginRequiredBanner>
<div>
<@mantine/core/Paper withBorder={true} p="md" radius="md">
<@mantine/core/Box ref={null} mod={[...]} className="m_1b7284a3..." style={{...}} variant={undefined} ...>
<div ref={null} style={{...}} className="m_1b7284a3..." data-variant={undefined} data-size={undefined} ...>
<@mantine/core/Stack gap="md">
<@mantine/core/Box ref={null} className="m_6d731127..." style={{...}} variant={undefined}>
<div ref={null} style={{...}} className="m_6d731127..." data-variant={undefined} ...>
<@mantine/core/Text>
<div>
<div>
<@mantine/core/Text size="sm" fw={500} mb={4}>
<@mantine/core/Box className="mantine-fo..." style={{...}} ref={null} component="p" ...>
> <p
> ref={null}
> style={{--text-fz:"var(--mant...",--text-lh:"var(--mant...",marginBottom:"calc(0.25r...", ...}}
> className="mantine-focus-auto m_b6d8b162 mantine-Text-root"
> data-variant={undefined}
> data-size="sm"
> size={undefined}
> >
<@mantine/core/Group gap="xs">
<@mantine/core/Box className="m_4081bf90..." style={{...}} ref={null} ...>
> <div
> ref={null}
> style={{--group-gap:"var(--mant...",--group-align:"center",--group-justify:"flex-start", ...}}
> className="m_4081bf90 mantine-Group-root"
> data-variant={undefined}
> data-size={undefined}
> size={undefined}
> >
...
...
...
...
react-dom-client.development.js:2605:19
```
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
## What
Adds a globe toolbar to Storybook so any story can be previewed in all
42 supported languages (the i18n init already on `main` was
English-only).
## How
Bundles every locale's `translation.toml` via a `?raw` glob into i18next
resources, and switches language on toolbar change. RTL locales (`ar`,
`fa`) flip `document.dir`.
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
### Motivation
* The repository uses per-layer licensing and the
`frontend/editor/src/portal-saas/` layer existed without an explicit
license entry in the root `LICENSE`, so the layered licensing reference
needed to be added for clarity.
### Description
* Add a new `frontend/editor/src/portal-saas/LICENSE` containing the
same "Stirling PDF User License" used by adjacent non-MIT layers and
update the top-level `LICENSE` to list
`frontend/editor/src/portal-saas/` as covered by that file.
### Testing
* Verified with `diff -u frontend/editor/src/portal/LICENSE
frontend/editor/src/portal-saas/LICENSE` and `test -f
frontend/editor/src/portal-saas/LICENSE`, and attempted `task
frontend:check` which could not run in this environment because `task`
is not installed.
------
[Codex
Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a59f78f2f8483258cbe914b84126346)
### Motivation
- Prevent concurrent GitHub Actions jobs from attempting to reserve the
same `setup-uv` cache key and failing with "Unable to reserve cache ...
another job may be creating this cache" when multiple workflows run at
once.
- Target workflows that enable `setup-uv` caching and have run
concurrently in CI: pre-commit, check-generated-models, ai-engine, and
sync_files_v2.
### Description
- Added a `cache-suffix` value to the `astral-sh/setup-uv` step in
`.github/workflows/pre_commit.yml`,
`.github/workflows/check-generated-models.yml`,
`.github/workflows/ai-engine.yml`, and
`.github/workflows/sync_files_v2.yml` to create unique cache keys
(`pre-commit`, `generated-models`, `ai-engine`, `sync-files`).
- No behavior changes beyond isolating the uv cache keys per-workflow
and no other workflow steps were modified.
### Testing
- Ran `git diff --check` which completed with no reported whitespace or
index issues.
- Verified each modified workflow is valid YAML by loading them with a
Ruby YAML parser which succeeded for all four files.
- Attempted to run `task --list` to exercise the Taskfile locally but
`task` is not installed in this environment so that check could not be
executed.
------
[Codex
Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a58b399a33083259d65e0614fcc35d2)
# Description of Changes
Collapses the portal admin-roster endpoint (`getAdminSettingsData`,
`/api/v1/proprietary/ui-data/admin-settings`) from a per-user N+1 into a
constant set of queries, and adds the missing
session/user/team-membership indexes.
**Verified on H2 and real Postgres 16, 2,000-user roster:** 10,601 → 7
SQL statements, 600 → 0 writes-during-a-GET, O(N) → O(1). Portal-access
resolution is proven equivalent to the per-user check (parity test), and
a scaling guard fails the build if the endpoint ever regresses.
Also in scope (same controller / session subsystem): `getLoginData`
counts instead of loading the whole user table; `getTeamDetailsData`
fetch-joins authorities; `SessionScheduled` uses one bulk expire + a
bounded purge.
Behaviour note: the roster "active" flag now reflects *any* live session
(a strict superset of the old "newest session only") — no user who was
active is ever shown inactive.
---
## Checklist
### General
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Testing
- [x] I have run backend `task check` (spotless + full backend test
suite) — all green
- [x] I have tested my changes locally (before/after benchmark on H2 +
Postgres)
## Description
The processor home-hero and download-editor modal "Open in browser"
buttons were meant to open the editor in a new tab, but they used
`window.location.href = EDITOR_URL`, which replaces the current tab.
This switches both to `window.open(EDITOR_URL, "_blank",
"noopener,noreferrer")`, matching the existing behaviour of the "Open"
button in `EditorStatusCard.tsx`. The `noopener,noreferrer` flags mirror
that same call and prevent the new tab from getting a `window.opener`
reference.
## Changes
- `WelcomeBanner.tsx` — home-hero "Open in browser" CTA
- `DownloadEditorModal.tsx` — download modal "Open in browser" CTA
## Notes
`EDITOR_URL` can resolve to a same-origin path when the editor is the
same SPA, so opening in a new tab triggers a full page load of the
editor app. This is the expected behaviour for "Open in browser".
# Description of Changes
- Patch CVEs in engine Python dependencies (43 alerts): `starlette`
1.3.1, `cryptography` 49.0.0, `pyjwt` 2.13.0, `urllib3` 2.7.0, `aiohttp`
3.14.1, `python-multipart` 0.0.32, `langchain-core` 1.4.8, `langsmith`
0.9.1, `authlib` 1.7.2, `requests` 2.34.2, `idna` 3.18, `pytest` 9.1.1,
`pygments` 2.20.0, `pydantic-settings` 2.14.2
- Cap `pydantic-ai` `<2.0.0` and bump to 1.107.0 (1.99.0 patches
CVE-2026-46678; 2.0 is a separate major migration)
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs (if functionality has heavily
changed)
- [ ] I have read the section Add New Translation Tags (for new
translation tags only)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally
## What
CI cost/routing cleanup. Four changes, each reversible with no code
deleted.
### 1. Disable Depot repo-wide (reversible)
Depot ran on trusted (non-fork) triggers via the `is_fork` output of
`_runner-pick.yml`, driving both the `depot-*` runner selection and the
Depot docker build actions. It's now disabled everywhere behind a single
kill-switch:
- `_runner-pick.yml` gains a dedicated `use_depot` output, forced
`false` via `DEPOT_ENABLED=false`. `is_fork` stays truthful for trust
gating (e.g. `build-enterprise` skipping on forks).
- All `runs-on:` and `USE_DEPOT:` expressions now key off `use_depot`,
so every job falls back to `ubuntu-latest` + buildx.
- `settings.gradle` Depot remote build cache (`cache.depot.dev`) gated
behind `depotCacheEnabled = false`.
**Switch back on:** set `DEPOT_ENABLED=true` in `_runner-pick.yml` (and
`depotCacheEnabled = true` in `settings.gradle`). Depot then reactivates
on trusted triggers exactly as before.
### 2. arm64 PR docker build only on Dockerfile changes
`test-build-docker.yml` was building `linux/amd64,linux/arm64/v8` on
every PR matching the broad `project` filter. With Depot off, the arm64
leg runs under slow QEMU emulation on every code PR. New `dockerfiles`
path filter (`docker/**/Dockerfile*`) gates the arm64 leg: normal code
PRs build amd64 only; PRs that touch a Dockerfile still build amd64 +
arm64. arm64 is still fully exercised on the base-image publish and on
release.
### 3. Tauri PR build -> Linux only, unsigned, deb-only
The PR path built the full 3-OS matrix (Windows + macOS-universal +
Linux), plus the flaky Linux AppImage pass (#6127). PRs now build Linux
only (fastest + cheapest to compile) via a new `minimal` input on
`tauri-build.yml`: Linux deb only, no rpm, no AppImage. The full signed
multi-OS matrix still runs on release, and nightly still warms the Rust
cache with all-OS defaults (unchanged).
Tradeoff: Windows/macOS desktop build breaks are caught by nightly
(all-OS) rather than the introducing PR.
### 4. CI self-testing routing
Editing `build.yml` only matched the `project` filter, so a change to
how e2e / enterprise / tauri / engine jobs are dispatched didn't
actually run those jobs. Added a `ci` anchor (`build.yml` +
`.github/config/.files.yaml`) that every job-gating area filter now
includes, so editing the router or the filter config runs every job.
Also added the orphaned reusable workflows (`e2e-*`,
`frontend-validation`, `docker-compose-tests`, `test-build-docker`,
`check-openapi`, `check-licence`) to their area filters so editing a
reusable workflow self-tests.
## Validation
- All workflow YAML + `.files.yaml` parse; anchor resolution verified
(every job-gating filter resolves to include the `ci` paths).
- Gradle evaluates `settings.gradle` cleanly; `spotlessGradleCheck`
passes.
# Description of Changes
Removes the feature flags for enabling policies on both the backend and
frontend. We shouldn't be releasing another self-hosted release that
doesn't include policies, so it makes sense to do this now. Builds that
don't have the Processor will just not run policies because they won't
have any. Beyond that, the API should always be available, but checks
whether the user actually has the entitlements to run policies (whether
they have credits/a payment method available)
# Description of Changes
The policies stored in the DB are currently encrypted at rest, because
one version in the past included S3 keys. These are now stored properly
in the credentials system and I've manually removed the only policy that
used S3 (it was very recently released). Since there's no S3 (or other)
credentials in the policies stored JSON now, we might as well just
decrypt them. This PR pairs with #7034 to fix the issues - #7034 makes
it resilient to crashing when attempting to load encrypted JSON that's
been encrypted with the wrong key, and this makes it so if it does load
any encrypted policies, they'll be re-saved decrypted, so we should have
a vanishingly small number of encrypted policies over time.
## What this does
Consolidates the frontend's colour/theme system into a small,
well-defined token layer and reworks the theme picker. The goal was a
minimal, scalable set of semantic tokens that the editor **and** the
Processor/portal (and Storybook) all share, plus a theme model that's
easy to reason about.
## Token architecture (`core/theme/`)
A four-file layer, imported once via `index.css`:
| File | Role |
|---|---|
| `primitives.css` | The raw palette — the **only** place literal
colours live (neutral ramps `--p-gray-*`/`--p-zinc-*` + status hues). |
| `colors.css` | ~21 semantic `--c-*` tokens (surfaces, text, borders,
primary, status) mapped from primitives per theme. **Reference these.**
|
| `compat.css` | Legacy names (`--bg-*`, `--text-*`, `--color-*`)
aliased onto `--c-*` via `:root:root` so ~200 existing files keep
working. |
| `dimensions.css` | All non-colour tokens (spacing, radius, z-index,
type, motion) — single source, resolving prior collisions. |
A blocking linter (`scripts/lint/theme-lint.mjs`, run in
`frontend:lint`) enforces "literals only in `primitives.css`" within
`core/theme/`, and has a non-blocking WCAG contrast report. See
`core/theme/README.md`.
## Theme model
- **Mode** (`light` / `dark` / `system`) and **accent** are independent.
Each mode has its own accent (`lightPrimary` / `darkPrimary`).
- The editor is always `data-app-theme="custom"`; `ThemeProvider`
injects the accent as `--user-primary` and sets `data-accent`.
- **Two accent states:**
- A **colour** (preset or custom hex) → tints every surface that hue
(whole-app theming).
- The **`default`** sentinel → neutral surfaces (white/grey light, zinc
black/grey dark) with blue buttons, no tint. (`data-accent="default"`
opts surfaces out of the tint.)
- Accent contrast guardrails (`utils/customPrimary.ts`): lightness
clamps so an accent can't collapse into the base, a contrast-picked
on-primary foreground, and an accent-as-foreground variant so accent
text is never dark-on-dark.
## Theme picker (Settings → General)
- 3×5 grid: a distinct **Default** icon chip (not a colour) + 14 curated
accents, in a dropdown per mode.
- **Custom** colour via the shared `ColorInput`, with a live gamut clamp
(`clampValue`) that refuses white/grey/black — the picker handle sticks
at the boundary and preserves the working hue at achromatic extremes.
- "Restore theme to default" resets both modes.
## Other
- Dark mode is a true neutral zinc (no navy "midnight" tint); the
Mantine dark ramp and Tailwind dark channels were neutralised to match.
- Pre-paint inline script in `index.html` applies theme + accent before
first paint (no FOUC); portal and editor now share the same
`preferences.theme` source of truth.
- High-visibility surfaces migrated to tokens (FAB, landing upload
buttons, portal hero banners); scattered per-component colour swaps were
intentionally **left for a follow-up** to keep this PR focused.
## Testing
- `task frontend:check:all` (typecheck all variants + eslint + prettier
+ colour-lint) green.
- Verified light/dark, default vs tinted accents, and the custom clamp
via computed styles in the dev preview.
> Note: the `prerender-og` build step failing in the e2e/deploy jobs is
unrelated to this diff — it's in `vite.config.ts` (untouched here) and
builds cleanly locally.
## Overview
Adds a **Classification policy** to the processor's policy catalogue,
set up the same way as the Security policy. This moves classifier
configuration out of the editor (where the labels UI landed in #6898 and
was then removed with the rest of the editor's policy-management surface
in #6932) and into the processor, which is now the single place policies
are configured.
## What it does
- **Classification card** in the processor policy catalogue. Always
shown, but **setup is locked until the backend reports the AI engine is
on** — so admins can see the capability they're missing rather than it
being hidden entirely.
- **Setup wizard** mirrors Security: the workflow step shows the team's
**classification label editor** (reused
`LabelsEditor`/`LabelsEditorModal` — add box, chip grid, per-label icon
picker, import/export, reset) instead of tool toggles, since classify is
a single non-configurable step.
- On enable, the team's label vocabulary is **seeded with the 268
built-in defaults** (clobber-safe: only when the team has none). On
upload the document is classified against the team's labels and tagged;
on SaaS with the engine on, files group by category in the editor
sidebar.
## Reuse & consolidation
- Reuses the existing labels table, `labelsFile` helpers, and default
vocabulary. Labels read/write through the processor's own
`apiClient.local` (not the editor's axios client) so auth/base routing
stays explicit; the wire shape is shared.
- Consolidates policy-category icons into a shared, **id-keyed**
`policyCategoryIcon` util (outline glyphs) used by both the editor and
the processor, replacing the processor's emoji-glyph map (and the stray
`schedule` key that rendered a bare dot).
## Testing
- `task frontend:typecheck:{core,proprietary,portal}`,
`frontend:lint:eslint`, `frontend:test` (156 files / 1305 tests) — all
green.
- Verified in Storybook: the Classification card renders, the setup
wizard shows the label editor (268 defaults), and the full labels editor
opens with icons/import/export/reset. Added an MSW handler for the
app-config + labels endpoints and a `Classification` wizard story.
## Notes for reviewers
- The AI-engine gate reads the public `/api/v1/config/app-config`;
classification labels use `/api/v1/classification/labels` (team-scoped,
team-lead/admin-gated, `policies.enabled`); the classify step hits
`/api/v1/ai/tools/classify-and-label` — all pre-existing backend from
#6898.
- Known parity behavior (matches the editor hook): a transient failure
loading team labels falls back to showing the defaults; not changed here
to avoid diverging the two hooks.
# Description of Changes
The Policies page and all the frontend logic for running Policies is not
making use of the bidirectional type mappings that we now have to safely
convert from frontend to backend param models and vice versa. This
changes the way we track the types throughout so we use the mappings
properly.
Because of this, the Add Watermark settings in Policies now actually
pre-populate with the defaults instead of with nothing like they
previously did.
<img width="791" height="725" alt="image"
src="https://github.com/user-attachments/assets/cbdf4ae0-35af-4792-bf64-89216e48d304"
/>
# Description of Changes
Some of the tool settings make use of editor preferences indirectly, but
the Processor never gets that provider, so it crashes when trying to
load them.
Merges the `hotfix/v2.14.2` branch into `main`.
on the hotfix branch:
### What this actually changes on `main`
- **Version bump 2.14.1 → 2.14.2** `build.gradle`, `tauri.conf.json`,
both AUR `PKGBUILD`s, and the two `serverExperienceSimulations.ts`
test-config files.
- **Fix Postgres user settings for some users** removes `@Lob` from
`User.java that broke settings for some Postgres users.
- **Release workflow: stop msiexec hang in Windows signature verify**
---------
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: LFdev <146497073+LFd3v@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
# Description of Changes
Redesign S3 connections based on feedback from #6948. Also redesigns the
UI for Sources to make them more like the Pipelines page which improves
UX quite a bit. There's still plenty more UI/UX work for Sources and S3
but moving in the right direction.
# Description of Changes
- Replaced the Tauri application icon with an RGBA-formatted PNG.
- Added a root `.imgbotconfig` that excludes the Tauri icon from
automatic image optimization.
- Fixed the `desktop:test` compilation failure caused by
`tauri::generate_context!()` rejecting the previous non-RGBA icon.
- Prevented ImgBot from potentially converting the icon back to an
unsupported indexed PNG while optimizing its file size.
- Verified that the current icon uses PNG Color Type 6 (`Truecolour with
alpha`).
```sh
[desktop:test] error: proc macro panicked
[desktop:test] --> src/lib.rs:202:12
[desktop:test] |
[desktop:test] 202 | .build(tauri::generate_context!())
[desktop:test] | ^^^^^^^^^^^^^^^^^^^^^^^^^^
[desktop:test] |
[desktop:test] = help: message: icon /Users/runner/work/Stirling-PDF/Stirling-PDF/frontend/editor/src-tauri/icons/icon.png is not RGBA
[desktop:test]
[desktop:test] error: could not compile `***-pdf` (lib) due to 1 previous error
[desktop:test] warning: build failed, waiting for other jobs to finish...
[desktop:test] error: could not compile `***-pdf` (lib test) due to 1 previous error
task: Failed to run task "desktop:test": exit status 101
Error: exit status 101
```
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
Policies can currently throw when calling redact:
<img width="1186" height="824" alt="image"
src="https://github.com/user-attachments/assets/bdcc09fe-5bf4-4b0a-b119-bcc33c98c7f2"
/>
Policies really need to be updated to properly make use of the new
bidirectional mappings for this, but this will hopefully fix it for now.
The external-link "Developer Tools" buttons (API, Automated Folder
Scanning, SSO Guide, Air-gapped Setup) used `p="sm"` while normal tool
buttons use `p="none"`, making them render larger; this aligns their
padding so they match the size of every other tool button.
<img width="308" height="196" alt="Screenshot 2026-07-10 at 5 01 40 PM"
src="https://github.com/user-attachments/assets/fb125500-28fb-4b83-85ed-2edc12e66fc0"
/>
## What this does
Adds one test to `ResourceAccessServiceTest`: a foreign team's lead is
**denied** on a team-owned resource under the `ADMINS_AND_TEAM_LEADS`
default policy, even when an unscoped `isAnyTeamLeader` check would
admit them (stubbed `lenient()` to `true` precisely so the test fails if
the scoped path ever consults it again).
## Why
Main is already correct here — no behaviour changes in this PR. #6913
landed the scoped implementation (`matchesTeamLeadDefault`: ownerless
portal → `isAnyTeamLeader`, team-owned → `isLeaderOfTeam`), which
superseded #6893. The only piece not carried over was #6893's boundary
test, so the cross-team scoping isn't currently pinned by any test. This
adds that pin as cheap insurance for future refactors.
Verified the test does its job: it passes on main as-is, and fails if
the scoped check is swapped back to the unscoped one.
## Test plan
- `:proprietary:test --tests
"stirling.software.proprietary.access.service.ResourceAccessServiceTest"`
— green
- Spotless applied
Closes the loop on #6893.
Reworks the Processor (PAYG) meter to **size-scaled units** while
keeping a true **PDF count** visible and distinct from units, and
replaces the fragile content+time lineage grouping with **explicit
per-run grouping**. Built as one PR across three slices.
> Status: **all three slices committed + verified.** `:saas` payg suite
green (418 tests, 0 failures); FE green (typecheck 0, 1260 tests, lint
0, format 0). Remaining before it takes effect in prod: run the
size-scaled default-policy SQL (below) in the Supabase SQL editor +
attach the $0.01/unit Stripe price.
## Model (what we're implementing)
- **Size scaling**: 1 unit per 50 MiB (bytes only, no page charge, no
cap). *(policy-row config, applied separately via SQL.)*
- **Charge = number of input files**: split (1→N outputs) = 1 charge;
merge (N→1) = N charges. `doc_count` = input files, fixed at open;
joined steps add 0.
- **Grouping by run id, not time**: a pipeline/policy/AI run = one
`run_id`; its tool sub-steps group into one charge (content-lineage
still maps split/merge journeys *within* the run). Two separate runs on
identical bytes = two charges. The 5-min window survives only as a
stale-job janitor.
- **10-tool split kept**: within a run's single-file lineage, an 11th
tool run opens a 2nd charge (step limit 10).
- **Count vs units surfaced**: usage page shows unique PDFs,
per-category (automation/AI/API) counts + units, and how many PDFs hit a
size multiplier with avg units/PDF.
## Slice 1 — run-id grouping (behavioural core)
- `AutomationRunContext` (common) — thread-scoped run id.
- `InternalApiClient` — stamps `X-Stirling-Run-Id`.
- Orchestrators open a run scope **on the worker thread that
dispatches** (async-safe): `PipelineProcessor.runPipelineAgainstFiles`,
`PolicyEngine.runToCompletion` (uses `run.getRunId()`),
`AiWorkflowService.orchestrate`.
- `ChargeContext` + `JobContext`: add `runId`; the charge interceptor
reads the header.
- `JobService.joinOrOpen`: `runId == null` → always open fresh
(standalone never joins); non-null → match scoped to the same `run_id`.
`JpaJobLineageStore`/`JobArtifactHashRepository`: add `run_id` filter to
the match query. Step-limit 10 unchanged.
## Slice 2 — doc_count + document_fingerprint
- V33 migration + entity fields.
- `JobService.openFresh`: set `docCount = inputs.size()`, compute
`document_fingerprint` from input signatures, and denormalise both onto
the DEBIT row in `JobChargeService.recordLedgerDebit`.
## Slice 3 — usage analytics API + FE
- `WalletLedgerRepository`: per-category `SUM(units)` +
`SUM(doc_count)`, `COUNT(DISTINCT document_fingerprint)`, and count of
rows whose units exceed their doc_count (a size multiplier fired), over
the period.
- `WalletSnapshotResponse` + `PaygWalletController`: add `categoryDocs`,
`docsProcessedThisPeriod`, `uniquePdfsThisPeriod`,
`sizeMultiplierPdfsThisPeriod`.
- FE `types.ts` + `PdfsProcessedCard` + `useWallet` + `walletFixtures` +
i18n: headline is the **PDF count**; a summary line shows "{unique}
unique · {units} meter units · {avg} avg units/PDF"; the split bar is
per-category PDF counts; a size-multiplier line shows how many PDFs
scaled. Count is separated from meter units so a 5-unit large PDF reads
as "1 PDF, 5 units".
## Config (out of PR — run in the Supabase SQL editor)
Wrap in one transaction. The partial-unique `is_default` index only
allows one default, so the old default is flipped off **before** the new
one is inserted. The new policy carries the prior default's
`free_tier_units` forward (change the literal if the launch grant should
differ).
```sql
BEGIN;
-- 1) flip default off the current policy + close its effective window
UPDATE stirling_pdf.pricing_policy
SET is_default = FALSE, effective_to = now()
WHERE is_default = TRUE;
-- 2) new default: 1 unit / 5 MiB, no page charge, no scaling cap.
-- free_tier_units carried from whatever the last policy granted (COALESCE→0).
INSERT INTO stirling_pdf.pricing_policy
(version, effective_from, doc_pages_per_unit, doc_bytes_per_unit,
min_charge_units, file_unit_cap, free_tier_units, is_default, notes, created_by)
VALUES
('v2-size-scaled-2026-07', now(),
2147483647, -- doc_pages_per_unit = INT_MAX → pages never drive units
52428800, -- doc_bytes_per_unit = 50 MiB → +1 unit per 50 MiB
1, -- min_charge_units
2147483647, -- file_unit_cap = INT_MAX → no cap on size scaling
COALESCE((SELECT free_tier_units FROM stirling_pdf.pricing_policy
ORDER BY effective_from DESC LIMIT 1), 0),
TRUE, 'Size-scaled: 1 unit/5MiB, bytes only, no cap', 'connor');
-- 3) per-source step limits: standalone ops = own charge; pipelines split at 10
INSERT INTO stirling_pdf.pricing_policy_step_limit (policy_id, job_source, step_limit)
SELECT p.policy_id, s.src, s.lim
FROM stirling_pdf.pricing_policy p
CROSS JOIN (VALUES
('WEB',1),('API',1),('DESKTOP_APP',1),('LINKED_INSTANCE',1),('PIPELINE',10)
) AS s(src, lim)
WHERE p.version = 'v2-size-scaled-2026-07';
-- 4) attach the $0.01/unit Stripe price (you handle the real price id)
INSERT INTO stirling_pdf.pricing_policy_stripe_price (policy_id, stripe_price_id)
SELECT policy_id, 'price_XXXXXXXX'
FROM stirling_pdf.pricing_policy WHERE version = 'v2-size-scaled-2026-07';
COMMIT;
```
Note: the `free_tier_units` subquery reads the most-recent policy
*before* the insert — run it as written (the new row doesn't exist yet
at step 2's SELECT).
## Self-hosted parity — tracked follow-up (not in this PR)
Combined-billing (`stirling.billing.account-link.enabled`) is a
**separate metering engine** (`app/proprietary/accountlink` —
`UsageMeterService`/`LocalUsageService`/`UsageSyncService`). The unit
*math* is shared (`DocumentUnitCalculator`), so size scaling matches
once the policy is pushed. But run-id grouping, `doc_count`, and
fingerprints must be mirrored there, and the usage-sync protocol
extended to report counts/fingerprints, before the self-hosted usage
page shows the same breakdown. Frozen/deferred, so this PR does SaaS;
self-hosted mirrors when it ships.
## What
Phase 2 of the Free PDF Editors usage card (self-hosted shipped in
#6919): make it work on **SaaS**, where one backend serves many teams so
every figure must be scoped to the **caller's team**.
| Metric | SaaS (per team) |
|---|---|
| **Editors deployed** | team member count (`team_memberships`) |
| **Active this month** | distinct members with a free-UI
(`source='WEB'`, non-`UI_DATA`) audit event in 30d, clamped ≤ deployed |
| **PDFs edited** | the team's cumulative free-UI
`PDF_PROCESS`+`FILE_OPERATION` events |
Cost stays `$0`; uncomputable figures render **N/A**.
## Backend
- **Gate the self-hosted controller** `@Profile("!saas")` — its counts
are server-wide, which would leak across tenants on SaaS. New
team-scoped `SaasFleetUsageController` `@Profile("saas")` owns the same
`/api/v1/usage/fleet-stats` path (mutually exclusive profiles → no
mapping conflict).
- **Team resolution** mirrors `PaygWalletController`:
`AuthenticationUtils.getCurrentUser(auth, userRepo)` →
`TeamMembershipRepository.findPrimaryMembership` → members via
`findByTeamId`. `@PreAuthorize("isAuthenticated()")` (team leaders
aren't global admins; any member sees their own team's totals).
- **Audit → team join**: on SaaS the audit `principal` is the user's
email and `User.username == email`, so principals join cleanly to a
team's member usernames (no hashing — only raw-JWT/over-long principals
get hashed). Two new `principal IN` count queries do the filtering,
served by the `(source, timestamp, principal)` index from #6919.
- Billing/ledger is deliberately **not** used — it only records billable
ops; free-editor activity comes from audit (same `source='WEB'` signal
as self-hosted).
- `null`→N/A when EE auditing < STANDARD; 401 on no-auth; empty-fleet
guard for the (post-migration-shouldn't-happen) teamless caller.
## Frontend
- New `src/portal-saas/api/fleetStats.ts` (rides the `@portal/*` cascade
from #6900) reads via **`apiClient.saas`** — the Supabase JWT the SaaS
backend uses to resolve the team. Re-exports `FleetStats` via
`@portal-proprietary`. **The card and `useAsync` hook are untouched.**
## Tests
`STIRLING_FLAVOR=saas` build green — `:proprietary` + `:saas` compile,
`SaasFleetUsageControllerTest` (team scoping, audit-off→null, clamp,
no-team→empty, unauth→401) and the existing suites pass; spotless clean.
## Notes
- Requires SaaS auditing at STANDARD (it is) — else N/A.
- Depends on #6900 (merged) for the portal-saas override layer and #6919
(merged) for the audit `source` column + DTO.
# Description of Changes
Adds Editor source permanently available in the Sources list. Excludes
it from the Pipelines list of available sources currently because it's
not a real source on the backend, so attempting to connect to it causes
an error. It'd be nice to extend in the future to be able to set up
policies in the editor from the pipelines page, but this'll do for now.
## What
Reworks the free-tier home hero (`WelcomeBanner` + `SetupChecklist`) to
match marketing's reworked top card: a compact product header over
numbered getting-started steps, dropping the marketing chrome.
## aim
attachments/assets/75a80e5f-119e-46bb-80e7-fc4b9a62e5b6" />
<img width="1098" height="646" alt="01-aim-marketing-demo"
src="https://github.com/user-attachments/assets/681ca1b8-219e-4afe-9748-89435aafd440"
/>
## old hero
<img width="1800" height="1338" alt="02-before-old-hero"
src="https://github.com/user-attachments/assets/a396cec0-4752-4ac0-9951-9a49f9d50ea7"
/>
## new screenshots
<img width="1800" height="626" alt="03-after-onboarding-card"
src="https://github.com/user-attachments/assets/59332111-6c49-438d-af6d-200d99bf0f8f"
/>
<img width="1800" height="180" alt="04-after-deployed-header"
src="https://github.com/user-attachments/assets/ea631e6c-7878-4159-aaf7-1f0c2bd795ce"
/>
<img width="1024" height="1396" alt="05-after-install-modal-list"
src="https://github.com/user-attachments/assets/125d0a11-d799-40f0-bd8b-42f5dedcfe6d"
/>
<img width="1024" height="858" alt="06-after-install-modal-docker"
src="https://github.com/user-
## Changes
- **Compact dark header:** brand mark + "PDF Editor" + social-proof
stats (`30M downloads · 60+ PDF operations · Free forever`) + a single
**Open in browser** CTA (→ `EDITOR_URL`).
- **Dropped** the decorative editor mock, marketing
title/subtitle/"Open-source" badge/perks, the two extra banner buttons,
and the checklist's dismiss/progress/done tracking.
- **Numbered nav steps** (①②③) — each opens its in-app surface:
| # | Step | Goes to | Change |
|---|------|---------|--------|
| ① | Download the editor | `editor` view | was an external
`stirling.com/download` link → now in-app |
| ② | Confirm your policies | `policies` view | live active/recommended
counts retained |
| ③ | Invite teammates | `users` view | **replaces** "Connect your
sources" (sources dropped to match the demo) |
- **Enterprise rung** unchanged (Start Trial / Get Quote → procurement).
## Notes
- **Shared hero** — self-hosted sees it too (per decision).
- **One deliberate deviation from the demo:** the header CTA is blue
(brand primary) rather than the demo's white button. Trivial to flip —
say the word.
- Behaviour change: the hero is now a quick-start (navigational) rather
than a completion checklist — the dismiss control + per-step done chips
are gone to match the demo.
- Supersedes the incremental #6944 ("add Open in browser" 3-button
version) — that can be closed in favour of this.
- Portal `tsc` clean; `unusedTranslations` green (removed orphaned
welcome/onboarding keys, added the new ones).
Removes some cluttered/unused UI from the portal:
- Search bar in the header
- The top bar entirely (breadcrumb, notification bell, plan switcher,
user menu)
- The plan/usage indicator in the sidebar footer
- The floating assistant badge
UI only. Where a component isn't deleted it's just no longer rendered,
so anything here is easy to restore.
## What
The portal's **"What runs on your PDFs"** table (`PolicySummary`)
rendered raw i18n keys instead of text:
- `portal.policies.categories.ingestion.label` / `.desc`
- `portal.policies.categories.security.label` / `.desc`
- …and the other three categories (compliance, routing, retention)
## Why it broke
[#6910 "Remove in-app portal
mocks"](https://github.com/Stirling-Tools/Stirling-PDF/pull/6910) moved
the policy catalogue to real data and converted each category's
`label`/`desc` (and each config's `summary`) into **i18n keys** — see
the `// values are i18n keys — render with t()` note in
`api/policies.ts`. Every consumer was updated to call `t()`
(`PolicyCategoryCard`, `PolicyDetailPanel`, `PolicySetupWizard`)… except
`PolicySummary`, which was not part of that PR and kept rendering the
fields verbatim.
The translation keys themselves already exist in
`en-US/translation.toml` (`[portal.policies.categories.*]`) — nothing
was missing, they just weren't being looked up.
## Fix
Wrap the values in `t()` in `PolicySummary.tsx` (the `t` from
`useTranslation` was already in scope):
- category `label` / `desc` in the Policy column
- `config.summary` in the Active-rule column (same keyed-value
treatment, latent until a policy is active)
## Test plan
- [ ] Open the portal Home / policies summary → each row shows the
translated category name + description (e.g. "Ingestion" / "Classify
documents…") instead of a dotted key.
- [ ] A row with an active policy shows its translated rule summary in
the Active rule column.
## What
Removes the policy **management** surface from the editor's right rail —
the Policies list above Tools, the open-policy detail takeover, and the
collapsed-rail policy icons — along with the whole UI tree only they
used: the setup wizard and its tool-config steps (PII / redact /
watermark), the detail panel, delete modal, selection store,
enforcement-queue status chip, activity/stats derivation, the catalog
hook, their i18n keys, dead types, and the admin-gate spec that tested
the wizard flow.
**Enforcement is untouched.** Auto-run on upload, the viewer blocking
overlay, exit-point blocking, file badges, and export-time enforcement
all stay. `usePoliciesEnabled` moves to its own module (core stub /
proprietary / desktop shadow with the SaaS-connection check) since it
still gates mounting the headless `PolicyAutoRunController` from the
rail.
## Why
Policies are configured in the admin portal now
(`src/portal/views/Policies.tsx`). Keeping a second management UI in the
editor rail meant two surfaces to maintain for one feature; the editor
only needs to *enforce*.
## Notes for review
- The rail UI lived in the shared `core` `RightSidebar`, so this removes
it from every build flavour at once; the deleted `PoliciesSidebar`
module existed at the core (stub) / proprietary / desktop alias layers
and all three are gone.
- Every deleted module was verified to have zero remaining importers;
near-misses that stay: `enforcementQueue` (used by export enforcement),
`poll` (test-imported), `usePolicies` (used by auto-run).
- Net −3,900 lines.
## Testing
- `task frontend:check` green: typecheck, ESLint + dpdm, Prettier, all
1,196 tests.
- All build-variant typechecks pass (core / proprietary / saas /
desktop).
## What this PR does
Brings the enterprise procurement flow in line with the new D71 pricing,
tidies up the buyer journey, and fixes a handful of things we found
testing it end to end.
### Pricing
- Priced on the new run-based model (per PDF, per policy), USD only.
Dropped the old currency picker.
- Added the policy posture choice (Essentials / Governed / Regulated)
and show roughly how many policies each covers (~2 / ~4 / ~7).
- The live estimate in the quote builder now matches the real quote the
backend produces.
- Contracts renew each year with a fixed 3% increase. The agreement
shows this plus the first renewal figure, and we save that figure on the
quote so it can't drift later.
### Trial and journey
- Starting a trial now asks for your deployment (Cloud / Self-hosted /
Air-gapped) and team size up front, and that seeds the quote.
- Quote and agreement are now one step: you review the quote and the
agreement together and click "Accept & subscribe" once. No more
accepting a quote and then separately signing.
- "Start a trial" on the home page opens the setup popup right there
instead of sending you off to another page.
- The calculator asks for number of users again and works the volume out
from that.
- Removed the demo-only buttons (reset, simulate payment) and the "Key
documents" button (it wasn't real).
- The licence key now lives behind its own "Licence key" button instead
of being shown inside every popup.
### Air-gapped licence file
- Air-gapped teams can download their licence file (.lic) during the
trial, not only after they pay.
- The popup warns that a trial file needs re-downloading once the
agreement is done, because the file is a snapshot and doesn't refresh
itself the way the online key does.
### Fixes found while testing
- Accepting a quote now upgrades the licence from trial to full straight
away (it wasn't before).
- The "Download invoice" button keeps working after a page refresh (we
now save the invoice PDF link).
- Invoice line items read differently from each other instead of all
showing the same name.
### Notes for reviewers
- The matching backend changes (Stripe quote/accept functions, database
migrations) live in the Stirling-PDF-SaaS repo on `v3`. They ship when
we do the full v3 release.
- All checks are green.
## Summary
A batch of shared **design-system** fixes (Button, SegmentedControl,
Chip, a new CarouselDots) and the consumer/call-site cleanups they
unlock, following the button consolidation (#6787). Also includes
dark-theme token alignment and some portal/auth polish that rides on the
same components.
The shared Button now sizes to its content instead of clipping it, gains
per-axis padding controls, and no longer misbehaves while loading or
disabled; several call sites are then migrated onto the proper component
APIs.
## Shared components (`core/ui`)
### Button
- **Content-driven height.** `--button-height` is now a `min-height`,
not a fixed cap. Single-line buttons still land exactly on the shared
control-height scale (pixel-aligned with `ActionIcon` /
`SegmentedControl`), while taller content — wrapped labels, stacked
title + subtitle rows — grows the button instead of being clipped
mid-glyph. Short content is re-centered with `align-content`,
**without** overriding the root `display`, so a consumer's own layout
(e.g. a full-width list row) isn't disturbed.
- **Padding props.** New `p` / `px` / `py` props
(`none`/`xs`/`sm`/`md`/`lg`/`xl`) override the size-based padding per
axis. Vertical padding is applied through a `--sui-btn-py` CSS variable,
so consumers can also set it from their own class.
- **Loading no longer collapses.** A `fullWidth` button is never treated
as icon-only, so an execute button whose label is momentarily absent
while files hydrate (e.g. `ScopedOperationButton`) keeps its full width
with a centered spinner instead of shrinking to an icon-sized square for
a split second.
- **Disabled in dark mode.** A disabled *primary* button keeps a muted
version of its own accent fill (`opacity: 0.55`) instead of Mantine's
near-black `--mantine-color-disabled`, which blended into dark surfaces
and made the button all but disappear. Loading spinners are excluded so
they stay full-strength.
No breaking API changes — buttons that don't opt in render exactly as
before.
### SegmentedControl
- Fixed a bug where a segment marked `disabled` that also happened to be
the currently-selected value was rendered disabled, leaving the active
segment un-selectable/greyed. A disabled option is now only disabled
when it isn't the current value.
### CarouselDots (new)
- New shared dots indicator component (with Storybook story), used by
the login carousel.
### Chip / theme
- Dark-theme tokens in `theme.css` aligned to the portal's `tokens.css`
so the editor and portal (Processor) dark modes stop drifting (chrome
surfaces lift off the darker canvas); plus a Chip dark-mode styling fix
and a small `mantineTheme` cleanup.
## Consumer / call-site cleanups
- **Compare** tool: the swap control is now a regular shared Button
placed **between** the Original and Edited file cards (the bespoke
full-height vertical swap button and its CSS were removed), and the file
cards fill the full available width.
- **Certificate format**: replaced the inline-styled buttons with clean
two-state (primary / secondary) buttons.
- **ToolPicker**: restored the label selectors that #6787 renamed to the
never-emitted `.sui-btn__label`, and fixed the sidebar-search row
clipping.
- **File sidebar**: "View all files" row fix; `FileSidebarFileItem`
migrated off `display:flex` + `gap` on the Button root (which no longer
reaches the nested label) onto `leftSection` / `rightSection` + a
stacked label.
## Portal / auth polish
- Portal button consolidation and styling across Header, SettingsModal,
Home, Infrastructure, ApiKeyCard, and PopularUseCases.
- **Login**: onboarding text now shows the default starting username /
password; login carousel uses the new CarouselDots; desktop OAuth
styling tweak.
## Verification
- Storybook: button sizes measure exactly on the control-height scale
and match `ActionIcon`; icon-only buttons stay square and centered;
`fullWidth` loading buttons hold full width; disabled dark-mode primary
buttons render as a muted accent rather than grey.
- Single-line buttons are pixel-identical before/after; only buttons
whose content previously overflowed a fixed height render differently
(they now fit rather than clip).
- `task frontend:lint` clean; typecheck shows only the pre-existing
third-party `node_modules` noise also present on `main`.
## Why
The portal Users page worked on self-hosted but **403'd on SaaS**. It
called the proprietary admin endpoints (`/api/v1/user/admin/*`,
`/api/v1/team/*`, `ui-data/admin-settings`), all `hasRole('ADMIN')`.
SaaS users are always `ROLE_USER` (never `ROLE_ADMIN`), so those
endpoints reject them. This is the last SaaS-release blocker for the
portal.
## What
Route the SaaS build's Users page to the **existing**
`SaasTeamController` (invitation-based team management) - no new
backend. Done via a build-time flavor seam, mirroring the existing
`usersCapabilities` pattern.
- **New seam `@app/portal/usersBackend`** (interface in
`portal/api/usersBackend.ts`) with two impls resolved by the `@app/*`
alias:
- `proprietary/portal/usersBackend.ts` re-exports the existing
admin-endpoint functions - **self-hosted behaves exactly as before**.
- `saas/portal/usersBackend.ts` calls `SaasTeamController`
(`/api/v1/team/*`) via `apiClient.local` (already flavor-aware: SaaS
backend + Supabase JWT). Resolves the leader's team from `GET
/api/v1/team/my`, maps `TeamMemberDTO`/`InvitationDTO` onto the portal
`Member`/`PendingInvitation` types.
- **`manageInvitations` capability** (SaaS `true` / self-hosted `false`)
gates a new **Pending invitations** panel (list from `GET
/{teamId}/invitations`, Cancel via `DELETE
/api/v1/team/invitations/{id}`).
- **Remove re-enabled on SaaS** (was gated off): the roster remove
action now works at team scope against `DELETE
/{teamId}/members/{memberId}`, with a flavor-aware label ("Remove from
team" vs "Remove from org") and confirm copy.
- Invite (email) and rename routed through the seam (`POST /invite`,
`POST /{teamId}/rename`); `fetchAuthConfig` on SaaS is static (no
spurious admin-endpoint 403).
- **MSW handlers** (`mocks/handlers/teamSaas.ts`) mirror the controller
so the SaaS Users page is exercisable in mock mode. Registered in
`handlers` but deliberately **not** `embeddedDataHandlers` (would clash
with the editor's own `/api/v1/team/*` routes when portal shares its
origin).
## Constraints honoured
- No new backend endpoints - reuses `SaasTeamController`.
- Self-hosted path unchanged (proprietary impl re-exports the same
functions).
- No SaaS user is ever `ROLE_ADMIN` - `adminRole`/admin-only UI stay
hidden.
## Notes from an adversarial self-review (both fixed in this PR)
- Solo SaaS users' auto-created **personal team** now hides the Rename
control (the backend rejects renaming personal teams with 400) -
`isPersonal` threaded through `Team`/`TeamGroup`.
- Expired-but-still-`PENDING` invitations are filtered in the adapter,
and the expiry label no longer mislabels a just-expired invite as
"Expires today".
## Testing
- `task frontend:typecheck:all` - all 8 flavors pass.
- Portal vitest project: **122 passing** (added SaaS adapter +
shape-mapping tests via MSW, PendingInvitations panel, and
remove/manageInvitations/personal-team gating).
- `task frontend:lint` (ESLint `--max-warnings=0` + dpdm no circular
deps) and prettier clean.
## Open questions
- **Team resolution on SaaS**: I resolve the leader's single manageable
team (prefer a real non-personal team they lead). If a leader owns
multiple real teams, only the primary is shown - matches the "single
team" framing in the spec; flag if multi-team management is wanted.
- **`isSelf` on SaaS** uses the LEADER role (the portal Users page is
leader-only on SaaS, so the leader row is the viewer). Verified there's
no multi-leader creation path today; revisit if that changes.
Draft - not marking ready until reviewed.
# Description of Changes
Empty-state polish across the four processor (portal) list pages, so a
fresh workspace gets clear next steps instead of a row of zeroed-out
stat boxes.
- **Sources / Pipelines** - hide the KPI stat strip when the list is
empty; the empty state now shows an icon plus a primary + secondary CTA
(Connect source / Read the docs; Create a pipeline / Connect a source).
Also closes a gap where a successfully-fetched empty list rendered stat
boxes over a blank page with no empty state at all.
- **Policies** - hide the summary stat strip until at least one policy
is configured; the catalogue cards stay as the "configure a policy"
CTAs.
- **Documents** - hide the filter-pill + search toolbar on an empty
queue; the empty state gains an icon plus Create a pipeline / Connect a
source CTAs.
- **Storybook** - added `Default` + `Empty` stories for all four views;
the preview now loads the real English copy so stories render shipped
text rather than raw i18n keys.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [x] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
- Audited every portal page/component for hardcoded UI strings not
routed through `t()`
- Wired the remaining ones to i18n (~80 new `portal.*` keys in
`en-US/translation.toml`):
- Infrastructure status/label maps (deploy, api-key, cert, key-mode,
attestation, audit, model, region, environment) + API-key permissions
- Procurement "Key documents" modal, editor-admin deploy targets, users
seats label, pipeline output-folder placeholder
- Follows the existing house pattern: label maps store i18n keys,
resolved via `t(MAP[value])` at the render site
- Documents CSV export now reuses the on-screen column keys, and fixes a
latent bug where the exported status leaked the raw key instead of the
translated label
- No UI-copy change: en-US values are identical to the previously
hardcoded strings; other locales fall back to en-US as before
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
## What this does
Reworks the portal's policy setup screens so a policy reads as **its own
settings**
rather than a list of tools you wire together, and rebuilds the forms on
the
shared design system so they match the rest of the portal.
## Why
Setup showed one card per underlying tool (tool name + a toggle), which
exposed
the "a policy is a pipeline of tools" plumbing. A policy should read in
terms of
what it does to a document, not which tools run under the hood.
## Changes
- **Setup reads as policy settings.** The per-tool cards are now a
plain-language
list of what the policy does — "Redact sensitive information", "Strip
active
content", "Apply a watermark", and so on — each with a short description
and a
toggle, with its options appearing inline when turned on.
- **Consistent design system.** The setup and edit forms use the shared
components instead of one-off styling.
- **Simpler setup.** Removed two sections that aren't part of what ships
here:
Document Types (scope-by-type) and Retries.
- **Watermarks are text-only.** A policy watermark is a text stamp, so
the image
option and the type picker are hidden.
- **The editor always shows as a source** (it used to disappear when no
other
sources were connected), and the source tiles now lay out correctly.
- **Clearer upsell copy.** Locked policies read **"Upgrade to
Enterprise"**
instead of "Coming soon".
## Scope
UI only — no backend changes. Keeping a policy's settings in sync
between the
portal and the editor is a known, separate issue and is **not** part of
this PR.
## Testing
Prettier, ESLint, typecheck (proprietary + saas), and the
unused-translation
guard all pass. Setup screens verified in Storybook.
The portal's `SettingsModal` was a parallel, mock-backed settings
implementation. It's replaced by the editor's `AppConfigModal`, mounted
via a new `PortalSettingsHost` that supplies the contexts the portal
doesn't have (app config, flavor-resolved session, preferences, editor
theme). Flavor resolution does the rest: the self-hosted portal gets the
admin sections, the SaaS portal gets the saas shell. The self-hosted
account-link panel rides in through the existing seam as an extra
section.
The shell gains three host props (`urlSync`, `initialSection`,
`extraSections`); editor behaviour is unchanged. Net −1,300 lines.
Manually verified on both flavors against live backends.
The portal no longer uses mock data — it always talks to the real
backend. Mocks still power Storybook and tests.
- Mocks button and all the in-app MSW machinery removed.
- Types the app needs moved out of mock files and into the api layer, so
the app no longer depends on `mocks/` at all.
- One deliberate exception for the onboarding tour (#6926):
`enablePortalDemoData()` fills the views with example data while a tour
runs, with zero cost the rest of the time.
Heads up: views without a real backend endpoint yet now show empty/error
states in dev.
# Description of Changes
Replaces the `.stirling/done` folder and its friends with a ledger in
the DB which tracks which documents have been processed. This should
scale dramatically better since it's just a few bytes being written for
each PDF processed, rather than each PDF being duplicated and held in
the folder forever. It's designed to work with the current folder
source, but also with S3 buckets and other sources in mind - each source
will define its own strategy for ensuring it knows whether the documents
have had policies run on them or not, and they all get written to the
same ledger.
## What this changes
Moves the admin portal's browser route from **`/portal`** to
**`/processor`**, to match the "Processor" product name (the in-app app
switcher already says "processor").
- `PORTAL_BASENAME` `"/portal"` → `"/processor"` — the single source of
truth for all portal paths on the frontend, so every `toPortalPath(...)`
link and redirect follows automatically.
- `adminRouteExtensions` now mounts at `` `${PORTAL_BASENAME}/*` ``
instead of a hardcoded `"/portal/*"`, so the route can't drift from the
constant.
- **Backend:** `RequestUriUtils.isStaticResource` now treats
`/processor` (not `/portal`) as the SPA shell, so a direct navigation or
hard refresh to `/processor` serves the app instead of 404ing. (This is
the only backend URL reference — there's no Spring Security matcher for
it.)
- Updated the two portal route tests and the doc comments that named the
old path.
**Not changed:** the `@portal/*` import alias — that's the code layer /
flavor-layering path, not the URL. Renaming it would be a much larger,
unrelated churn.
The portal isn't publicly launched yet, so there are no existing links
to preserve — no redirect from the old path is included.
## Testing
- `task frontend:typecheck:all`, full test suite (1,211), lint, format —
green
- `./gradlew :common:test --tests "*RequestUriUtilsTest"` — green
## What this changes
Cleaning up the portal home page. Most of what was under the plan card
was mock — it hit endpoints that don't exist on any backend, so on SaaS
it just showed a wall of "—" and "Nothing here yet". I removed the fake
stuff and wired the bits worth keeping to real data.
**Scrapped (all mock, no backend anywhere — self-hosted included):**
- The "No usage yet" usage chart
- The KPI strip (Docs/30d, Pipelines, Agents active, Eval pass rate)
- The "Build a pipeline in seconds" fork wizard (fake build animation,
deploy was a TODO)
- The Sources/Pipelines/Agents product cards
- The "Popular use cases" marketing cards
- Enterprise region health
- The "Try a PDF operation" runner
Deleted their component/api/mock/MSW/story files too, plus the now-dead
i18n keys and CSS.
**Wired to real data (finished, not removed):**
- The plan strip and the sidebar footer now show the **real** 30-day
processed-PDF count from `/api/v1/usage/fleet-stats` (was a mock KPI).
Shows "—" honestly when the backend can't compute it, never a fake
number.
- **Recent activity** now reads the **real audit log** — the same
endpoint the Infrastructure → Audit tab uses.
**Result:** one simple layout for all tiers — plan strip → recent
activity + quick actions → "What runs on your PDFs" (the real policy
summary). Every block is backed by data that actually exists.
Net: **−3,221 / +89 lines**, 17 files removed.
## Testing
typecheck (all variants), full test suite (1211), saas + proprietary
builds, lint, format, storybook build, toml-sort — all green. The
`unusedTranslations` test guarantees no orphaned i18n keys were left
behind.
## Problem
With `RUN_SUBPATH=app`, the app is served under base `/app/`. Vite
serves `index.html` at `/app/` and redirects `/` → `/app/`, but a bare
**`/app`** (no trailing slash) returns **404** — so you had to type
`localhost:5173/app/` to load the app. `/app` should work too.
## Fix
A small dev + preview middleware that **301-redirects `/app` → `/app/`**
(query string preserved), so either form loads the app. Only active when
`RUN_SUBPATH` is set; no-op otherwise.
Also routed the vite `base` through the same slash-stripped `runSubpath`
value the middleware uses, so a stray `RUN_SUBPATH=/app/` can't produce
a doubled `//app//` base.
## Verified (dev server + prod build, `RUN_SUBPATH=app`)
| Request | Before | After |
|---|---|---|
| `GET /app` | 404 | **301 → `/app/`** |
| `GET /app?foo=1` | 404 | **301 → `/app/?foo=1`** (query kept) |
| `GET /app/` | 200 | 200 (unchanged) |
| `GET /` | 302 → `/app/` | 302 → `/app/` (unchanged) |
Production build under the subpath still emits `<base href="/app/">` and
`/app/assets/...`. Lint + format green.
## Overview
Adds **AI document classification** and a **classification-aware Files
sidebar**: uploaded documents are automatically tagged with
document-type labels (Invoice, Contract, Lab report, …), and the sidebar
groups files under editable parent categories so a large library stays
navigable.
> [!IMPORTANT]
> **This feature only runs in the SaaS build.** Classification depends
on the AI engine and team-scoped label storage, so it's gated to SaaS
end-to-end:
> - The sidebar grouping is a `saas/`-layer override of the
`fileSidebarGrouping` seam; every other build (OSS core, self-hosted
proprietary, desktop) gets the null stub and renders the **unchanged
flat, recency-sorted list** — no categories, no "Other", no picker.
> - The classify/labels backend endpoints are gated on
`policies.enabled` (on in SaaS) and live in `app/proprietary`, so
they're absent from pure OSS and dormant in self-hosted unless
explicitly enabled.
> - The Python classifier is reached only via that gated path.
>
> Shared-layer changes that do compile everywhere are inert without the
engine (dormant schema/field additions) or intentional (`GetInfoOnPDF`
surfacing custom metadata).
## What it does
- **Classifier (engine):** reads the first/last two pages of a PDF and
assigns document-type labels from an allowed vocabulary. Labels are
deliberately document-*type* descriptors — no deep-content/PII
detection, since only a page window is read.
- **Team label vocabulary:** ~270 built-in defaults across ~15 families,
seeded per team. Editable by team leaders/admins in the Classification
policy settings (import/export/reset). Team-scoped and shared;
**per-user personal labels are intentionally out of scope** — the
vocabulary is team-level only.
- **Sidebar categories:** files group under parent categories
(Financial, Legal, Medical, …), busiest-first, collapsible, with a
"Recent" group on top and an "Other" group for anything uncategorised.
The category structure (names, icons, membership, custom categories) is
**device-local and user-editable** via a "Customize" picker — the only
per-user personalization; it never changes the team's label vocabulary.
- Classification results are written to PDF metadata
(`StirlingPDFClassification`), read back to keep files in their groups
without re-parsing.
## Architecture
Spans all three layers, mirroring the existing policy/source subsystem
conventions:
- **`frontend/editor`** — sidebar grouping seam + SaaS override,
category manager, labels editor, icon palette, file grouping, tests,
`en-US` i18n.
- **`app/proprietary` + `app/common` + `app/core`** —
`ClassifyLabelController`, team-scoped `ClassificationLabelStore` (Jpa +
in-process impls, same shape as `PolicyStore`/`SourceStore`), metadata
read/write.
- **`engine`** — the document-classifier agent, contracts, routes,
tests.
## Screenshots
**Files sidebar — grouped by category (SaaS)**
### Loading view
<img width="2056" height="1046" alt="Screenshot 2026-07-07 at 5 12
56 PM"
src="https://github.com/user-attachments/assets/1d712da5-50ae-4349-b0cd-e62665c3ec0c"
/>
### Organized in the sidebar
<img width="2056" height="1045" alt="Screenshot 2026-07-07 at 5 14
05 PM"
src="https://github.com/user-attachments/assets/3ea4fe21-da51-4cea-bc3a-18ce040d3d05"
/>
**Customize categories picker**
### Personal settings to change how labels are grouped in an individual
users editor
<img width="2056" height="1044" alt="Screenshot 2026-07-07 at 5 52
42 PM"
src="https://github.com/user-attachments/assets/40be03ce-0f63-4d1e-b58b-cec045d01cb2"
/>
**Classification labels editor (team settings)**
<img width="2056" height="1042" alt="Screenshot 2026-07-07 at 5 53
00 PM"
src="https://github.com/user-attachments/assets/337b0739-15c9-4749-9c6b-22e3b20825b8"
/>
## Testing
- Frontend `task frontend:check` — green (editor + portal tests,
typecheck across all flavors, lint, label-drift guard).
- Backend `task backend:check` (proprietary) and `:saas:test` — green.
- Engine `task engine:check` — green.
# Description of Changes
Disallow SaaS guests from accessing the portal. One day we might want to
make this better so they can go there but then have to sign up before
doing anything useful, but this is the easiest way to disallow it for
now.
## What
Adds a **Storybook preview** for PRs. When a PR changes any story
(`*.stories.{ts,tsx,mdx}`) or the `.storybook` config, this builds the
static Storybook, deploys it to the preview VPS on a PR-scoped port, and
comments with the URL plus an **expandable list of exactly which stories
changed**. Torn down automatically when the PR closes.
New file: `.github/workflows/storybook-preview.yml`. Nothing else is
touched.
## How
- **Detect** (`changes` job) - `dorny/paths-filter` with `list-files:
json` flags Storybook changes and captures the exact changed files.
Skipped on close and for fork PRs (which don't get the VPS secrets).
- **Deploy** (`deploy` job, only when Storybook changed) - builds the
static Storybook (`task frontend:prepare` + `frontend:storybook:build`),
tars it, and serves it from an `nginx:alpine` container on the VPS at
port `PR# + 20000` (offset from the app preview's bare-PR-number port to
avoid collisions). Mirrors `PR-Auto-Deploy-V2.yml`'s VPS SSH pattern and
reuses the same secrets.
- **Comment** - a single bot comment (replaced on each push) with the
preview URL and a `<details>` block listing the changed stories (and any
`.storybook` config changes), e.g.:
> ## 📚 Storybook preview
> 🔗 **Preview:** http://<vps>:26911
> <details><summary>2 stories changed (+1 config
file)</summary>…</details>
- **Cleanup** (`cleanup` job, on PR close) - stops the container,
removes the files, and deletes the comment.
## Validation
- Static Storybook builds locally (`task frontend:storybook:build` →
`frontend/storybook-static`, 151 stories).
- Confirmed `task frontend:prepare` regenerates the un-committed
`material-symbols-icons.json` that stories import, so a fresh CI
checkout builds (added it before the build step).
- Comment-markdown logic unit-checked against a sample changed-files
list.
- YAML validated; action pins match the repo (`setup-node` v6.4.0 / node
22, same `paths-filter`, `setup-bot`, `harden-runner`).
## Note
The VPS deploy mirrors the proven `PR-Auto-Deploy-V2` machinery but
couldn't be exercised end-to-end from a dev box (needs the VPS secrets)
- the first live run on a Storybook-touching PR will confirm the
deploy/serve/cleanup path. Everything build- and comment-side is
validated locally.
# Description of Changes
Portal access control + user management
What this does
- Adds server-side portal access enforcement: a ResourceGrant ACL (owner
→ admin → grant → default policy) gates the portal via
@resourceAccess.canUsePortal(), so access is authoritative on the
backend, not just hidden in the UI.
- New proprietary/access module: ResourceAccessService +
ResourceAccessSecurity, PrincipalResolver (default + SaaS + team-lead
lookup), OwnershipService, ResourceGrantController, and a SecretMasker
for safe config display.
- Exposes an authoritative portalAccess flag on /me (AuthController /
AdminUserSummary); drops the old org-principal shortcut.
- Full portal Users page: team + member management (members table,
invite, move-to-team, new/rename team, reset password, access controls,
confirm modals) wired to real user/team/grant endpoints.
- Per-flavor capabilities seam (usersCapabilities): self-hosted
org-admin gets everything; SaaS is trimmed to what a team leader can do
(no ROLE_ADMIN ever surfaced).
SaaS blockers (separate follow-up PR)
The portal Users page works on self-hosted but 403s on SaaS (it calls
the admin API hasRole('ADMIN'), and SaaS users are ROLE_USER). To ship
the portal on SaaS:
- Add a @app/portal/usersBackend seam and point the SaaS build at the
existing SaasTeamController (no new backend).
- Resolve the leader's team-id on SaaS and map member/invitation shapes
to the portal Member type.
- Add pending-invitation management (list + cancel) - the parity gap vs
the editor.
- Re-enable the roster remove action on SaaS against
SaasTeamController's remove-member endpoint.
<img width="1426" height="464" alt="image"
src="https://github.com/user-attachments/assets/7a441a35-7a57-472f-a8c7-e6d8ae998439"
/>
<img width="492" height="722" alt="image"
src="https://github.com/user-attachments/assets/d4e8a088-b2eb-4326-9e00-7ada6eb72a85"
/>
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---------
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
Builds on the procurement vertical slice (#6861). Adds the **enterprise
licence-key mechanism**: a Keygen licence is generated at trial,
upgraded in place when the committed subscription is created, and is
viewable/downloadable in the portal. Flag-gated — ships with the mock as
default until Keygen env vars are wired.
### What it does
- **Offline / air-gapped licence** is a new **priced add-on** on the
quote ($12k/yr, flat, alongside indemnification / training / QBR).
- **Licence key visible from the trial step** — the portal shows the key
with **Copy**, and (when the offline add-on is bought) a **Download
offline licence (.lic)** button.
- **Real Keygen client, called directly from Java**
(`KeygenEnterpriseLicenseService`), behind `stirling.keygen.enabled`;
`MockEnterpriseLicenseService` stays the default. All creds are env vars
(`STIRLING_KEYGEN_*`) — nothing committed.
- **Provisioning is driven by the Stripe `customer.subscription.created`
event** (source of truth), not a UI action — so a sales-led deal entered
manually in Stripe provisions a licence too. The webhook calls a new
admin `POST /api/v1/procurement/provision`, which upgrades the trial
licence **in place** to the committed annual term, **valid immediately**
(no wait for payment). The deal stays in the payment step so the
outstanding invoice remains visible.
- Offline `.lic` is checked out `base64+ed25519` (signed, unencrypted)
so the self-hosted `KeygenLicenseVerifier` validates it fully offline.
### Not in scope (deliberate, follow-ups)
- Cloud entitlement flip — a **cloud** customer sees/downloads the key
but the running cloud product doesn't unlock yet (self-hosted/air-gap
**are** unlocked by the key). Immediate next PR.
- `invoice.paid → fully-live` / `payment_failed → suspend` webhook
safety-net.
### Companion change (separate repo)
- The Stripe-webhook wiring that calls `/provision` lives in the
**Stirling-PDF-SaaS** repo (committed on `v3`, not part of this PR):
`stripe-webhook` routes enterprise-committed `subscription.created` →
`provisionProcurement()` → the Java admin endpoint.
### Prod setup required
- Create the committed-enterprise **Keygen policy with
`scheme=ed25519`** under the existing account, set
`STIRLING_KEYGEN_ENABLED=true` + account/token/policy env vars.
### Verified
saas `:saas:test` (procurement) · portal typecheck / eslint / prettier ·
82 portal tests · `deno check` on the webhook handler.
### Review follow-ups (PR review, tracked)
Low-hardening fixes applied in `85369633ed`: keep Keygen response bodies
out of thrown/logged messages; fail-fast at startup when the flag is on
but creds are missing; gate the offline `.lic` on the *accepted* quote
(not the latest draft).
Deliberately deferred, tracked here:
- **Pre-flag verification.** Before `stirling.keygen.enabled=true`,
confirm the id-vs-key addressing against live Keygen. (The shipping
self-hosted edge addresses licences by URL-safe key in the path and
Keygen docs allow it, so the client mirrors that — but confirm
empirically with the real committed-enterprise policy.)
- **No auto-revoke on non-payment.** Provision issues an
immediately-valid annual licence before payment settles; `invoice.paid →
live` and `payment_failed → suspend` are out of scope here. Note the
offline `.lic`, once downloaded, verifies offline for the full term and
**can't be revoked** — so the real mitigation for the offline case is a
shorter bridge term until `invoice.paid`, not just wiring `suspend`.
Enterprise is sales-led/ADMIN-gated, so this is a collections concern,
not mass abuse.
## What this changes
Getting the portal ready to show the world on SaaS. A few things bundled
in here:
**Developer docs tab** — now opens https://docs.stirlingpdf.com/ in a
new tab instead of taking you to an empty page (we haven't built the
in-app docs page yet).
**Hid the bits that aren't finished yet — SaaS only:**
- Took the Agent Builder button off the Sources page.
- Removed the Components page.
- Infrastructure: the tabs that aren't ready (Deployments, Security,
Models, Storage) are greyed out as "coming soon". API keys and Audit
stay live. Also dropped the "Manage editor deployment" button.
- Removed the floating AI assistant blob.
**Fixed the SaaS api client.** Before this, only the usage/billing page
actually reached the backend — everything else (sources, users,
policies, etc.) was going to the vite dev server with the wrong login,
so it never worked. Now every portal call goes to the one SaaS backend
using the Supabase login.
Self-hosted is left exactly as it was — all the SaaS hides go through
the saas override layer, so self-hosted still shows everything.
## Testing
typecheck (all variants), full test suite, both builds, lint + format —
all green.
## What
The procurement flow's **Schedule a call** action (deal-status hero →
side modal) was a mock: a fake "SE" avatar and four hardcoded time-slot
buttons that just closed the dialog. This wires it up to the real
Calendly booking widget the admin provided.
## How
- **New `CalendlyInline` component**
(`portal/components/procurement/CalendlyInline.tsx`)
- Lazily loads `assets.calendly.com/assets/external/widget.js` via the
existing `@app/utils/scriptLoader` — only when the modal actually opens,
deduped across reopens.
- Calls `Calendly.initInlineWidget()` explicitly so it rebuilds on
reopen / theme change.
- Colours track the portal's light/dark theme (`useTheme`) via
Calendly's `background_color` / `text_color` / `primary_color` params,
mapped to the portal design tokens (surface / text-1 / primary), plus
`hide_event_type_details=1`.
- Graceful fallback to an "open in a new tab" link if the script fails
to load.
- Base URL overridable via `VITE_CALENDLY_URL` (defaults to the
group-discussion link).
- **`ScheduleCallModal`** now renders `<CalendlyInline />` instead of
the mock; copy moved into i18n (`portal.procurement.schedule.*`).
- `SideModal` gains a `wide` variant so the embed has room; removed the
now-dead `.portal-se*` / `.portal-slots*` CSS and `SLOTS` constant.
## Notes / follow-ups
- No app-level CSP blocks `calendly.com`, so the embed loads without
config changes.
- Verified with the portal typecheck (`tsc -p src/portal/tsconfig.json`)
and ESLint on the changed files; only pre-existing Storybook/msw dev-dep
type errors remain.
<img width="1160" height="642" alt="image"
src="https://github.com/user-attachments/assets/d9b5d92d-ea7e-4862-9f35-a71f65392a2c"
/>
<img width="3744" height="1990" alt="image"
src="https://github.com/user-attachments/assets/0b8002c6-dd3e-41e6-8e8b-6faf6090314c"
/>
<img width="2620" height="1928" alt="image"
src="https://github.com/user-attachments/assets/781b7a60-7065-4a7a-b9f7-1cdb0dece7b3"
/>
## What
Replaces the **mocked** "Free PDF Editors" fleet card on the portal
Usage page with live figures. Cost stays a literal `$0`; any figure that
can't be computed renders **N/A** (never a misleading 0).
| Metric | Self-hosted source |
|---|---|
| **Editors deployed** | total users (`UserRepository.count()`) |
| **Active this month** | distinct `source=WEB` principals active in 30d
(excl. `UI_DATA` polling), clamped ≤ deployed |
| **PDFs edited** | cumulative `PDF_PROCESS` + `FILE_OPERATION` audit
events that are **free UI runs** |
## Why the counting approach
"Free operations = UI tool runs." Two dead ends first:
- **Billing/PAYG is the wrong source** — it *deliberately discards* free
ops (classified `BYPASSED`, no DB row); its tables only hold billable
(API/AI/automation).
- **Raw audit is also wrong** — a tool controller emits `PDF_PROCESS`
for UI **and** API/AI/automation calls, and billable traffic exists on
every tier.
So the count is **audit filtered to free UI runs**. Audit events gain a
`source` column, stamped from the always-on signal
`BillingCategoryClassifier.classify(...) == BYPASSED` (not API-key auth,
no `X-Stirling-Automation` header, not `/api/v1/ai/`) — zero
billing-module coupling. Captured on the request thread
(`AuditService.captureCurrentSource`), carried via MDC in
`ControllerAuditAspect` (same propagation as `requestId`), persisted by
`CustomAuditEventRepository`. The count filters `source = 'WEB'`.
## Endpoint
`GET /api/v1/usage/fleet-stats` — admin-gated, EE-only. Returns `null`
per field when EE auditing is off (→ N/A).
## Frontend
- New `portal/api/fleetStats.ts` → `apiClient.local` (this instance's
backend).
- `FreePdfEditorsCard` rewired to `useAsync(fetchFleetStats)`; preview
badge removed, `null`→"N/A", loading→"—".
## Tests
`:proprietary:build` green — `FleetUsageControllerTest` (4) and
`CustomAuditEventRepositoryTest` (+2 for source-from-MDC) pass; spotless
clean.
## Notes / follow-ups
- `deployed` currently counts all users incl. disabled — refine to
enabled-only later.
- **SaaS** (team-scoped endpoint + a `fleetStats.ts` override) is
deferred to a follow-up riding the portal-SaaS layering PR #6900.
- Depends on EE auditing running at `AuditLevel ≥ STANDARD` for the
audit-derived figures; otherwise they show N/A.
## What
While a per-file policy run is in flight, the editor now blocks every
way the file can leave the app, and shows why:
- **Viewer** — a blocking overlay with live progress ("Enforcing
policy…"). Dismissible: collapses to a corner badge (top right, tinted
with the policy's accent) so the file stays readable while the run
finishes.
- **Workbench bar** — Print / Download / Save As / Share are disabled
with an explanatory tooltip and progress bar. The Ctrl+P shortcut and
the form-fill bar's "Download PDF" button are covered too.
- **File lists** — the file sidebar, file-editor thumbnails, and files
page show a spinning shield badge on the affected file, and thumbnail
hover actions (download / upload to server) are blocked with the same
tooltip.
Once a run settles, everything unblocks — including FAILED and CANCELLED
runs. A failed check surfaces through the run's activity feed; it never
locks the user out of their file.
## Why
Upload-triggered policies exist so the enforced output is what leaves
the app. Before this, a file could be printed, downloaded, or shared
while its policy run was still processing.
## Also in here
- **One shared `PolicyBadges` component** — the sidebar, thumbnails, and
files page each had their own copy of the badge markup/CSS and had
drifted (different sizes, tints, missing spinner and glow on the files
page, hardcoded English tooltips). All badge surfaces now render the
same component: accent-tinted shield, spinner while enforcing, one-off
glow when recent, i18n'd tooltips.
- **Cascade fix:** outputs imported from reconciled
(server-rediscovered) runs are now tagged `derivedFromTool`, stopping an
auto-run → import → auto-run loop that produced ever-growing
`_sanitized_sanitized…` filename chains on fresh devices.
- **Core stub for `policyRunStore`** so the core build compiles —
`WorkbenchBar` and `ViewerShareButton` resolve `usePolicyRuns` via
`@app/*`.
## Testing
- `task frontend:check` green: proprietary typecheck, ESLint + dpdm,
Prettier, 915+ editor + 81 portal unit tests.
- `typecheck:core` / `saas` / `desktop` variants all pass.
- Enforcement flow exercised manually against a live backend with an
upload-triggered policy (overlay + progress during the run, dismiss to
corner badge, unblock on completion).
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
## What this PR does
Redesigns the portal home to match the marketing demo, across all tiers.
- Swapped the old rotating welcome carousel for a static **welcome
hero** on the free tier
- Subscribed (Processor) + enterprise get a **deployed-editor hero** —
shows the live instance (host, version, active users) with an *Open in
browser* button, pulled from the real editor-deployment API (same data
the Editor admin page uses)
- Added a time-of-day **greeting** on the paid tiers
- Rebuilt the **"Finish setting up" checklist** so it's real: the counts
and tick-offs come from the actual policies + sources (a step is done
when there's at least one), not hardcoded numbers
- *Download the PDF Editor* → `https://stirling.com/download`; the other
steps deep-link to Policies / Sources
- **Procurement is now a bolt-on to any tier** — if a deal's in flight
the deal-status hero drops into the hero's footer, otherwise you get the
setup checklist
- All new copy is translated (en-US) and it reuses the shared UI kit,
icons and design tokens
## Tidy-ups / fixes found along the way
- The subscribed hero was shadowing the real `/v1/editor/deployment`
endpoint (broke the Editor admin page) — now reuses it
- Renamed the hero's CSS namespace to `.portal-welcome` so it stops
clashing with the procurement hero's `.portal-hero`
- Refactored the merged procurement component into a shared
`useProcurement` hook + banner + flow, so the deal hero can live inside
the tier hero — `/procurement` route unchanged
## Screenshots
<img width="1258" height="1338" alt="pr-free"
src="https://github.com/user-attachments/assets/9bb7db44-5f8e-4388-857a-7f113c2d7d82"
/>
<img width="1258" height="862" alt="pr-enterprise"
src="https://github.com/user-attachments/assets/f1f36faa-1467-404e-9036-dab12e3d0b54"
/>
<img width="1258" height="944" alt="pr-subscribed"
src="https://github.com/user-attachments/assets/775f1228-f182-47b5-82ff-7ac6d5932bd9"
/>
---
## Checklist
### General
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### UI Changes
- [x] Screenshots demonstrating the UI changes are attached
### Testing
- [x] Portal typecheck, ESLint and Prettier pass on the changed files
- [x] Verified all states in Storybook and ran the app locally via `task
dev:portal`
## What
Lets the admin portal ("Stirling Processor") ship **inside the JAR**,
gated by a build flag. On `main` the portal already exists as a lazy
`/portal/*` route in the editor but isn't included in production builds
and isn't reachable in a login-enabled server. This PR makes it a
**flag-gated, directly-navigable** part of the editor bundle, and wires
it into the PR preview deployment so it can be tried live.
It keeps the exact architecture `main` uses (portal = a lazy chunk of
the editor, not a separate app), so it inherits all the editor's global
providers/styles and there's no second build to maintain.
## How
**Frontend - gate the existing lazy route**
([`adminRouteExtensions.tsx`](frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx))
```ts
const includePortal = import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
const PortalApp = includePortal ? lazy(() => import("@portal/PortalApp")) : null;
```
Vite bakes the env to a literal, so when off the dynamic import is
**tree-shaken out entirely** (no `PortalApp` chunk emitted). Always on
in dev. `VITE_INCLUDE_PORTAL` is typed in `vite-env.d.ts` and declared
(default `false`) in `editor/.env`.
**Gradle** ([`build.gradle`](app/core/build.gradle)) -
`-PbuildWithPortal=true` forces `buildWithFrontend=true` and sets
`VITE_INCLUDE_PORTAL=true` on the editor build. Process-env takes
priority over `.env`, so the flag wins for JAR builds while plain `vite
build` / Cloudflare Pages default to off.
**Backend - make the shell reachable**
([`RequestUriUtils`](app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java))
- permits `/portal` + `/portal/*` as public SPA routes. The editor keeps
its JWT in localStorage (not a cookie), so a direct nav/refresh to
`/portal` isn't authenticated at the server and would otherwise redirect
to `/login` and never load. Serving the shell pre-auth (like the editor
root already is) lets it load; **access control is unchanged** - the
portal has its own auth gate + `RequirePortalAccess`, and its data APIs
stay protected.
**Docker** - the embedded Dockerfiles take `ARG BUILD_PORTAL=false` →
`-PbuildWithPortal=${BUILD_PORTAL}`. Default off, so official
`push-docker` images do **not** bundle the portal.
**CI - scoped to the PR preview deploy only**
([`PR-Auto-Deploy-V2.yml`](.github/workflows/PR-Auto-Deploy-V2.yml)) -
the one job that builds the JAR and comments owns all portal wiring:
passes `BUILD_PORTAL=true`, enables the portal's backend features
(`POLICIES_ENABLED`, `STIRLING_BILLING_ACCOUNT_LINK_ENABLED`), and adds
an "Admin portal included" line (linking `/portal` via the direct IP) to
the deployment comment. `push-docker`, `build.yml`, `test-build-docker`,
and the shared paths-filter are untouched.
## Validation (real, in the JAR)
Built and booted the JAR with `-PbuildWithPortal=true` and login
enabled:
- `/portal` and `/portal/users` load via direct nav and render **fully
themed** (dark surfaces, gradients, filled buttons).
- Editor-only build (`-PbuildWithFrontend=true`, no portal flag) →
editor ships, **0 portal chunks** (tree-shaken).
- `-PbuildWithPortal=true` → `PortalApp` chunk present.
Green: `frontend:check:all` (typecheck all variants, lint, format,
build, tests incl. the `VITE_*` env guard), backend compile,
`RequestUriUtilsTest`, spotless.
## Notes
- **Official images never bundle the portal** (Dockerfile default off);
only the PR preview does. Flip `BUILD_PORTAL` / `-PbuildWithPortal` to
include it elsewhere.
- The `/portal` shell being public is the one deviation from `main`, and
it's required for the route to be reachable at all in a login-enabled
server; data access is still fully gated.
# Description of Changes
The Change Metadata tool was missed from the bidirectional mappings
added in #6867. This PR adds it to the list of supported tools.
Adds the portal's top-left app switcher to the editor sidebar, so you
can jump between the two apps from either side.
- Both sidebars render the same shared `AppSwitch` component (sui
dropdown).
- Switching is client-side (no page reload); portal→editor no longer
breaks when `VITE_EDITOR_URL` is unset.
- Editor side is admin-gated (`portalAccess`) and only exists in flavors
that ship the portal — core/desktop stub it out, same seam pattern as
the portal routes.
- Fixes en route: dropdown menu stacking in the editor sidebar, sui
dropdown item button reset, stale `dist-portal` ESLint ignore.
## What
Ground-work so the admin portal can build for the **SaaS flavor**
alongside self-hosted, using the editor's existing **build-time
file-override** mechanism — no runtime flavor flags. This PR
demonstrates SaaS end-to-end (single-login + Usage page loading via the
inherited Supabase session) without changing self-hosted behaviour.
This is intentionally scoped as foundations, not the whole feature.
## How
**Build hook**
- `tsconfig.saas.vite.json`: `@portal/*` now cascades
(`src/saas/portal/*` → `src/portal/*`); added `@portalCore/*` for the
explicit base path.
- New `src/portal-saas/` layer (sibling of `src/portal`) holds SaaS-only
overrides, so `@app/*` resolves only editor layers and `@portal/*` only
portal layers. Self-hosted builds never import it (tree-shaken).
**Seams live in the api-client + composition layers — never in page
components**
- `saasApiBase` — base URL source (self-hosted: `VITE_SAAS_API_URL`;
SaaS reuses the single `VITE_API_BASE_URL` backend).
- `portalSaasSession` — flavor-agnostic token from the shared Supabase
client.
- `PortalAuthBoundary` — self-hosted: Spring `AuthProvider` +
`AuthGate`; SaaS: Supabase `AuthProvider` + session-only gate (inherits
the SaaS session, so no second login).
**Link concept pulled out of the Usage page (one clean cut)**
- `Usage` is now a link-free wallet renderer with generic
`onWalletLoaded` / `onReauth` callbacks; it always loads the wallet and
has zero flavor awareness.
- `PortalBillingGate` is the single flavor seam: self-hosted gates on
link (prompt when unlinked; wires the callbacks onto link/tier +
re-auth), SaaS is a passthrough that renders `Usage` directly.
- Keeps the flavor switch out of the page entirely (no per-flavor code
in `Usage`).
## Testing
Green locally and in CI (CI runs the umbrella `task
frontend:check:all`):
- `task frontend:typecheck:all` — clean across all 7 build variants
- `task frontend:test` — vitest suites pass (portal + saas cover this
change; 146 tests)
- `task frontend:build:saas` and `task frontend:build:proprietary` —
both green
- `task frontend:lint` and `task frontend:format:check` — clean
## Also in this PR (added after the initial foundations)
- **Tier from wallet + full link-layer excision on SaaS.** `TierContext`
no longer reads `LinkContext` (via a `usePlanTier` seam: self-hosted
from link state, SaaS from `wallet.status`), and the SaaS
`PortalProviders` drops `LinkProvider` / `AccountLinkProvider` /
`LinkModalHost` entirely — the link machinery is *absent* from the SaaS
bundle, not mounted-but-inert.
## Deliberately out of scope (follow-ups)
- SaaS-only read-only "connected servers" settings view.
- Shared wallet source so the SaaS tier badge and the Usage page don't
both fetch `/payg/wallet` (harmless double-fetch today).
# Description of Changes
Followup work requested in review of #6867. Currently, there is nothing
enforcing that the endpoint chosen in the tool config is the correct
mapping for `toApiParams`, so theoretically it's possible for a tool to
be set up to call an endpoint with the wrong API params for it. There's
also nothing currently enforcing that `toApiParams` and `fromApiParams`
are compatible with each other (using the same types). This PR changes
it so that instead of creating the config object directly, tools create
it via a generic function, which enforces that all of the relevant
mappings are using compatible types.
## Summary
Converts SUI's existing Select and Slider to Mantine-backed
implementations, and adds three new Mantine-backed SUI components:
MultiSelect, NumberInput, ColorInput.
All five components follow the same contract as the rest of the SUI
catalogue:
- Imported from `@app/ui` — Mantine is an implementation detail
- Explicit prop allowlists: appearance props (color, variant, radius,
classNames, styles) are locked internally to SUI tokens; only
behavioural props are exposed
- Labels and error messages stripped from the interface — callers use
`<FormField>` for both. The components take an `invalid` flag that
applies error styling only; Mantine never renders its own message
element, so the text can't appear twice
- `aria-label` / `aria-invalid` / `aria-describedby` and `FormField`'s
injected `required` are forwarded, so the injected accessibility wiring
reaches the underlying input. Mantine drops some of this wiring
internally (`aria-describedby` on inputs, all aria props on Slider's
thumb, `required` on MultiSelect's field), so `ariaForwarding.ts`
re-applies it to the DOM node and `ariaForwarding.test.tsx` locks the
contract in
- Typed escape hatches (`comboboxProps`, `popoverProps`, `rightSection`)
documented for the z-index-in-modal use case
**Select** — rebuilt from native `<select>` to Mantine combobox. Gains
searchable/clearable. `onChange` now receives the value string directly,
not a DOM event — callers updated.
**Slider** — rebuilt from native `<input type="range">` to Mantine
Slider. Gains accessible keyboard navigation and `marks` support.
**MultiSelect, NumberInput, ColorInput** — new components. The behaviour
(multi-select combobox, number stepper, colour picker) is too complex to
hand-build correctly; Mantine provides it for free behind a locked SUI
interface.
Also wires `suiCssVariablesResolver` into the Storybook
`MantineProvider` so Mantine combobox/popover dropdowns follow the SUI
palette in dark mode, and adds `"neutral"` accent variant to
`IconBadge`.
## Usage
```tsx
import { Select, Slider, MultiSelect, NumberInput, ColorInput } from "@app/ui";
import { FormField } from "@app/ui/FormField";
// Select — onChange receives string | null, not a DOM event
<FormField label="Retention">
<Select options={options} value={value} onChange={setValue} searchable clearable />
</FormField>
// Slider — same external API as before, now with marks support
<FormField label="Confidence">
<Slider value={v} onChange={setV} min={0} max={1} marks={[{ value: 0.5, label: "0.5" }]} />
</FormField>
// New components
<FormField label="PII types">
<MultiSelect data={options} value={value} onChange={setValue} searchable clearable />
</FormField>
<FormField label="Opacity">
<NumberInput value={opacity} onChange={setOpacity} min={0} max={100} suffix="%" />
</FormField>
<FormField label="Watermark colour">
<ColorInput value={color} onChange={setColor} />
</FormField>
```
## Notes
- **Select `onChange` is a breaking change** — receives `string | null`
instead of a DOM event. All existing callers in this repo are updated.
- The policy PR (`main` WIP) depends on this merging first.
- Stories for all five components are under **Primitives / Forms** in
Storybook.
## What this is
The enterprise procurement flow, built into the customer portal as a
**vertical slice** — one linked account can go the whole way from trial
to a paid, committed subscription, using real Stripe under the hood.
Procurement no longer lives as a nav tab. It sits on **Home** as a
deal-status hero and expands into a full-screen takeover, matching the
marketing prototype.
## The journey (what a customer does)
- **Start a trial** in one click — the deadline and next steps show on
the Home hero (no card, mock licence).
- **Build a quote** — a short form (volume → commitment & service →
details); pricing is computed server-side.
- **Generate the quote** — this creates a real **Stripe Quote** with a
proper **PDF** you can download and share, and it becomes a milestone
you can come back to.
- **Review & sign the agreement** — one combined agreement (MSA + Order
Form + EULA + DPA) with an itemised order form and an "I agree" (no
e-signature yet).
- **Accept** — Stripe creates the committed annual **subscription** and
its **first invoice**, which you can **pay or download right in the
app** (no waiting on email).
- Edit a quote any time — it remembers your inputs and company name; the
old Stripe quote is cancelled so it can't still be accepted.
- The hero also has quick actions: **key documents**, **invite
teammates**, **schedule a call**, and a **trial countdown** you can
extend.
## Architecture — Supabase vs Java
Pricing, deal/quote state, and the commercial journey live in **Java
(`:saas`)**. Everything that touches **Stripe** (writes + PDFs) lives in
**Supabase edge functions** — Java has no Stripe SDK and only reads
Stripe via the sync mirror. The portal calls both.
```mermaid
flowchart LR
Portal["Portal (React · editor/src/portal)"]
subgraph JAVA["Java :saas backend (trusted cloud)"]
Pricing["Pricing engine (volume bands, SLA, term, add-ons)"]
Deal["Deal + quote state, journey, snapshot"]
Trial["Trial (mock Keygen licence seam)"]
Authz["Auth: team resolve + leader gating"]
Mirror["Reads Stripe via sync mirror (stripe.* tables)"]
end
subgraph SUPA["Supabase edge functions (own Stripe)"]
Issue["issue-procurement-quote → create + finalize Stripe Quote"]
Accept["accept-procurement-quote → subscription + finalize invoice"]
Pdf["get-procurement-quote-pdf → proxy the quote PDF"]
RPC["SECURITY DEFINER RPCs (read/write stirling_pdf, enforce team/leader)"]
end
Stripe["Stripe (Quotes · Subscription · Invoice)"]
Portal -->|"price / build / trial / agreement / snapshot"| JAVA
Portal -->|"issue / accept / download PDF"| SUPA
SUPA --> Stripe
SUPA --- RPC
Mirror -. reads .-> Stripe
```
| Top-level feature | Handled in |
|---|---|
| Quote pricing (bands, SLA, term, add-ons) | **Java** |
| Deal + quote state, journey, snapshot | **Java** |
| Trial start / extend (mock licence) | **Java** |
| AuthN/Z (team resolve, leader gating) | **Java** |
| Issue quote → Stripe Quote + PDF | **Supabase edge fn** |
| Accept → subscription + invoice | **Supabase edge fn** |
| Quote PDF download | **Supabase edge fn** |
| Reading Stripe state | **Java** (sync mirror) |
| `stirling_pdf` writes from edge | **SECURITY DEFINER RPCs**
(service-role only) |
## Screenshots
<!-- Drag each PNG into the box below it before publishing. -->
**Home deal-status hero (trial)**
<img width="1920" height="1009" alt="hero-check"
src="https://github.com/user-attachments/assets/7ae21831-9578-4f4d-b91a-d3ab2cb171dc"
/>
**Issued quote milestone (with breakdown)**
<img width="1920" height="1009" alt="milestone-breakdown"
src="https://github.com/user-attachments/assets/3a463c67-3c6e-4f93-a1cc-59b250d54cc9"
/>
**Agreement step (itemised order form)**
<img width="1920" height="1009" alt="agreement-itemised"
src="https://github.com/user-attachments/assets/1a4efa16-d0a3-41d0-849c-a125b1492a34"
/>
**Key documents**
<img width="1920" height="1009" alt="keydocs-modal"
src="https://github.com/user-attachments/assets/5672d1d2-99d9-499e-9edf-d485df378e7f"
/>
**Subscription created (pay / download invoice)**
<img width="1920" height="1009" alt="accepted-check"
src="https://github.com/user-attachments/assets/3a8cbe9f-e72c-4389-b365-0c1749108b6f"
/>
## Mocked for now (scaffolding, not wired to real backends)
- **Key documents** ledger — static demo list.
- **Schedule a call** — static solutions-engineer + time slots.
- **Invite teammates** — routes to the existing Users view.
- **Simulate payment received** / **Reset procurement** — demo controls,
**off by default** in prod (flag-gated), 404 unless enabled.
## Deferred (separate follow-up PRs)
- **Real `invoice.paid` webhook** → go-live (today a demo button stands
in).
- **Keygen licence controller** — real licensing (currently a mock
seam).
- **Document sharing**.
- **Stirling admin / Deal Desk** view.
- **Minimum ACV floor** — pending a number from marketing (server-side
enforcement is a one-liner once decided).
## How to test
- **Frontend, no backend:** runs against MSW mocks (Storybook + mocks-on
dev) — the whole journey is clickable.
- **Real end-to-end:** apply the migrations (Flyway `V27–V29` / Supabase
`20260701–20260707`), deploy the three edge functions, ensure
**Invoicing Plus** is enabled on Stripe, and set
`STIRLING_PROCUREMENT_DEMO_CONTROLS_ENABLED=true` if you want the demo
controls.
- Paired SaaS PR: **Stirling-Tools/Stirling-PDF-SaaS#318**.
## Notes for reviewers
- Pricing is server-authoritative (client sends config, never amounts).
- Security review done: edge functions validate the JWT and enforce
**team membership** (and **leader** for issue/accept) via the RPC; demo
endpoints are flag-gated off. Only open item is the ACV floor (policy).
# Description of Changes
Modernises the codebase and gets rid of warnings where Node complains
that it doesn't know what type of JS it's supposed to be reading on
`.js` files. We might as well update everything to just use correct JS
syntax instead of keeping with some files having Node-specific imports.
# Description of Changes
Please provide a summary of the changes, including:
- What was changed
- Moved PostHog startup out of `index.tsx` and into a config-aware
initializer inside `AppProviders`.
- Added a dedicated `usePosthogTracking` hook that only initializes
PostHog when `enableAnalytics` is explicitly `true` and `enablePosthog`
is not disabled.
- Kept cookie-consent handling in the same flow so consent is applied
only after PostHog is actually initialized.
- Removed the unconditional `PostHogProvider` and `posthog.init(...)`
bootstrap from the app entrypoint.
- Added targeted frontend tests covering analytics-disabled and
analytics-enabled startup behavior.
- Why the change was made
- The previous frontend bootstrap initialized PostHog before app config
was loaded, so disabling analytics in the UI or via environment settings
did not prevent PostHog network activity.
- This change makes analytics behavior follow the server-provided config
instead of always connecting on page load.
Closes#6358
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
In SaaS mode the self-hosted "Update Available" notification could still
appear and the update-check code (external call to
`supabase.stirling.com/functions/v1/updates`) still ran, even though the
cloud owns app versioning. The web `UpdateStartupPopup` was already
SaaS-gated via a null override, but two other paths were not:
- **Desktop app in SaaS connection mode** - `useDesktopUpdatePopup()`
ran its startup check and rendered the `UpdateModal` regardless of
connection mode, so a self-hosted update popup appeared while connected
to SaaS.
- **Settings → General** - the core `GeneralSection` fired
`checkForUpdate()` on mount unconditionally, even when the update
section was hidden (as SaaS does), so the external call still ran.
**What changed**
- `useDesktopUpdatePopup.ts` - the startup timer now bails out
immediately when `connectionModeService.getCurrentMode() === "saas"`. No
mode lookup, no external fetch, no modal.
- `core/GeneralSection.tsx` - the mount `checkForUpdate()` now returns
early when `hideUpdateSection` is set, so hiding the section (web SaaS,
managed-disabled desktop) also stops the external call.
- `desktop/GeneralSection.tsx` - passes `hideUpdateSection` when
`useSaaSMode()` is true, which (via the above) suppresses the settings
check in desktop-SaaS too.
**Why** - in SaaS the update check should never be called and no update
notification should be shown; the cloud handles versioning.
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
A few changes to improve things in the engine:
- Changed the PDF to Markdown code to be a real tool in Java, to remove
the need for the `pdf_ingest` code, which looked a bit like an agent but
wasn't behaving as an agent. It's now just covered automatically by the
edit agent.
- Noticed that 0-parameter-endpoints were previously being ignored by
the `tool_models` generator, so some tools which require no params were
being mistakenly excluded.
- Removed tools which currently never succeed like Add Stamp, Cert Sign,
and Overlay, because they require the supporting files to be sent in a
different location in the API call, which we don't currently do.
Ideally, we'd add proper support for this, but we're better off now
removing support for these tools rather than just have them crash. We
can re-add these tools in a future PR properly.
# Description of Changes
Fix https://github.com/Stirling-Tools/Stirling-PDF-SaaS/issues/281. Add
generated backend API mappings to the frontend code, and the logic to
convert from a backend API to frontend parameters objects.
Previously, it was impossible to tell if changing the backend API would
require a change to the frontend to support it because the frontend had
no static type information about the backend API. This PR adds
autogenerated tool API types to the frontend (in `toolApiTypes.ts`) and
adds explicit typed mappings between the frontend parameter types and
the backend API types, so theoretically the type checker should be able
to catch issues when changing one puts us in an invalid state with the
other. During development, it pointed out several inconsistencies that
we have between the frontend and backend types, some of which were
genuine bugs, and others were only happening to work because the backend
is more permissive than its API claims to be.
This also unlocks the ability for us to render the frontend settings on
saved backend API structures, which we've previously had to avoid doing
because we had no reverse mapping.
# Description of Changes
Fixes intermittently failing tests (and replaces one that wasn't useful
in its previous state) and also adds a CI check to warn if there are any
Playwright tests which failed on their first go and succeeded on
retries, to hopefully help find intermittently failing tests more
quickly and avoid them being merged in the first place.
## Account-link Phase 2: metering + daily usage sync
Phase 1 (already on main) let a self-hosted instance link a SaaS account
and blocked billable work when it was over its limit. It blocked, but it
never actually charged anything. This PR adds the metering + billing
half.
**It's off by default.** Everything sits behind
`stirling.billing.account-link.metering.enabled`, on top of the existing
`stirling.billing.account-link.enabled` master flag. Both have to be on
for any of it to run, so it can't touch production. The billing model
isn't going live yet — this is a dark merge.
### How it works
1. The instance classifies each billable request (API / AI / Automation
— manual PDF editing stays free) and counts it locally into a per-period
counter.
2. Once a day it reports its running totals to SaaS.
3. SaaS bills only the delta since the last report, reusing the existing
charge path (free grant + wallet ledger + Stripe meter). No new money
logic.
4. The portal shows current usage (synced spend plus anything not
reported yet), and when you subscribe it now reflects the new plan right
away instead of waiting for a cache to expire.
### What's worth a reviewer's eyes
- **It can't double-charge.** SaaS only ever bills the delta, refuses a
counter that goes backwards, dedups repeat/late reports on a monotonic
sequence number, and takes a row lock so a duplicate delivery can't
charge twice.
- **The cap is enforced at the instance gate**, not in the charge path
(same as the in-cloud flow). A $0 cap blocks all metered work.
- Page counts use jpdfium so the instance and the cloud agree on the
number that gets billed.
- New SaaS surface: `POST /api/v1/instance/sync`, migrations V25
(`payg_instance_usage`) and V26 (allow the `LINKED_INSTANCE` job
source), and a small `POST /api/v1/payg/wallet/refresh` the portal calls
after checkout.
### Companion PR
Stirling-PDF-SaaS #314 (on `v3`): the checkout edge function so the
embedded Stripe flow finishes in-page instead of reloading, plus a
`Deno.serve` migration so the edge functions actually deploy.
### Testing
Java unit tests (proprietary + saas), portal vitest, and the SaaS
edge-function tests all pass. Branch is merged up to date with main.
### Not done yet (doesn't block this merge — only matters once both
flags are on)
- V25 Supabase twin in the SaaS repo.
- Same in-page checkout fix for the editor's upgrade modal.
- A flags-on smoke test in staging (one real sync round-trip).
---------
Co-authored-by: James Brunton <james@stirlingpdf.com>
# Description of Changes
Playwright tests currently fail in Firefox and Safari because of
inconsistent behaviour across the browsers. This is causing the
nightlies to fail every night. This PR fixes the test behaviour to work
consistently across browsers (most of the issues were to do with the
tests opening the file picker, which was being automatically suppressed
in Chromium, but not the other browsers).
# Description of Changes
This change fixes the artifact upload path used by the Playwright E2E
workflows after the frontend directory structure was updated.
### What was changed
- Updated the Playwright report artifact path from:
- `frontend/editor/playwright-report/`
- to `frontend/playwright-report/`
- Applied the fix to:
- `build-enterprise.yml`
- `e2e-stubbed.yml`
- `nightly.yml`
- Renamed the nightly Playwright artifact from:
- `playwright-nightly-${{ github.run_id }}`
- to `playwright-report-nightly-${{ github.run_id }}`
for consistency with the other workflows.
### Why the change was made
The workflows attempted to upload artifacts from a directory that no
longer exists, causing GitHub Actions to report:
> No files were found with the provided path:
`frontend/editor/playwright-report/`
Updating the upload path ensures Playwright reports are successfully
collected and available for debugging failed E2E runs.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
Please provide a summary of the changes, including:
- What was changed
- Moved PostHog startup out of `index.tsx` and into a config-aware
initializer inside `AppProviders`.
- Added a dedicated `usePosthogTracking` hook that only initializes
PostHog when `enableAnalytics` is explicitly `true` and `enablePosthog`
is not disabled.
- Kept cookie-consent handling in the same flow so consent is applied
only after PostHog is actually initialized.
- Removed the unconditional `PostHogProvider` and `posthog.init(...)`
bootstrap from the app entrypoint.
- Added targeted frontend tests covering analytics-disabled and
analytics-enabled startup behavior.
- Why the change was made
- The previous frontend bootstrap initialized PostHog before app config
was loaded, so disabling analytics in the UI or via environment settings
did not prevent PostHog network activity.
- This change makes analytics behavior follow the server-provided config
instead of always connecting on page load.
Closes#6358
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
We don't have any strong reasons to keep the Portal as a separate Vite
app, and it needs access to so many things from the Editor that it no
longer makes sense to keep them separate. This PR moves the Portal code
to have direct access to the Editor code and gets rid of the shared
folder.
# Description of Changes
In SaaS mode the self-hosted "Update Available" notification could still
appear and the update-check code (external call to
`supabase.stirling.com/functions/v1/updates`) still ran, even though the
cloud owns app versioning. The web `UpdateStartupPopup` was already
SaaS-gated via a null override, but two other paths were not:
- **Desktop app in SaaS connection mode** - `useDesktopUpdatePopup()`
ran its startup check and rendered the `UpdateModal` regardless of
connection mode, so a self-hosted update popup appeared while connected
to SaaS.
- **Settings → General** - the core `GeneralSection` fired
`checkForUpdate()` on mount unconditionally, even when the update
section was hidden (as SaaS does), so the external call still ran.
**What changed**
- `useDesktopUpdatePopup.ts` - the startup timer now bails out
immediately when `connectionModeService.getCurrentMode() === "saas"`. No
mode lookup, no external fetch, no modal.
- `core/GeneralSection.tsx` - the mount `checkForUpdate()` now returns
early when `hideUpdateSection` is set, so hiding the section (web SaaS,
managed-disabled desktop) also stops the external call.
- `desktop/GeneralSection.tsx` - passes `hideUpdateSection` when
`useSaaSMode()` is true, which (via the above) suppresses the settings
check in desktop-SaaS too.
**Why** - in SaaS the update check should never be called and no update
notification should be shown; the cloud handles versioning.
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
Fix#6801, along with fixing policies on desktop, which would attempt to
download policy outputs from the local backend instead of the server,
where they actually live. I've changed the policies logic to maintain
the same backend for the file retrieval as it used for the policy
running, so when we support running policies locally, it should still
work correctly.
## What
Makes `@shared` the single home for the Stirling brand logo assets.
Moves the editor's two logo sets — `classic-logo` + `modern-logo` (22
files: marks, wordmarks, favicons, login headers, PNGs) — out of
`editor/public/` into `shared/assets/brand/`, and adds a Storybook
**Brand/Logos** gallery.
## Why this shape (not a plain move)
The editor serves logos by **URL** from `public/` and switches
`classic`/`modern` by a **user preference** (`useLogoAssets`,
`manifest.json` / `manifest-classic.json`, `index.html` favicon links).
Rewiring all that to module imports would be a large, risky change to
the variant system.
Instead the editor keeps its variant system **unchanged** and just
sources the files from shared: `vite-plugin-static-copy` copies
`shared/assets/brand/{classic,modern}-logo/*` back to the served
`/{classic,modern}-logo` paths (the editor already uses this plugin for
pdfium/pdfjs assets). Single source of truth in shared, zero editor
code/manifest/markup changes.
## Verified
- **Build:** editor builds with both sets present at
`dist/{modern,classic}-logo/`; `manifest.json` + favicon refs resolve.
- **Dev:** the vite dev server serves the bridged paths —
`/modern-logo/logo512.png`,
`/modern-logo/StirlingPDFLogoNoTextDark.svg`,
`/classic-logo/favicon.ico` all return **HTTP 200** (the plugin's dev
middleware).
- Typecheck clean on core/proprietary/saas; prettier clean; `storybook
build` succeeds with the `Brand/Logos` gallery bundled.
- The portal's existing `@shared/assets` brand imports are untouched.
## Follow-ups (not in this PR)
- **Dedup:** `shared/assets/stirling-mark-*.svg` is byte-identical to
`brand/modern-logo/StirlingPDFLogoNoTextDark.svg`, and
`stirling-pdf-logo-*` is a near-twin of the modern wordmark. Reconciling
these (and re-pointing the portal) needs a designer eye on which
wordmark is canonical, so it's left out here to avoid changing the
portal's rendered logo.
- `editor/src/logo.svg` appears unused (no references) — candidate for
deletion separately.
## Problem
The account-link / billing / Usage strings migrated to i18next in #6738
call `t("key")` with **no inline default**. When no i18next instance is
initialized — which is the case in **Storybook** (the preview doesn't
load the portal i18n config) — or whenever a key is missing,
react-i18next renders the **raw key** (e.g. `billing.walletMeter.title`)
instead of English. That's why the billing stories regressed to showing
keys.
## Fix
Add the English string as the `t()` default value, matching the
**existing portal convention** (`AuthGate`, `Header`, `Sidebar`) and the
editor:
- plain → `t("key", "English")`
- interpolation → `t("key", "English {{var}}", { var })`
- plural → `t("key", "{{count}} …", { count })`
Dynamic keys resolved via data fields carry a sibling `*Default` string
passed as the default:
- `LINK_INFO` badge labels → `labelDefault` (`t(info.labelKey,
info.labelDefault)`)
- `PdfsProcessedCard` segment legend → `labelDefault` / `descDefault`
Defaults were sourced **verbatim from the merged
`en-US/translation.toml`**, so the TOML stays the source of truth — the
inline default only fills in when the catalogue isn't loaded or lacks
the key.
## Scope
All strings added in #6738: 5 account-link + 12 billing components + the
Usage view (157 static call sites + the `LINK_INFO` / segment dynamic
ones). No new keys; no copy changes.
## Verification
- `tsc -p portal/tsconfig.json` → 0
- `eslint --max-warnings=0` (changed files) → 0
- `prettier --check` → clean
- portal `vitest` → **62/62 pass**
No behaviour change when i18n is initialized; Storybook and any
missing-key fallback now render English.
> **Draft / WIP** — print enforcement is still to come (see below).
## Goal
A "run on export" policy must enforce on **every** path where a PDF
leaves the editor, not just the main Download/Export button. This routes
the remaining exits through the existing export-policy gateway
(`downloadFileWithPolicy`), which runs `enforceExportPolicies` before
the file leaves and is a no-op when no export policy is active.
## Audit of exit paths
| Path | Status |
|---|---|
| Web download / export, page-editor, file-editor, thumbnails | ✅
already covered (gateway) |
| **Form-fill download** (`FormSaveBar`) | ✅ fixed here — was a raw
`createObjectURL` download |
| **Desktop Ctrl+S save** (`useSaveShortcut`) | ✅ fixed here — was raw
`downloadService` |
| **Desktop save-operation-results** (`operationResultsSaveService`) | ✅
fixed here — was raw `downloadService` |
| Viewer `saveAsCopy` (annotations/redactions) | n/a — in-memory version
saves, not exits |
| **Print** (`printActions.print`) | ⏳ pending — enforce-then-print
(below) |
| Web operation-results (`downloadFromUrl`) | ⏳ pending — URL-stream,
needs a fetch→enforce wrapper |
| Share link | excluded by design (enforce at share-creation, not
recipient download) |
## In this PR
All three fixes are the same pattern — route the raw download through
`downloadFileWithPolicy` instead of `URL.createObjectURL` / the raw
download service.
## Still to come (why it's a draft)
- **Print** — enforce-then-print: on print, run the same
`enforceExportPolicies`; if it changed the doc, swap the viewer to the
enforced version (new version in history) and toast *"PDF updated by
policy enforcement — review, then print again"* rather than silently
printing a different doc; if unchanged, print. Covers Ctrl+P, the
toolbar button, and embedded PDF-JS print.
- **Web operation-results** (`downloadFromUrl`) — fetch the result to a
blob, enforce, then download.
## Verification
Typecheck (core/proprietary) + prettier clean for the changes here;
desktop tsc clean for the touched files. The print UX, once added, needs
a manual run with an active export policy — there's no automated path
for it.
> **Draft / WIP.** Combined-billing **Mode A** (connected self-hosted).
Entirely behind `stirling.billing.account-link.enabled` (default **off**
→ beans absent → 404). Pairs with Stirling-PDF-SaaS PR #313 (twin
migration → `v3`).
## What this does
A self-hosted instance links a SaaS account in the **Portal**, gets a
**device credential**, and authenticates unattended metering/entitlement
with it — no long-lived user JWT on the server. The Portal then surfaces
the team's **billing** (free trial → metered Processor plan) driven by
the live wallet.
```mermaid
sequenceDiagram
participant Portal as Portal (browser)
participant Supa as SaaS Supabase Auth
participant Local as Self-hosted backend
participant SaaS as SaaS Java (app/saas)
Portal->>Supa: signIn / signUp (Supabase JS, short-lived JWT)
Supa-->>Portal: JWT (SDK-refreshed, stays in browser)
Portal->>Local: hand JWT (same-origin)
Local->>SaaS: POST /account-link/register (Bearer JWT, leader)
SaaS-->>Local: { device_id, device_secret } (secret once)
Note over Local: store device_secret server-side
loop unattended
Local->>SaaS: /api/v1/instance/** (X-Device-Id + X-Device-Secret)
SaaS-->>Local: entitlement / gate decision
end
```
**Auth model:** human auth = Supabase JS (ephemeral JWT, kept for
attended portal features). Durable instance auth = a team-bound
**device_id + secret** (SHA-256 stored, shown once), non-user
`ROLE_LINKED_INSTANCE`, path-scoped to `/api/v1/instance/**`. Instance
binds to a **team**, never a user.
## Billing surface (Portal · Mode A states)
`Usage & billing` is state-driven by the link/subscription dimension and
built to the marketing designs, sharing one component layer across
states:
- **Unlinked** → link-account prompt.
- **Linked · Free** — the *Processor trial*: a one-time 500-PDF free
grant ("Process 500 PDFs free, then $X/PDF"), the team's free-editor
fleet, and a leader-only **Switch on the Processor →** (embedded Stripe
Checkout).
- **Linked · Subscribed** — the *Processor plan* dashboard:
PDFs-processed split (API / Agents / Automation), **spend this month**
vs. a **spend limit** meter with a run-rate projection and an **in-place
cap editor** (preset buckets + suggested value + guardrail), Stripe
**invoices** (with billed PDFs per invoice), and the default **payment
method**. Card / subscription changes deep-link to Stripe's hosted
portal.
Manual PDF editing is always free — only Automation / AI / API is
metered; a `$0` cap blocks all metered work (≠ "no cap").
**Shared, not duplicated:** the editor-fleet card, the Enterprise
upsell, and the meter (`@shared/billing` `MeterBar`) render in both the
free and subscribed views; money/cap math lives once in
`@shared/billing`. The page header is a sticky, full-bleed bar.
**New SaaS reads** (defensive — degrade to empty/"—" when the Stripe
mirror lacks a table, never 500):
- `GET /api/v1/payg/payment-method` — default card (brand / last4 /
expiry) from `stripe.payment_methods`.
- Invoice **PDFs processed** — billed line-item quantity from
`stripe.invoice_line_items`.
## Progress
- [x] Schema: `V22 linked_instance` (+ Supabase twin in #313)
- [x] `AccountLinkController` register / list / revoke (leader-only,
team from caller)
- [x] Device-credential filter (path-scoped, constant-time,
revocation-aware) + `SupabaseSecurityConfig` wiring (conditional)
- [x] `GET /api/v1/instance/whoami` + **`/entitlement`** (reuses
`EntitlementService`/`TeamBillingService`) + tests
- [x] Self-hosted backend (`app/proprietary`): orchestrator + instance
gate (dark + **fail-open**) + tests
- [x] Portal: in-app Supabase login modal + register hand-off +
`LinkContext` (unlinked default) + "Linked instances" view — all
`@shared` Storybook components
- [x] **Portal billing surface** — free (Processor trial) + subscribed
(Processor plan) Usage views to marketing spec; link-state derived from
the **live wallet**; in-place cap editor; over-cap banner
- [x] **SaaS reads** — payment-method endpoint + invoice billed-units
(defensive `stripe.*` mirror DAOs) + tests
- [x] Orphan guard: block leaving/accepting away from a team whose
departure orphans its linked instances
- [ ] Metering Step 2 (lease + reconcile loop) + bounded fail-open
cutoff
- [ ] Proprietary hardening (SaaS base-url config, secret-at-rest, finer
billable classification) + HTTP integration test
- [ ] Cross-repo Stripe lifecycle certified end-to-end (subscribe →
meter → cancel → 402)
- [ ] Admin ⟺ SaaS-leader enforcement (separate portal-team-mgmt
workstream)
## Verification — all green
| Gate | Result |
|---|---|
| `STIRLING_FLAVOR=saas :saas:test` | BUILD SUCCESSFUL (account-link +
payg, incl. `PaygPaymentMethodControllerTest`,
`PaygInvoicesControllerTest`) |
| `:proprietary:test` | BUILD SUCCESSFUL (account-link + entitlement
cache/interceptor) |
| portal | tsc 0 · eslint 0 · **vitest 55** · storybook build (all
billing stories) |
| frontend post-sync | typecheck shared + portal + editor (saas +
desktop): 0 |
## Screenshots — billing UI
_Latest Storybook renders (Portal/Billing). Drag each capture below its
caption — kept out of the repo._
**Linked · Free — Processor trial**
<img width="1648" height="503" alt="01-free-processor-trial"
src="https://github.com/user-attachments/assets/afe6238a-d3b4-47fd-8ea2-cbaed8b0a653"
/>
**Linked · Subscribed — Processor plan dashboard**
<img width="1648" height="930" alt="02-subscribed-processor-plan"
src="https://github.com/user-attachments/assets/329e6808-a9a9-4e65-99af-5a8a5e6bf4ab"
/>
**Spend limit — in-place cap editor**
<img width="1648" height="411" alt="03-spend-limit-editor"
src="https://github.com/user-attachments/assets/acc95096-bf8e-4ab0-a32c-3c20dc94f816"
/>
## Review feedback applied
Reworked the portal after first-pass feedback: linking signs in via the
**shared Supabase login** (SSO + email/password) — no bespoke form; the
**device secret is never shown in or sent to the FE** (the local backend
registers + stores it server-side); billing copy reads **PDFs**, not
"units"; the wallet surface uses **`@shared` components** matching the
SaaS Plan page. Re-verified including an assertion the link response
carries no `deviceSecret`/`deviceId`.
**Synced onto unified auth + in-app login (2026-06-23).** Merged `main`
incl. **#6725 unified auth** (`frontend/shared/auth`); the link flow
uses a shared `useSupabaseLogin` hook + `SupabaseLoginForm`, a portal
`LinkAccountModal`, and `useAccountLink.completeLink(session)` (+
on-mount SSO redirect-return). Config: `VITE_SAAS_SUPABASE_URL` +
`VITE_SAAS_SUPABASE_ANON_KEY`. The local `/account-link/link` call
carries the Spring admin bearer with the SaaS JWT in the body. **SSO**
needs the SaaS Supabase project to allow-list the portal redirect URL
(email/password works without it).
## Assumptions / open
- **Proprietary remains a scaffold** (placeholder SaaS base-url,
plaintext device secret at rest, coarse billable classification).
- Payment-method + invoice-quantity render only when
`stripe.payment_methods` / `stripe.invoice_line_items` are in the
Sync-Engine target (confirm in the Supabase/Sync-Engine config);
otherwise they degrade gracefully.
- A self-contained local HTML report + manual E2E runbook live in
`notes/account-link-report/` (dev artifacts, outside the repo).
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
# Description of Changes
- Fix PR CI for base-image changes: the embedded build's buildx
container builder could not resolve the locally-built
`stirling-pdf-base:pr-test` and tried to pull it from a registry,
failing the build
- `test-build-docker.yml`: when the base changed, build the embedded
image with the docker driver (`docker build`) so the locally-built base
resolves from the daemon image store
- `docker-compose-tests.yml`: when the base changed, skip the buildx
container builder + gha cache so `test.sh`'s local base build resolves
via the default docker driver
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs (if functionality has heavily
changed)
- [ ] I have read the section Add New Translation Tags (for new
translation tags only)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
# Description of Changes
Continued effort to expand linting scope to ban the `any` type in our
codebase. This PR pulls in a lot of subfolders into the linting scope,
because the excluded list was getting short enough that it was feasible
to move a layer down. I then fixed all the trivially fixable `any` type
violations in the subfolders, which just required local changes to the
one file. The aim of this PR is more to expand the scope to all the
folders we can that already avoid `any` types, rather than actually fix
violations.
# Description of Changes
We can't convert to TS7 completely yet because it lacks the TS API, so
ESLint and some of our scripts don't work, but we can do [what the TS
team suggest and run TS6 and TS7
side-by-side](https://devblogs.microsoft.com/typescript/progress-on-typescript-7-december-2025/#compiler).
When we do that, we take the `task frontend:typecheck:all` job from ~76s
to ~13s, and everything else continues to work as it did before.
I've set it so that CI will still use TS6 for the time being and locally
we use TS7 out of an abundance of caution because CI time doesn't really
matter but local time does. I do think it was a bit pointless doing that
since the TS team claim the type checking performs identically, but we
might as well have it like that for now. If it happens to go badly
locally for any devs, they can use `CI=true task frontend:typecheck` to
revert to use TS6 trivially.
# Description of Changes
[Our nightlies have literally never passed
before](https://github.com/Stirling-Tools/Stirling-PDF/actions/workflows/nightly.yml).
As far as I can tell, that's because the frontend was never being built,
so the Playwright tests would just never start up.
I've forced a nightly run from this branch, and the Playwright tests
still fail, but for legitimate failures now. It's a separate job to
track down why they're actually failing, so I'm leaving that for
followup work.
# Description of Changes
Redesign policies backend to treat sources a lot closer to how the
frontend imagined them working (they're persistent now and have an API).
Then connect the portal to the sources when mocks are off to allow for
source creation in the UI. It's not particularly useful to do that right
now because there's no policies UI, but I've tested manually that
sources set up in the UI are usable by policies created via the API.
I had to change the portal so that when mocks are off, it doesn't just
hard crash when attempting to connect to all the backend APIs that don't
exist yet. It'll still log the errors, but just continues on rendering
the UI now.
I also changed all the policies backend APIs to be gated behind a flag
instead of behind the SaaS profile. This is because we haven't yet got
the payment model sorted, but we're going to need this stuff running
self-hosted to be able to test it locally.
# Description of Changes
- Change the nightly build to not sign any of the desktop builds, since
we just care about the compiled code. The restored code will still be
signed dependent on the OS in the PR builds.
- Change RPM Linux to use zstd for compression because the one it was
using runs really slowly, and the Jar is already compressed so it makes
basically no difference (arguably we shouldn't compress at all)
- ~Switch to consistently use Depot for Docker caching to stop filling
up the GHA cache and evicting the Rust cache~ Decided against switching
to Depot because we're probably doing another PR to remove Depot
altogether in the near future
# Description of Changes
Closes#6695
This PR adds bulk cleanup actions for comments and annotations in the
PDF editor, while tightening the save and navigation behavior around
annotation edits.
### Comments sidebar
Adds a “Clear all comments” action to the comments sidebar overflow
menu. The action opens a confirmation modal before clearing sidebar
comments and replies.
The implementation distinguishes between standalone comment annotations
and comments attached to existing visual annotations. Standalone
comments and replies are removed from the document, while comments
attached to markup, shapes, ink, or other visual annotations are cleared
from the sidebar without deleting the underlying annotation itself. This
preserves the visible document markup while removing the comment
metadata and persisted comment contents.
The comments sidebar state is also reset after clearing, including draft
comments, reply drafts, edit state, and open confirmation/delete modal
state.
### Annotate tool
Adds a document-level “Clear all annotations” action to the Annotate
tool. The action is exposed through the annotation panel’s overflow menu
and uses a confirmation modal before removing annotations.
The clear operation is routed through the existing annotation API bridge
and delegates to EmbedPDF’s document-level annotation clearing API. The
UI handles unavailable annotation state, successful clears, and
failures.
After annotations are cleared, the editor resets annotation interaction
state, exits placement/selection-specific state, returns to select mode,
and marks the document as having unsaved changes only when annotations
were actually removed. The user can then persist the removal through the
normal Save Changes flow.
### Save and navigation hardening
Improves the viewer save/apply flow used by annotations and manual
redactions.
Save operations are now deduplicated while an apply operation is already
in flight, preventing duplicate exports or duplicate file consumption
when users trigger save/navigation repeatedly.
The global unsaved-changes navigation modal now waits for “Apply &
Leave” to complete successfully before navigating. If saving fails, the
modal keeps the user in place instead of leaving with unsaved edits
still present.
The Annotate panel also prevents “Save Changes” and “Clear all
annotations” from running concurrently.
<!--
Please provide a summary of the changes, including:
- What was changed:
- Why the change was made
- Any challenges encountered
-->
---
## Checklist
### General
- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [X] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [X] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
Clear all comments :
<img width="310" height="397" alt="image"
src="https://github.com/user-attachments/assets/d1682611-13f8-4f40-aa77-44b37450e56e"
/>
Clear all annotations:
<img width="284" height="549" alt="image"
src="https://github.com/user-attachments/assets/e4049bc1-f07b-4b36-b08e-ad6d6b86fe62"
/>
### Testing (if applicable)
- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
Changes
- Use 127.0.0.1 instead of localhost for the local backend. The bundled
backend starts on a random port and binds the IPv4 wildcard, but the
frontend health-checked http://localhost:{port}. On macOS (and some
Linux) localhost resolves to IPv6 ::1 first, so the connection is
refused and every backend-dependent tool shows "backend offline" even
though the backend started fine. Switched getBackendUrl() and the
health-check URL to the 127.0.0.1 loopback literal (already in the Tauri
HTTP capability allowlist, and what the OAuth loopback server already
uses). Client-side tools were unaffected, which matches the reports.
- Fail the desktop build when the bundled JRE is older than the app JAR.
The app JAR is compiled for Java 25, but the bundle could ship an older
runtime/jre (jlink:runtime short-circuits on an existing runtime, and
nothing checked its version), producing UnsupportedClassVersionError at
launch so the backend never starts. Added a jlink:verify task that reads
the jlink release file and fails the build if the bundled JRE major is
below REQUIRED_JAVA (25, kept in sync with build.gradle
modernJavaVersion). It runs after the runtime is staged - including the
short-circuit reuse path that lets a stale JRE slip through.
Cross-platform Node script, no new dependencies.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
## What was changed
- Updated all GitHub Actions workflows using Gradle from older versions
(9.3.1 and 9.5.1) to Gradle 9.6.0.
- Updated the Gradle Wrapper distribution URL to use Gradle 9.6.0.
- Updated all Gradle-based Docker build stages to use the
`gradle:9.6.0-jdk25` image and corresponding image digest.
- Aligned CI, Docker, and local development environments on the same
Gradle version.
- Included the regenerated `gradlew` script changes produced by the
Gradle wrapper update process.
## Why the change was made
- Ensures consistent Gradle versions across local development, CI
workflows, and Docker builds.
- Takes advantage of the latest Gradle 9.6.0 improvements, fixes, and
compatibility updates.
- Reduces the risk of version mismatches causing build or deployment
inconsistencies.
- Simplifies maintenance by standardizing the build toolchain throughout
the repository.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
This change centralizes several dependency version declarations into
shared Gradle version properties and updates module build files to
reference those properties instead of hardcoded version strings.
### What was changed
- Added centralized version properties in the root `build.gradle` for:
- commons-io
- commons-lang3
- rhino
- okhttp BOM
- gson
- guava
- bucket4j
- archunit
- batik
- jpdfium
- JWT
- AWS SDK
- Testcontainers
- Replaced hardcoded dependency versions across multiple modules with
shared version variables.
- Updated `resolutionStrategy.force` declarations to use centralized
version properties.
- Updated dependency constraints and BOM references to use shared
version variables.
- Removed module-specific duplicate version declarations from
`app/proprietary/build.gradle`.
- Standardized dependency declarations across `common`, `core`,
`proprietary`, and `saas` modules.
## Why the change was made
- Reduce duplication of dependency version definitions.
- Simplify future dependency upgrades and maintenance.
- Ensure consistent dependency versions across all modules.
- Improve readability and reduce the risk of version drift between
subprojects.
- Make security-related dependency overrides easier to maintain from a
single location.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
## Summary
Published Docker images (`stirling-pdf:latest`, `:2.13.1`) still shipped
the full `ffmpeg` package even though it was disabled in source back in
#6053.
**Root cause:** `push-docker.yml` passed a hardcoded
`BASE_VERSION=1.0.0` build-arg for the regular image, overriding the
Dockerfile's `ARG BASE_VERSION=1.0.2` default. Base `1.0.0` is the
original base that still does the explicit `ffmpeg` apt install, so the
published image never picked up the removal.
# Description of Changes
Fixes one of the main causes of `any` typing left in tools, the way that
we register tool parameters in the registry. Currently, it just accepts
tool params via `any`, but instead we can explicitly change them to
`Record<string, unknown)`, so on the way back out they can more safely
be cast back to their correct type when known.
One consequence of this is that I had to redesign the way we
special-case the Convert tool, which previously was a different shape
than all the other param types. Now it's just got optional parameters on
it, which isn't quite as type-safe as before, but it does mean all tools
are a consistent shape now, which I think is worth the tradeoff.
• Removed colors from policies to make them look more professional.
• upgraded to enterprise link to contact us.
• Hid inactive policies from users (Kept for admin and team lead).
• Closing policies had wrong arrow, made a standard component for chat,
tools and policies header.
## What
Make the **SaaS** build always use the modern logo, so the classic logo
can no longer appear anywhere in the SaaS app.
This is a minimal, SaaS-only alternative to the full classic-logo
removal PR (~80 files). **OSS (`core`) and proprietary builds are
untouched** — they keep the full modern/classic variant system,
including the admin _Logo Style_ picker.
## How
A single SaaS-layer override shadows the core hook:
- `frontend/editor/src/saas/hooks/useLogoVariant.ts` → returns
`"modern"` unconditionally.
In the SaaS build the `@app/*` alias cascade resolves
`@app/hooks/useLogoVariant` to `src/saas/*` before `src/core/*`, so this
shadows the core implementation (which otherwise resolves the variant
from the stored user preference or the server `logoStyle`).
## Why one file is enough
All logo rendering funnels through `useLogoVariant()`:
- `useLogoAssets()` → favicon, web manifest, apple-touch icon, wordmark,
`logo512`, tooltip logo — consumed by `BrandingAssetManager` (which sets
`<link rel="icon|manifest|apple-touch-icon">`), `Wordmark`, `LogoIcon`,
`Tooltip`.
- `useLogoPath()` → the no-text logo SVGs.
- The login-carousel slides (`buildLoginSlides`) receive the variant
from `AuthLayout`, which calls the hook.
Everything else that references a logo in SaaS already hardcodes
`modern-logo` (`index.html`, the SaaS
`Login`/`Signup`/`AuthCallback`/`OAuthConsent` routes, cloud onboarding,
account/MFA QR logos).
The only hardcoded `classic-logo` reference — the admin _Logo Style_
picker in `AdminGeneralSection` — is **not shipped in SaaS**:
`createSaasConfigNavSections` builds from the core nav sections and
never includes the proprietary admin sections.
`manifest-classic.json` and the classic assets remain in the shared
`public/` folder (served by all builds) but are never referenced in the
SaaS bundle.
## Test plan
- [x] `task frontend:typecheck:saas` — clean
- [x] `eslint` on the new file — clean
# Description of Changes
TS6 introduced backwards-incompatible changes which affected us a little
bit. Other than that, I don't think it significantly changes things for
us, but we will need to deal with these breaking changes to be able to
upgrade to TS7 (the version written in Go, so dramatically faster), so
I'd rather do the work now before TS7 actually releases.
Main things I've done:
- Removed the use of `baseUrl` in the `tsconfig.json` files
- Explicitly provide the `node` types where needed
- Explicitly reference the un-referenced but required Google API types
- Dropped the installation of `madge` which we weren't using and wasn't
directly compatible with TS6
- Updated the `i18next` packages for explicit TS6 compatibility
- Explicitly override `tsconfck` to force TS6 compatibility since we
can't upgrade it. We're only using that for `vite-tsconfig-paths` and it
all still seems to work fine, so I don't think this is an issue. I think
we can theoretically drop `vite-tsconfig-paths` when we upgrade to Vite
8 ([because it supports
`paths`](https://v8.vite.dev/guide/features#paths)), but that's a bigger
job than I want to do in this PR
# Description of Changes
Refactor frontend auth to the shared folder and hook it up to both the
portal and editor so they share the same system. Also adds various tasks
to help run the portal, including `task dev:portal` to spawn the portal
with the backend, and `task dev:portal:proxy` to spawn the editor,
portal and backend, and a reverse proxy (at localhost:3000) to allow you
to use both at once to simulate how this will actually be deployed,
allowing you to check whether the seamless transition between the two
actually works.
# Description of Changes
> [!note]
> GitHub absolutely mangles the diff unless you change to ignore
whitespace changes
This page has never had the Enter key bound to the Change Password
button:
<img width="673" height="745" alt="image"
src="https://github.com/user-attachments/assets/7a1b06f0-2945-4270-a795-f799ec556c12"
/>
This PR changes the modal to be properly wrapped in a form so key
commands work correctly on it.
# Description of Changes
Rust cache added in #6732 never fired because `main` builds don't
include building the desktop apps. We could build them on `main` builds,
but that's fairly expensive, so just build them on nightlies instead to
warm the cache for any desktop PRs the next day
## Overview
Internationalizes the **developer portal**, which previously had **zero
i18n** — every string was hardcoded across ~118 components. Rather than
stand up a parallel system, this shares the **editor's** existing i18n
setup (same TOML locale format, same Crowdin pipeline), then converts
every portal surface to `react-i18next` and adds a CI guard so coverage
can't regress.
## What's included
### 🔗 Shared i18n core (`@shared/i18n`)
- Extracts the editor's `TomlBackend` (HTTP loader for
`public/locales/{lng}/translation.toml`) and language metadata/helpers
(the 42-language list, RTL set, `LanguageSource` priority, code
normalizers) into `frontend/shared/i18n/`.
- The **editor** now imports and re-exports these from `@shared/i18n` —
its 20+ consumers are unchanged. Its local `tomlBackend.ts` is deleted.
- The **portal** builds its own i18next instance from the shared core,
with **en-US as the source of truth** and the same
`/locales/{lng}/translation.toml` layout.
### 🌍 Full portal coverage
- Every view and component converted to `t()` — all feature areas (home,
pipelines, sources, infrastructure, usage, documents, agent-builder,
editor-admin, policies, users, docs, catalogue, components view) plus
app shell, nav, modals, and the home/domain widgets.
- **1108 keys across ~30 namespaces** in
`portal/public/locales/en-US/translation.toml`, grouped by feature;
shared strings under `[common]`. Plurals use i18next count forms;
dynamic labels (nav, settings sections, status badges) use template keys
against populated tables.
- Data-driven strings (values from `@portal/api/*` mocks, enum/id
values, code samples) are intentionally left untranslated — they're
data, not UI chrome.
### ✅ CI coverage guard
- `portal/scripts/check-i18n.mjs` fails if any static `t("key")` in
portal source is missing from the en-US locale. Wired into
`frontend:check` and `frontend:check:all`, so missed keys break CI. This
mirrors the editor's `missingTranslations` test for the portal, which
has no vitest harness of its own.
## Testing
- `task frontend:check:all` passes locally (typecheck all variants,
lint, format, **portal i18n guard**, builds, tests, storybook).
- Every static `t()` key verified to resolve in the locale (1108 keys /
186 source files); all dynamic key prefixes map to populated tables.
- Runtime sweep of all 12 portal routes shows **no unresolved keys** on
screen; nav labels, plurals, and array-backed copy all render real text.
## Follow-ups (not in this PR)
- **Crowdin** — register `frontend/portal/public/locales/` as a source
so portal strings flow through the same translation pipeline as the
editor (an ops step on the Crowdin side; there's no Crowdin config in
the repo).
- Only `en-US` is populated; other languages will arrive via the
pipeline.
AI PDF creation ("create a PDF for me") has been broken since the
Policies backend (#6527) introduced PolicyExecutor as the tool execution
pipeline. PolicyExecutor runs normal single-input tools with a per-file
loop, but generator tools like `create-pdf-from-html-agent` take no
input file and build their output purely from parameters. With zero
input files the loop ran zero times, so the endpoint was never called
and the step silently produced nothing. The chat reported success
("Created Purchase Order") while no document ever appeared.
This adds an `else if (inputFiles.isEmpty())` branch so a generator tool
is called once with an empty file list, matching what the multi-input
branch already does for an empty input. Two files changed: the
one-line-ish fix in `PolicyExecutor`, and a regression test covering the
no-input case.
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
# Description of Changes
The Tauri jobs are very slow, especially the Linux ones, which can take
>1hr to build all the necessary code. A lot of that is because of the
actual Rust compilation, which isn't cached at all as far as I can tell.
This introduces a cache step for the Rust dependencies, so PRs will just
reuse the compiled Rust from the last build of main (if it's safe to do
so).
# Description of Changes
Continued effort to remove the remaining uses of the `any` type from our
TS code. The vast majority of these uses that it cleans up was just
catching errors as `any`, which are pretty simple to fix. I couldn't
completely remove the `any` type usage from `core/tools` because there
were cascading issues from a couple of the files in there (most notably
Automate) but still, moving in the right direction.
# Description of Changes
#6727 introduced frontend code which goes against the architecture, so
this PR re-implements it in the architecture properly, along with
another bad Tauri check that I found in the source. I also updated the
`AGENTS.md` file to use Claude's "read this file" syntax to try and
force AI to actually read the file instead of just suggesting that it
does it.
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md">js-yaml's
changelog</a>.</em></p>
<blockquote>
<h2>[4.2.0] - 2026-06-01</h2>
<h3>Added</h3>
<ul>
<li>Added <code>docs/safety.md</code> with notes about processing
untrusted YAML.</li>
<li>Added <code>maxDepth</code> (100) loader option. Not a problem, but
gives a better
exception instead of RangeError on stack overflow.</li>
<li>Added <code>maxMergeSeqLength</code> (20) loader option. Not a
problem after <code>merge</code> fix,
but an additional restriction for safety.</li>
<li>Added sourcemaps to <code>dist/</code> builds.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Stop resolving numbers with underscores as numeric scalars, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/627">#627</a>.</li>
<li>Switched dev toolchains to Vite / neostandard.</li>
<li>Updated demo.</li>
<li>Reorganized tests.</li>
<li><code>dist/</code> files are no longer kept in the repository.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Fix parsing of properties on the first implicit block mapping key,
<a
href="https://redirect.github.com/nodeca/js-yaml/issues/62">#62</a>.</li>
<li>Fix trailing whitespace handling when folding flow scalar lines, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Reject top-level block scalars without content indentation, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/280">#280</a>.</li>
<li>Ensure numbers survive round-trip, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/737">#737</a>.</li>
<li>Fix test coverage for issue <a
href="https://redirect.github.com/nodeca/js-yaml/issues/221">#221</a>.</li>
<li>Fix flow scalar trailing whitespace folding, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Fix digits in YAML named tag handles.</li>
</ul>
<h3>Security</h3>
<ul>
<li>Fix potential DoS via quadratic complexity in merge - deduplicate
repeated
elements (makes sense for malformed files > 10K).</li>
</ul>
<h2>[3.14.2] - 2025-11-15</h2>
<h3>Security</h3>
<ul>
<li>Backported v4.1.1 fix to v3</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/nodeca/js-yaml/commits">compare view</a></li>
</ul>
</details>
<br />
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md">js-yaml's
changelog</a>.</em></p>
<blockquote>
<h2>[4.2.0] - 2026-06-01</h2>
<h3>Added</h3>
<ul>
<li>Added <code>docs/safety.md</code> with notes about processing
untrusted YAML.</li>
<li>Added <code>maxDepth</code> (100) loader option. Not a problem, but
gives a better
exception instead of RangeError on stack overflow.</li>
<li>Added <code>maxMergeSeqLength</code> (20) loader option. Not a
problem after <code>merge</code> fix,
but an additional restriction for safety.</li>
<li>Added sourcemaps to <code>dist/</code> builds.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Stop resolving numbers with underscores as numeric scalars, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/627">#627</a>.</li>
<li>Switched dev toolchains to Vite / neostandard.</li>
<li>Updated demo.</li>
<li>Reorganized tests.</li>
<li><code>dist/</code> files are no longer kept in the repository.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Fix parsing of properties on the first implicit block mapping key,
<a
href="https://redirect.github.com/nodeca/js-yaml/issues/62">#62</a>.</li>
<li>Fix trailing whitespace handling when folding flow scalar lines, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Reject top-level block scalars without content indentation, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/280">#280</a>.</li>
<li>Ensure numbers survive round-trip, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/737">#737</a>.</li>
<li>Fix test coverage for issue <a
href="https://redirect.github.com/nodeca/js-yaml/issues/221">#221</a>.</li>
<li>Fix flow scalar trailing whitespace folding, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Fix digits in YAML named tag handles.</li>
</ul>
<h3>Security</h3>
<ul>
<li>Fix potential DoS via quadratic complexity in merge - deduplicate
repeated
elements (makes sense for malformed files > 10K).</li>
</ul>
<h2>[3.14.2] - 2025-11-15</h2>
<h3>Security</h3>
<ul>
<li>Backported v4.1.1 fix to v3</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/nodeca/js-yaml/commits">compare view</a></li>
</ul>
</details>
<br />
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
# Description of Changes
Add message describing the most common tasks when running `task` with no
arguments. I think this should help newcomers because `task --list` is
massive at this point and nobody's going to read through it all. Let me
know if you think any other commands should be in the default message.
# Description of Changes
## What was changed
- Updated editor translation files across multiple locales.
- Migrated numerous count-based translation keys from legacy
`{{plural}}` handling to ICU-style plural forms using `_one`, `_other`,
and where applicable `_zero` variants.
- Added translations and localization keys for newly introduced features
and UI areas, including:
- Stirling Agents
- Chat interface and quick actions
- Files management and folder organization
- Desktop update workflow
- Folder scanning warnings
- Team and workspace management
- Sharing and upload dialogs
- Comparison status messages
- Relative time formatting
- Additional tool panel and update UI strings
- Added missing translation entries required by recently introduced
frontend functionality.
- Reorganized some translation sections to maintain consistency and key
ordering.
## Why the change was made
- To align locale files with the current frontend feature set.
- To support proper pluralization behavior across languages.
- To prevent missing translation keys and fallback text in newly added
UI components.
- To improve localization consistency and maintainability as the
application grows.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
Auto-generated by stirlingbot[bot]
This PR updates the backend license report based on dependency changes.
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
## What
Residual dead-code cleanup following the credits engine teardown
(#6687).
- **Delete the `ErrorTrackingService` dead island** (6 files): the
service, `UserErrorTrackerRepository`, `UserErrorTracker`,
`ProcessingErrorType`, `CreditsProperties`, and
`ErrorTrackingServiceTest`. These formed a self-referential cluster with
**zero external callers** once the credit machinery was removed.
- **Remove `/api/v1/credits/**`** from both `excludePathPatterns` blocks
in `PaygWebMvcConfig` — the credits controller no longer exists, so the
exclusion is defunct. (spotless collapsed the lists to one line.)
## Verification
- `./gradlew :saas:compileJava :saas:compileTestJava` → **BUILD
SUCCESSFUL**
- grep confirms zero dangling references to the deleted types
## Not in scope (deliberately deferred)
Destructive DB drops
(`user_credits`/`team_credits`/`user_subscription_plans` tables, dead
`payg_shadow_charge` columns, `user_error_tracker` table) are gated
behind the post-release soak (`live_ratio==1.0 ≥7d`) and tracked in a
separate bundle.
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
# Description of Changes
The `pre-commit` commands in this repo are inconsistent with the rest of
the dev workflow, as they are impossible to run through Task and they
can cause CI to fail with no way for a developer to run the `pre-commit`
scripts after they've failed. This PR adds `task pre-commit` (and `task
pre-commit:fix`) and then hooks up the existing `pre-commit` hooks and
CI to call the Task rule, so if developers are using pre-commit hooks
then they should still work, but they're also runnable without using
pre-commit at all.
I think it'd be worth reviewing what we're actually running at
pre-commit in the future because I'm not entirely convinced by all of
the scripts that we are running, but this should at least make what we
have properly enforced and usable by all devs.
Fix issues with the theme of the app that caused some things to persist
in light mode/dark mode whilst the rest of the app was the opposite
theme.
Removed dead rainbow mode code.
Added system theme option to settings.
## What & why
Policies (automation-backed enforcement) execute and bill through the
cloud backend, so the feature should only be available in the hosted
**SaaS** product — not in self-hosted proprietary or core builds. Today
it's enabled in the proprietary build (and the API is exposed in any
proprietary backend), so this locks it to SaaS on both layers.
## Frontend (build-flavor gate)
`POLICIES_ENABLED` is the single gate `usePoliciesEnabled` uses (rail +
auto-run controller).
- `proprietary` flag → **`false`** (self-hosted web no longer shows
policies)
- new `src/saas/constants/featureFlags.ts` → re-exports proprietary
flags, overrides `POLICIES_ENABLED = true`
- new `src/desktop/constants/featureFlags.ts` → same `true` override —
**required**: desktop's `@app` alias has no saas layer, and desktop
already gates policies on `POLICIES_ENABLED && useConfirmedSaaSMode()`,
so without a `true` here that runtime gate could never be satisfied.
Behaviour unchanged: desktop shows policies only when connected to SaaS.
- `PoliciesSidebar.test` mocks the flag on (it tests the component, not
the build gate — same pattern the existing `usePolicyAutoRun.retry.test`
uses).
## Backend (`@Profile("saas")` gate)
The saas backend runs under the `saas` Spring profile (as
`EntitlementGuard`, the AI controllers, etc. already do). The policy
beans are now `@Profile("saas")`, so `/api/v1/policies/*` and the
auto-run triggers exist **only** in the saas backend:
`PolicyController`, `PolicyEngine`, `PolicyRunner`, `PolicyRunRegistry`,
`PolicyValidator`, `JpaPolicyStore`, `FolderInputSource`,
`FolderOutputSink`, `InlineOutputSink`, `PolicyAccessGuard`,
`FolderAccessGuard`, `FolderWatchTrigger`, `ScheduleTrigger`,
`PolicyTriggerManager`.
**Deliberately *not* gated:** `PolicyExecutor` — `AiWorkflowService`
(always-on) injects it to run ad-hoc pipelines, so it stays
profile-free. It only depends on shared infra (`InternalApiClient`,
`ToolMetadataService`, `TempFileManager`, `ObjectMapper`), so leaving it
on is safe. Gating the engine/store/triggers as a set keeps wiring
consistent (nothing un-gated depends on a gated bean).
The saas `PolicyManagementAuthority` impl
(`TeamLeaderPolicyManagementAuthority`, `@Profile("saas")`) satisfies
`PolicyAccessGuard` in the saas context.
`AdminPolicyManagementAuthority` (`@Profile("!saas")`) becomes an unused
orphan in non-saas builds — harmless; left as-is rather than expanding
this PR's scope.
## Testing
- Frontend: full suite **869 pass**; typecheck clean on
proprietary/saas/core.
- Backend: `:proprietary` compiles, spotless clean, policy tests pass,
and the proprietary (non-saas) Spring context still boots with the
policy beans gated out (verified via the MCP `@SpringBootTest`
integration tests — no missing-bean failures).
Net: SaaS web build + desktop-in-SaaS-mode get policies (UI + API);
self-hosted proprietary and core get neither the UI nor the
`/api/v1/policies` endpoints.
# Description of Changes
Currently, `task dev` explicitly calls the backend with
`AIENGINE_ENABLED=true` even though it isn't being spawned, so you just
get a dead FAB in the UI. This PR fixes it so that the engine will only
be enabled for tasks that will actually spawn the engine.
It also fixes a bug with the chat which makes it unusable locally. The
API path was not going through `apiClient` so for local dev you end up
with `//api/v1/...` which is not a valid path, so you get CORS errors
when trying to connect to the AI engine.
# Description of Changes
Redesign policy running so the server is in charge of policy IDs and
running, to make it impossible to have the frontend miss the results.
This solves a minor bug that we currently have in policies, where if you
load a file and then refresh while the policy is running, you'll never
receive the outputted file.
Complete legacy-credits teardown ("Group 3"). The per-user/per-team
credit model is fully superseded by PAYG (`wallet_ledger`) — confirmed
no PAYG code references it. Authorized to also remove the `TeamCredit`
pool + its monthly reset.
## Frontend (saas)
- Deleted `saas/hooks/useCredits.ts`, `apiKeys/hooks/useCredits.ts`,
`types/credits.ts`, `apiKeys/UsageSection.tsx`.
- `UseSession.tsx`: removed credit members (`creditBalance`,
`creditSummary`, `hasSufficientCredits`, `updateCredits`,
`refreshCredits`, `fetchCredits`) + the credit types + global
credit-update callback. **Kept** `isPro`/`refreshProStatus` and the
Supabase auth subscription listener.
- `services/apiClient.ts`: removed the dead `x-credits-remaining`
handler + low-credit plumbing (token-refresh / PAYG / 401 logic
untouched).
- Credit refs removed from `ApiKeys.tsx`, `AppConfigModal.tsx`,
`auth/teamSession.ts`.
## Java (:saas)
**Deleted (15):** `UserCredit`(+repo),
`TeamCredit`(+repo)+`TeamCreditService`, `CreditService`,
`CreditHeaderUtils`, `CreditResetScheduler`, `CreditController`,
`CreditInterceptorConfig`, `UnifiedCreditInterceptor`,
`CreditSuccessAdvice`, `CreditErrorAdvice`, `CreditConsumptionResult` (+
the CreditController test).
**Edited — stripped legacy credit side-effects, preserved
auth/role/AI/PAYG logic:**
- `AiCreate`/`AiProxyController`: dropped the
`X-Credits-Remaining`/`X-Credit-Source` response header (its only
consumer, the desktop credit system, was already removed).
- `SaasTeamService`: dropped UserCredit/TeamCredit init on team-create +
seat-update.
- `SupabaseAuthenticationFilter` / `SupabaseSecurityConfig`: dropped
`getOrCreateUserCredits` on signup + the credit field/CORS header.
- `UserRoleService`: dropped `resetCycleAllocationForRoleChange`;
`ROLE_PRO_USER` grant/revoke preserved.
- proprietary `UserRepository`: dropped
`findUsersWithApiKeyButNoCredits()`.
- Tests updated to drop credit mocks/refs.
## Kept / scope
- `isPro` / `is_pro` RPC / `ROLE_PRO_USER` (that's the separate Group-4
/ EE effort) and **all PAYG** are untouched.
- **No DB tables dropped.** `user_credits`/`team_credits` stay until a
later **gated** migration — which this PR unblocks (the JPA entities
that pinned them are gone).
## Verify
`:saas:compileJava` + `:saas:compileTestJava` pass; FE `tsc --noEmit`
(saas) + eslint clean; 0 stray artifacts; no residual source refs to the
deleted classes.
## Follow-up (not in this PR)
`ErrorTrackingService` (+
`UserErrorTracker`/`ProcessingErrorType`/`CreditsProperties`) is now a
dead island — its only callers were the deleted interceptors. Safe to
delete, but it cascades beyond the credit scope, so it's a separate
tidy-up.
Targets `feat/desktop-cloud-saas-reuse`.
## What & why
Chained on top of #6649 (the `cloud/` refactor). The AI assistant was
effectively dead on desktop:
1. **Hidden** — `ChatFAB` gates on `aiEngineEnabled`, which desktop
reads from the **local** bundled backend's `/api/v1/config/app-config`.
The local backend has no AI engine, so the flag is always `false` and
the FAB never renders.
2. **Mis-routed** — even if shown, AI calls used `getApiBaseUrl()`,
which is empty/local on desktop, so the orchestrate stream and AI
result-file download missed the engine (which only runs in the cloud).
This PR wires AI properly **without hardcoding it on**, so the cloud
keeps the kill switch: flip `aiEngineEnabled` server-side and the
desktop FAB disappears on the next load — no desktop release required.
(Deliberately *not* assume-on, so a future "turn AI off" doesn't strand
shipped versions.)
## Changes
**General SaaS app-config service** (reusable for any cloud flag, not
just AI):
- `desktop/services/saasAppConfigService.ts` — SaaS-mode-only fetch +
5-min cache of the **public** `/api/v1/config/app-config` from the
**SaaS** backend over native HTTP (`@tauri-apps/plugin-http`, no CORS).
Returns `null` outside SaaS mode.
- `desktop/hooks/useSaasAppConfig.ts` — hook over it; reloads on
connection-mode change.
**AI gating + routing seams:**
- `useAiEngineEnabled()` — core reads `useAppConfig()` (web), desktop
reads `useSaasAppConfig()`. `ChatFAB` consumes it.
- `getAiBaseUrl()` — core uses the normal API base (web), desktop points
AI calls at the SaaS backend. `ChatContext` uses it for the orchestrate
stream + result-file download.
- `operationRouter` — route `/api/v1/ai/*` to the SaaS backend
(cloud-only prefix).
**Docs:** AGENTS.md gains a short "cloud feature flags on desktop" note
so the pattern is maintained.
## Verification
- `tsc --noEmit` green for saas / desktop / cloud flavors
- `eslint --max-warnings=0` clean (cloud-layer guardrail respected — the
platform-coupled bits live in `desktop/`)
- New `saasAppConfigService.test.ts` (3 tests) + existing
`operationRouter` / `tauriHttpClient` / `httpErrorHandler` suites green
- 0 stray compiled artifacts
## Not headlessly verifiable — needs a live Tauri smoke
The orchestrate **SSE stream** uses the webview's global `fetch` (native
HTTP can't stream the body the same way), so it's subject to browser
CORS to the SaaS backend. The `SupabaseSecurityConfig` tauri-origin
allowance (from #6649) covers it, but please confirm on a real build:
open the FAB in SaaS mode, run an agent task, watch the stream + a
result-file download succeed.
- Surface the real reason an MCP token is rejected: the 401's
WWW-Authenticate header now includes error_description
(audience/issuer/expiry), and a present-but-rejected token logs the
concrete OAuth2 reason. Tokenless 401s (the normal discovery handshake)
stay at debug.
- Add McpConfigValidator that sanity-checks MCP config at startup and
logs actionable warnings (missing issuer-uri/resource-id, unrecognized
auth mode, sub + require-existing-account, open access, scopes,
allow/block overlap) so misconfig shows up in the logs before a client
ever connects.
- Align the audience-rejection message to mention both resource-id and
accepted-audiences.
- Harden audit writes: hash JWT-shaped or over-long principals
(token:<sha256-prefix>) so the insert fits the column and never stores a
raw bearer token, and stop logging the raw principal on persist failure.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
One of the Playwright tests is flaky, despite several attempts to fix it
before it made it into main. This disables the test for now so a
followup PR can try to fix it again.
Closes#6518
# Cause of the bug
This is a fix to the #6518 issue. The bug happened because the link
toolbar was rendered inside the PDF page layer. That layer can be
affected by the viewer/page rotation transform, so the toolbar was laid
out using local page coordinates and then visually transformed together
with the page.
As a result, the placement logic could calculate a position that was
correct in the page’s local coordinate space, such as above or below the
link, but the parent transform would rotate or shift that result after
layout. On rotated pages, this could make the toolbar appear on the
wrong side, inverted, or misaligned relative to the link.
More specifically, in the PDF that exposed the bug, the page content
appears to have been authored upside down and then corrected with a
180-degree page/viewer rotation so it looks normal to the user.
Because the toolbar was rendered inside the same transformed page layer,
it inherited that 180-degree rotation as well. The PDF content looked
upright because the rotation was part of how the page was displayed, but
the toolbar is viewer UI and should not be rotated with the page. As a
result, the tooltip appeared upside down even though the PDF itself
looked correct.
# Description of Changes
Fixes the inverted link tooltip/toolbar positioning in rotated PDF
viewer pages.
The link toolbar is now rendered through a body portal and positioned
from the link element’s real viewport bounds, so page rotation
transforms no longer flip or misalign it.
The update also keeps the toolbar within the viewport during scroll,
resize, zoom, and rotation changes, preserves the hover delay between
the link and toolbar, centralizes the z-index in a shared constant, and
improves label sizing to avoid clipped text.
Note: The link hover styling was also changed from an underline to a
subtle rectangular highlight based on the PDF link annotation bounds.
<!--
Please provide a summary of the changes, including:
- What was changed
- Why the change was made
- Any challenges encountered
Closes #(issue_number)
-->
---
## Checklist
### General
- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [X] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
UI behaviour before the changes :
<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-12-10"
src="https://github.com/user-attachments/assets/321edbb3-42a2-4bc3-96ad-3ccc70a355b8"
/>
<img width="762" height="496" alt="Captura de tela de 2026-06-16
00-11-46"
src="https://github.com/user-attachments/assets/be4c1af4-5488-4a54-9b6f-675e3bea73b8"
/>
<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-12-57"
src="https://github.com/user-attachments/assets/60f44cd5-c772-44a8-97c8-bde135764e53"
/>
UI behaviour after the changes :
<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-22-02"
src="https://github.com/user-attachments/assets/dda77bda-0780-4807-a70d-3bbc60683e5a"
/>
<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-23-07"
src="https://github.com/user-attachments/assets/5745c37e-438a-4bbe-ba1e-c6f2098421de"
/>
<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-23-24"
src="https://github.com/user-attachments/assets/85932541-4a6f-48e4-879f-41f34a6d79e6"
/>
### Testing (if applicable)
- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---------
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
## What & why
Production reports of policy enforcement "hanging" traced to
large/many-page documents: the watermark step's flatten-to-image
(`convertPDFToImage`) on a 500+ page PDF takes minutes, exceeding both
the client poll cap and the backend per-step timeout. This makes the
slow case graceful instead of looking broken, and makes load-shedding
non-fatal.
### Poll runs to completion (no false "hang")
The client poll loop used a flat ~150s cap that was **shorter than the
backend's 300s per-step timeout**, so it abandoned long-but-healthy runs
mid-flight. The budget is now sized to the backend's real worst case —
`stepCount × per-step timeout + grace`, learned from the first status
report — so the client always polls long enough to surface the run's
**actual** terminal state (success or the backend's real error) rather
than a misleading client-side timeout.
### Per-step progress
The activity feed now shows `Enforcing… · step n/m` (from
`currentStep`/`stepCount`), so a slow step shows movement instead of a
dead spinner.
### Soft-retry on queue rejection
Under load the shared `JobQueue` rejects runs ("queue full"), which
previously surfaced as a hard failure needing a manual Retry. The
backend now tags that rejection with a stable `POLICY_QUEUE_FULL`
errorCode; the client treats it as transient backpressure and
**auto-retries the file in place** with exponential backoff (≈4s→64s, ~2
min), shown as a soft "Busy — retrying…" row, falling back to the manual
Retry only once the retry budget is spent.
## Testing
- **Frontend unit tests** (30 pass across the policies suite), including
a new `usePolicyAutoRun.retry.test.tsx` that drives the real controller
orchestration (poll → `POLICY_QUEUE_FULL` → relabel → backoff → in-place
re-dispatch), plus poll-budget, step-progress, and activity-feed relabel
cases.
- **Backend** `PolicyEngineTest` case asserting a queue-rejected run
carries the `POLICY_QUEUE_FULL` code.
- Typecheck clean on all three flavors (proprietary/saas/core); prettier
+ spotless clean.
- Poll-budget + progress + real-error surfacing were also verified live
end-to-end against a 599-page run (survived past the old cap, showed
step progress, reported the backend's real 300s-timeout failure,
recovered after a simulated network drop).
## Not included (follow-ups)
- The underlying flatten-to-image cost itself (bounded-memory/streaming
flatten, revisiting `convertPDFToImage` default and the 300s timeout) —
the real perf fix, deliberately out of scope here.
# Description of Changes
Fix Playwright failing test in SaaS (I think this is my third attempt
now so who knows if this will actually fix it for real this time, but
hopefully it does)
# Description of Changes
> [!warning]
> **Do not** squash this on merge. It should be merged via a merge
commit
Fixes conflicts in `pgvector_store.py`.
Also since codespell is failing, add comments to ignore the errors in
`sync_en_us_spelling.py`
---------
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
# Description of Changes
* Remove complex port selection logic from `engine.yml`. It's
inconsistent with the frontend & backend task files, and caused issues
with Docker, which have been worked around but would be simpler to just
get rid of the problem altogether
* Fix Ruff formatting of Python script
* Remove payg tests which are failing and have drifted too far from the
implementation to save directly
`PolicyController` was annotated `@PremiumEndpoint` (requires a
Pro-or-higher server license). Policies don't need a server-license
gate:
- On SaaS the server runs in Pro mode, so the check is always satisfied
anyway — it gates nothing in practice.
- Access is already governed by team scoping (#6632) plus the per-user /
guest gates.
So the annotation is dead weight and misleading. This removes it (and
its import) — a 2-line change.
## Verification
- `:proprietary:compileJava` succeeds; spotless clean. No other premium
gate on policy classes.
Guests (anonymous users on a login-enabled deployment) could open a
policy's setup/detail. Policies are an account feature, so a guest
clicking a policy should be nudged to sign up rather than opening it.
## Behaviour
A guest clicking a policy row — or a collapsed-rail icon — now
**re-summons the existing guest sign-up banner** ("You're using Stirling
PDF as a guest!…") and does **not** open the policy.
- `GuestUserBanner` listens for a `stirling:show-guest-banner` window
event and re-shows (even if previously dismissed; the render guard still
hides it for non-anonymous users).
- The policy sidebar dispatches that event on a guest click (cross-layer
via `CustomEvent`, same pattern as `payg:signupRequired`; a no-op on
builds without the banner).
- `usePolicyGuestBlocked()` gates it: `config.enableLogin === true &&
user.is_anonymous === true`.
- **Login-disabled single-user** deployments have an anonymous local
operator with full access → not gated.
## Verification
- Typecheck clean (proprietary + saas); eslint clean; sidebar tests
pass.
## Note
No dedicated guest unit test — the suite mocks `useAppConfig` at module
scope, and making it per-test controllable needs `vi.hoisted` plumbing
that risked the existing tests. Easy follow-up.
## Problem
We're getting 402s when an AI **agent** (chat) run hits the free
allowance / spending cap, but the frontend handles them poorly and never
pops the usage-limit modal.
The agent runs its tool calls **server-side** (loopback HTTP via
`PolicyExecutor`), so the 402 never reaches the `apiClient` interceptor
that pops the modal for direct calls. It was caught by the generic
tool-failure handler and flattened into a `CANNOT_CONTINUE` reason
string (`"The /api/v1/… tool failed: 402…"`), streamed as a `result`
event, and rendered as a scary chat bubble. This is the same gap the
policy auto-run path bridges (#6626) — one layer up.
## Fix
**Backend** (`proprietary`)
- `AiWorkflowResponse` gains `errorCode` + `errorSubscribed`.
- `AiWorkflowService` detects a downstream 401/402 entitlement sentinel
in its three tool-exec catch sites (`onToolCall`, `runPlan`,
`onConvertMarkdown`) and surfaces the structured code (+ `subscribed`)
on the terminal response instead of the raw failure text.
- Factored the 401/402 body extraction `PolicyEngine` already had into a
shared `DownstreamEntitlementError` util so the two server-side paths
can't drift.
**Frontend**
- New `usageLimitBridge` (`PAYG_LIMIT_REACHED_EVENT` +
`dispatchPaygLimitReached`) generalises the previously policy-only
bridge. Proprietary can't import the saas modal API (layering), so
server-side limit hits broadcast a window event the saas
`UsageLimitModalHost` opens the modal from. Migrated the policy path
onto it.
- `ChatContext` fires the matching modal (free → subscribe, subscribed →
raise cap) on the limit result **and** on a direct 402, replacing the
raw reason with a brief friendly line
(`chat.responses.usage_limit_reached`).
No Python engine changes — the charge/402 happens on the Java tool
endpoint that Java itself calls.
## Test plan
- [x] `:proprietary:compileJava` + `spotlessCheck` clean
- [x] `AiWorkflowServiceTest` + `PolicyEngineTest` green
- [x] eslint, proprietary + saas typechecks clean
- [ ] Manual: drive an agent run over the limit → brief line in chat +
the right modal (free vs cap)
> Note: proprietary test compilation is currently blocked on the
pre-existing `InitialSecuritySetupTest` 6-arg ctor break (unrelated,
tracked separately); verified locally by temporarily patching it.
add en-US changes to SaaS, previously merged into main. So this is
effectively a main -> SaaS PR also. It seems to be all additive.
Also take the 230 ish missing translations from en-GB over to en-US
using a script, and also make and english spellings American when adding
them to the en-US file, and fix any existing American spellings in the
en-GB file.
# Description of Changes
Search has got significantly worse since #6581, where I added all the
missing tags for tools that should have been there for months. Turns out
that the fuzzy matching search logic has always been way too permissive
to match words with Levenshtein distances way too far away from the
target word, so long searches include way too much stuff. The new tags
just exposed that underlying logic issue. This PR makes the Levenshtein
logic much stricter, so it is still tolerant to minor typos in tool
names, but doesn't match completely inappropriate strings.
Frontend follow-up to #6632 (team-scoped policies, editing gated to team
leaders on the backend). Brings the UI's edit gate in line.
## Problem
The policy config UI gated editing to `config.isAdmin`. On SaaS, org
users are never the single global admin, so **no one could open the
policy editor** — the same lockout #6632 fixed on the backend.
## Fix
`usePolicies` now allows a **team leader** to configure, falling back to
a global admin self-hosted:
```ts
canConfigure =
config != null && (!config.enableLogin || isTeamLeader || config.isAdmin === true);
```
- SaaS → `isTeamLeader` (from `useSaaSTeam()`) — team leaders can
configure; members get the read-only surface.
- Self-hosted → `config.isAdmin` (the core `useSaaSTeam` stub returns
`false`, so admins aren't locked out).
- Login disabled (single-user) → always allowed.
- The `config != null` guard keeps the gate closed until app-config
resolves, so edit controls never flash for users who can't use them.
The two locked-policy banners now read "Contact a team leader to change
this policy" (updated in the `t()` defaults and the `en-GB`
translations).
## Verification
- Typecheck clean (proprietary + saas); eslint clean.
- Tests pass: `usePolicies`, `PoliciesSidebar`.
## Problem
When the `EntitlementGuard` refuses a request with **402** (team is over
its free allowance / spending cap, or has no subscription to bill), the
handler never runs — so it must not charge. But it did: the guard (order
**1100**) ran *after* the charge interceptor (**1000**), so
`openProcess` had already written the charge before the 402, and
`afterCompletion` then billed it as "customer paid for the attempt" — a
ledger debit, and a **Stripe meter for a subscribed-over-cap team**.
## Fix
**Run the guard first** (order **900**, before the charge interceptor at
1000). Spring runs interceptors in ascending order on the way in and
**skips a later interceptor's `preHandle` (and `afterCompletion`)
entirely once an earlier one returns `false`** — so a refused request
short-circuits with its 402 *before* the charge interceptor runs at all.
A blocked request never opens a process, materialises inputs, or writes
a charge.
This replaces the earlier attribute-flag + `afterCompletion`-refund
approach with a simpler reorder (per review): the no-charge-on-block
guarantee is now **structural**, and it also avoids the wasted
open-then-refund churn (no temp-file write, no debit/refund pair) for
refused requests.
### Why the reorder is safe
- `EntitlementGuard` reads no `PaygChargeInterceptor` state and has **no
`afterCompletion`** (only `preHandle`), so reverse-order teardown is a
non-issue.
- The legacy `UnifiedCreditInterceptor` (default order **0**, and only
registered under the `legacy-credits` profile) still runs first, so any
legacy rejection wins.
- For *admitted* requests both interceptors still run (guard then
charge) — behaviour is unchanged; only refused requests now
short-circuit before the charge.
## Tests
`PaygWebMvcConfigTest` locks the `ENTITLEMENT_GUARD_ORDER <
INTERCEPTOR_ORDER` invariant (if it's ever reversed, refused requests
would bill again — this fails first). Existing `EntitlementGuardTest`
already proves the guard returns 402 on a degraded/billable request.
`:saas:test` + spotless green.
## Related (separate, in progress)
The **fail-cleanly + fire-the-modal** half (suppress the error toast,
trigger the subscribe/raise-cap modal via the existing
`subscribed`/`category` signal — catching the 402 centrally so direct
API usage is handled, and propagating the entitlement reason through the
async policy-run status) lands **with Ethan's modal** so we don't remove
the toast before there's a popup to replace it.
## Problem
Two AI surfaces slipped through PAYG unbilled:
1. **AI document tools** — `/api/v1/ai/tools/**`
(`PdfCommentAgentController`, `MathAuditorAgentController`) live in the
**proprietary** module, which can't depend on `saas` and so can't carry
the saas-only `@RequiresFeature`. They also lacked
`@AutoJobPostMapping`, so the charge interceptor's scope gate
short-circuited them **before** category resolution: **not charged, and
not even entitlement-gated** — whether called directly or dispatched by
the orchestrator.
2. **AI Create** — `/api/v1/ai/create` is JSON/session-based with no
file input, so the multipart charge path never fired. The old
per-generation charge ran through the now-dead legacy credit system, so
it currently charges nothing.
## Fix
- **`AiToolRoutes`** (new, saas) — single source of truth for the
`/api/v1/ai/tools/**` prefix. The proprietary controllers stay
untouched; the saas hot-path recognises them by path:
- **`PaygChargeInterceptor`**: brings these routes into scope and bills
them **AI** on a direct call. An orchestrator-dispatched call still
resolves to **AUTOMATION** first (the `X-Stirling-Automation` header is
checked before the path rule), so AI-tool-inside-a-workflow keeps
billing as automation.
- **`EntitlementGuard`**: gates them on **`AI_SUPPORT`**.
- This keeps the `proprietary → saas` layering intact (no backwards
dependency).
- **`JobChargeService.chargeStandalone(ctx, units)`** — charges a fixed
unit count for a non-file billable action, reusing the existing
free-grant split + shadow row + ledger debit + `close()`→meter path on a
standalone bookkeeping job (no lineage inputs, so nothing lineage-joins
it). **`JobService.open(ctx, docUnits)`** opens that bare job.
- **`AiCreateController.createSession`** — charges **one document per
session** at creation (best-effort; entitlement is already enforced
upstream by the class-level `@RequiresFeature(AI_SUPPORT)`). Follow-up
edits (`outline` / `reprompt` / `draft` / `template` / `stream`) carry
**no** charge — they have no charge hook, so "charge on create,
follow-ups free" falls out naturally.
Per the agreed scope: charge AI Create on create now; we can optimise
follow-up handling later. **AI workflow categorisation (AUTOMATION vs
AI) intentionally left as-is** (the orchestrator's automation header
dominates by design).
## Tests
- `PaygChargeInterceptorTest`: AI-tool route (no annotations) is in
scope + **AI** category; same route with the automation header →
**AUTOMATION**; a plain non-AI route still short-circuits.
- `EntitlementGuardTest`: AI-tool route is in scope + gated on
**AI_SUPPORT** (degraded team → 402; anonymous → 401 with `category:
AI`).
- `JobChargeServiceTest`: `chargeStandalone` charges + meters the paid
portion for a subscribed team, draws the free grant (no meter) for an
unsubscribed team, and rejects `BYPASSED`.
`:saas:test` + `:saas:spotlessCheck` green; coverage gates met.
## Follow-ups (not in this PR)
- Make AI Create follow-ups explicitly cheaper / chained if we want
(currently free by absence of a hook).
- Decide whether AI-tool-inside-a-workflow should bill as AI rather than
AUTOMATION.
## Problem
A **free team can invite a paid team's leader** to join. The leader
accepts, and:
- `acceptInvitation` moves them onto the inviting team and removes their
membership from the old team, but only deletes the old team if it's
**personal**.
- Their paid (non-personal) team is left **memberless but still
subscribed** — an orphaned Stripe subscription billing for a team nobody
is in.
## Root cause
Two gaps in `SaasTeamService.acceptInvitation`:
1. The pre-accept guard checks `hasPaidSubscription(acceptingUser)` →
`existsActivePaidSubscriptionForUser(supabaseId)`, keyed on
**`user_id`**. A team's plan is keyed on **`team_id`**
(`existsActiveSubscriptionForTeam`), so a paid team's *leader* isn't
caught and accepts freely.
2. The 'leave existing teams' loop deletes the membership directly and
only marks **personal** teams for deletion — it bypasses the last-leader
protection `leaveTeam` already enforces (`"Cannot leave as the last team
leader. Transfer leadership first."`), and never cleans up / cancels the
non-personal team.
There is no in-app subscription-cancel path (cancellation is
Stripe-portal/webhook driven), so nothing reconciles the orphan after
the fact — it has to be prevented.
## Fix
Add `assertCanLeaveCurrentTeamsToJoinAnother(user)`, called in
`acceptInvitation` before any membership changes. For each non-personal
team where the user is the **last leader**, the accept is rejected:
- team has an active subscription → *"Cancel the plan or transfer
leadership before joining another team."*
- otherwise → *"Transfer leadership before joining another team."*
This mirrors the protection `leaveTeam` already has and is
team/leadership-aware, closing the `user_id`-vs-`team_id` gap. Regular
members and teams with another leader are unaffected.
## Verification
- `ENABLE_SAAS=true ./gradlew :saas:compileJava` — passes.
- Manual: with a paid team leader, accepting an invite to another team
should now be rejected with the message above; verify a non-leader
member can still accept.
## Note
This prevents *new* orphans. Any teams already orphaned by this bug
(memberless, still subscribed) would need a one-off reconciliation —
happy to follow up with a query/cleanup if useful.
## Problem
A policy ran over a file but the owner's **free usage was never
consumed**.
A policy run executes on a **background virtual thread**
(`PolicyEngine.submit` → `asyncExecutor`), and Spring's
`SecurityContextHolder` is thread-local — so the worker thread has no
identity. When `PolicyExecutor` → `InternalApiClient.post` resolves the
tool-call API key via `UserService.getCurrentUsername()`, it finds
nothing and falls back to the **`INTERNAL_API_USER`** key. The loopback
tool calls then authenticate as that system account, so
`PaygChargeInterceptor` attributes the charge to *its* team (or none) —
the real owner's free grant is untouched. Folder-watch / scheduled
triggers are even further removed (fired from a background watch loop
with no request context at all).
The charging *mechanism* was fine (AUTOMATION, multipart,
`openProcess`); only the **attribution** was wrong.
## Fix
Propagate the acting identity onto the worker thread using the
**audit-principal MDC key** that `UserService.getCurrentUsername()`
already reads as its documented async fallback (the same mechanism used
for other async jobs). No new plumbing through the executor.
- **`runPolicy`** (stored policies — covers triggers *and* manual
`runWith`) → bill the **policy owner**. `Policy.owner` is the username
stamped at creation, so `getApiKeyForUser(owner)` resolves it.
- **`submit`** (ad-hoc Automate/AI one-offs) → bill the **submitting
user**, captured on the request thread (it doesn't survive the hop to
the worker otherwise).
With the principal set, `InternalApiClient` dispatches each tool call as
that user → the interceptor resolves the right team → free grant draws /
Stripe meters correctly.
## Tests
`PolicyEngineTest`:
- `runPolicyDispatchesToolCallsAsTheOwner` — asserts MDC
`auditPrincipal` == the policy owner at the moment
`InternalApiClient.post` is invoked.
- `adHocRunDispatchesToolCallsAsTheSubmittingUser` — asserts it's the
submitting user for an ad-hoc run.
`:proprietary:test` + `:saas:test` + spotless green; coverage gates met.
## Heads-up (not in this PR)
Once attributed, **automatic folder-watch / scheduled runs consume free
grant (or bill) per file** — set up once, runs forever. That's
automation-is-billable working as intended, but a set-and-forget policy
can drain an allowance fast, so it may warrant a per-policy cap or a
heads-up in the UI. Flagging for a product decision.
Follow-up to #6604 (merged). Builds the Security policy out so it
actually enforces, driven from the editor.
## What it does
- **Run on upload or export** — a single choice in the wizard: enforce
when a file is uploaded, or just before it's exported.
- **Output** — enforced result is a **new version** of the file
(default) or a **new file**, with optional filename
prefix/suffix/auto-number ("Output filename" subsection; auto-number
only for new files).
- **Export enforcement** — exporting an export-mode file runs the policy
first and downloads the enforced result; never hard-blocks (on failure
the original downloads). For new-version policies the in-editor file is
versioned too. Covers every export path incl. multi-file ZIP. A toast
(glowing in the policy's accent while it runs) reports progress and
fades after ~10s.
- **Affordances** — a freshly enforced file briefly glows its policy
accent and carries a shield badge.
- **Config tidy-up** — removed the unwired Security setting fields + the
wizard's review step; "Upgrade to enterprise" on locked categories;
category accent in the detail/wizard headers.
## Notes
- Builds on the manual-only (client-driven) policy model from #6587
(`trigger: null`, metadata in `output.options`), adding the `runOn`
field + export-time enforcement.
- The page-editor merge-export (no single source file) enforces +
downloads but doesn't version in place.
## Verification
typecheck (core + proprietary), eslint, prettier; proprietary suite
(105) green; flows checked in-app.
## Problem
When a user is invited to a team, the SaaS web app shows **no invitation
banner** — even though the pending invite is returned by
`/api/v1/team/invitations/pending` on refresh.
## Root causes
1. **Never rendered in SaaS.** `TeamInvitationBanner` only existed in
`desktop/`, wired solely into `DesktopBannerInitializer`. The SaaS
banner stack rendered only `<UpgradeBanner />`.
2. **Single banner slot.** `BannerContext` holds one node; `setBanner`
replaces it. `TrialStatusBanner` called `setBanner(null)` when there was
no active trial (and re-fired once `trialStatus` resolved async), wiping
any other banner.
3. **Shadowing was too fragile.** A first attempt shadowed the
proprietary `UpgradeBannerInitializer` from the saas layer, but
`vite-tsconfig-paths` resolves the `@app` specifier once at dev-server
start — a newly-added shadow of an already-resolved module isn't picked
up on a browser refresh, only a full restart. So the proprietary
initializer kept running and no invite banner appeared (while the SaaS
team context still fetched + populated the invite, which is why the
pending call was visible).
## Fix
- Add `saas/components/shared/TeamInvitationBanner.tsx` — ported from
desktop, minus the desktop `connectionMode` gate and explicit billing
refresh (SaaS `acceptInvitation` already refreshes credits + session).
- Render it **inline in `saas/routes/Landing.tsx`** next to
`GuestUserBanner` — a new import specifier in an existing file
(HMR-friendly), unambiguously inside `SaaSTeamProvider`, mirroring the
proven `GuestUserBanner` pattern. No dependency on the single banner
slot.
- **Remove `TrialStatusBanner`** (trials are being retired) so it can't
clobber banners. Also drops the stale mention from the stripe-lazy-load
test comment.
## Verification
- `tsc --noEmit -p tsconfig.saas.vite.json`: clean in touched files;
total unchanged from baseline (37 pre-existing, unrelated).
- Manual: pull + verify the Accept/Decline banner appears for an account
with a pending invite.
# Description of Changes
Kill off agents pane now that we have the FAB. Also fixes a bug with the
FAB where it would sometimes fail to render the chat, and fixes a
duplicated entry in the Vite config which was throwing a warning
## Problem
A team that **cancelled** its PAYG subscription kept full subscribed
access:
- **UI didn't reflect cancellation** — the Plan tab still rendered the
subscribed view, never the free/upgrade view.
- **Automation wasn't stopped** — automation / AI / API kept running
without ever falling back to the free-grant gate.
## Root cause
`TeamBillingService.compute` decided `subscribed` as:
```java
boolean subscribed =
subscriptionId != null
|| extOpt.map(PaygTeamExtensions::getStripeCustomerId).filter(s -> !s.isBlank()).isPresent();
```
On cancellation, the `customer.subscription.deleted` webhook calls
`payg_unlink_subscription`, which nulls `payg_subscription_id` but
**deliberately keeps `stripe_customer_id`** (so a future re-subscribe
can reuse the Stripe customer).
`payg_link_subscription` is the **only** writer of
`payg_team_extensions.stripe_customer_id`, and it writes it in the
*same* `UPDATE` as `payg_subscription_id` (on
`customer.subscription.created`). So the customer id is never set before
the subscription id — the "pre-webhook stand-in" the old comment claimed
**cannot happen**. The fallback only ever pinned a team that *ever*
subscribed to `subscribed` forever, because the Stripe customer outlives
the subscription.
Both symptoms are this one flag:
- `PaygWalletController` status → `SUBSCRIBED` vs `FREE`
- `EntitlementService` gate branch → monthly-cap vs free-grant
## Fix
Gate `subscribed` purely on `payg_subscription_id != null`. A cancelled
team now correctly drops to free (UI shows free; billable ops gate on
the one-time grant). This aligns the wallet/entitlement read with the
**meter path** (`JobChargeService.close`), which already gated on
`payg_subscription_id`.
Handles both Stripe cancel modes: "cancel at period end" keeps the sub
`active` (id stays set) until `.deleted` fires at period end → access
through the paid period; immediate cancel fires `.deleted` now → flips
to free now.
**No data migration / backfill** — already-cancelled teams have
`payg_subscription_id = NULL`, so they flip to free as soon as this
ships (within the 30s billing-cache TTL).
## Tests
Adds `TeamBillingServiceTest` — the `subscribed` computation previously
had **no** unit coverage (which is how this shipped). Covers: subscribed
iff subscription id present; **cancelled team (customer id remains,
subscription id null) ≠ subscribed** + free grant survives;
no-subscription/no-customer; no extension row.
`:saas:test` + `:saas:spotlessCheck` green; coverage gates met.
## Not included (optional hardening, can fast-follow)
- Cross-check the synced `stripe.subscriptions.status` to guard a
*missed* `.deleted` webhook leaving `payg_subscription_id` stale.
- Push cache-invalidation from the webhook (currently ≤30s TTL
staleness).
## What
Removes all em dash (`—`) characters from the **user-facing text** on
the Plan page (PAYG section), replacing them with colons, commas, or
restructured punctuation so the copy reads naturally.
## Changes
- `frontend/editor/public/locales/en-GB/translation.toml` — all `payg.*`
strings (this is what actually renders on the page)
- `PaygFree.tsx` — `t()` default fallbacks + the `{" — "}` JSX
benefit-list separators (now `{": "}`)
- `Payg.tsx` — `t()` default fallback for the editor-plan body
## Notes
- The en-dash range separator (`{{start}} – {{end}}`) in the
billing-period string is intentionally **kept** — only em dashes were
targeted.
- JSDoc / code comments containing em dashes were **left unchanged**,
since they aren't rendered text on the page.
<img width="990" height="502" alt="image"
src="https://github.com/user-attachments/assets/13d89b0f-007c-4b4c-b72d-1d912f968bc7"
/>
# Description of Changes
- Use pool for postgres connections
- Add ability to require user ID to be set on API calls to the engine
- Add process-wide concurrency cap on AI access (in addition to existing
user caps)
- Allow number of workers (threads) to be specified for stirling engine
- Update env var names to reflect that the DB is not just for RAG
# Description of Changes
Remove the Pro guards from the Team settings page and also fix the
styling of the MCP settings screen (the code sections were black text on
black background in light mode)
## Summary
Pay-as-you-go (PAYG) billing for Stirling-PDF SaaS. Manual PDF editing
stays free forever; only **automation, AI, and API** usage is metered.
Every team gets a **one-time lifetime free grant** (default 500 PDFs)
before any billing; past that, a team adds a card and pays per metered
document, with a self-set monthly spending cap.
This branch combines and supersedes the in-flight BE (#6574) and FE
(#6579) work plus the SaaS edge functions (Stirling-PDF-SaaS PR, now on
`v3`), hardened into a single reviewable feature after a pre-merge
dead-code/security review.
## Billing model
- **Always free:** manual / JWT web-tool usage is `BYPASSED` — never
metered, no matter where it's triggered.
- **Billable categories:** `AUTOMATION`, `AI`, `API`.
- **One-time lifetime free grant** (`pricing_policy.free_tier_units`,
default 500): never resets, survives subscribing. It gates unsubscribed
teams (billable API calls hard-stop with a 402 once exhausted) and
decides the free-vs-paid split of every job.
- **Subscribed:** paid documents (beyond the grant) are metered to a
Stripe Billing Meter; an optional monthly spending cap degrades billable
categories when reached.
- **Dedup:** the same file pushed through several steps within a
workflow window counts **once** (lineage join), so API/AI chaining on
one file isn't double-charged.
## What's included
**Database** — Flyway migrations `V11`→`V21` with matching Supabase
twins: pricing policy + per-team sidecar (`payg_team_extensions`:
subscription id, Stripe customer, free-grant counter), append-only
`wallet_ledger`, shadow charges, subscription-state RPCs (`V14`), audit
logs (`V15`), billing category (`V16`), one-time lifetime free grant
(`V19`), launch-grant seed (`V20`), drop of the unused
`wallet_category_summary` view (`V21`).
**Charge pipeline** — `PaygChargeInterceptor` (open/join a process,
split the free grant, write the ledger DEBIT), `JobChargeService`
(consume the grant under a row lock, restore it on a first-step refund,
meter only the paid portion on completion), `StaleJobCloser` fallback
(idempotent close → meter).
**Entitlement** — `EntitlementService` (per-team cached snapshot:
grant-gated for free teams, monthly-cap-gated for subscribed) +
`EntitlementGuard` (401 `SIGNUP_REQUIRED` / 402 `FEATURE_DEGRADED` /
`PAYG_LIMIT_REACHED`).
**Metering** — `PaygMeterReportingService` writes a durable
`payg_meter_event_log` row around every POST to the `meter-payg-units`
edge fn (pending → posted/failed); `PaygMeterReconcileScheduler` retries
unposted events under the same idempotency key inside Stripe's 24h dedup
window.
**Billing facts** — `TeamBillingService` reads the synced `stripe.*`
mirror (subscription window, per-document rate; the unsubscribed-team
estimate resolves the rate by Price `lookup_key = plan:processor`).
**Wallet API** — `PaygWalletController`: `GET /api/v1/payg/wallet`,
`PATCH /api/v1/payg/cap`.
**Frontend** — PAYG Plan page (two-card free layout + subscribed views),
`useWallet`, upgrade modal with lazy-loaded Stripe Embedded Checkout and
a shared `SpendCapControl`, customer-portal link, 402/401 interceptor
toast, en-GB i18n. (Per-member usage shows each teammate's spend; the
activity feed is behind a flag until polished.)
**SaaS edge functions** (`Stirling-PDF-SaaS` `v3`) —
`create-checkout-session`, `create-payg-team-subscription`,
`create-customer-portal-session`, `meter-payg-units`,
`payg-subscription-webhook`, `stripe-sync`, plus the stripe-sync
`migrate` + scoped-`backfill` scripts. All price lookup is DB-driven (no
`STRIPE_PAYG_PRICE_ID_*` env vars).
## Release prerequisites (prod)
1. Apply Flyway migrations (`V11`→`V21`) and the Supabase migration
twins.
2. Stripe Sync Engine: run `stripe-sync:migrate`, then a **scoped**
backfill — `product`, `price`, `customer`, `subscription` only (not
`all`, which rate-limits).
3. Register 2 PAYG webhook endpoints (each its own signing secret):
`stripe-sync` (product/price/customer/subscription `.*`) and
`payg-subscription-webhook` (`customer.subscription.created`/`.deleted`
drive state; `.updated` + `invoice.*` observed). Keep the legacy
`stripe-webhook` only if credits/self-hosted flows still run.
4. Stripe Billing Meter: `event_name = payg_doc_units`, value key
`processed_documents`.
5. Env: `PAYG_METER_ENDPOINT` + `SUPABASE_EDGE_FUNCTION_SECRET`
(backend); the webhook signing secrets (edge fns). The default pricing
policy must point at the PAYG Stripe Price(s); `V20` seeds
`free_tier_units = 500`.
## Testing
- `:saas:test` green, `:saas:spotlessCheck` clean, edge-fn Deno tests
green, FE saas typecheck clean (the remaining errors are pre-existing
`proprietary/*` + `prototypes/*`, untouched here). Cucumber shadow-mode
suite + CI workflow included.
## Pre-merge review
An independent dead-code/security pass came back **clean on security**
(team-derived authz / no IDOR, leader-only cap mutation, no
billing-category downgrade, dev/mock hooks gated to
`import.meta.env.DEV` + `/dev/`, no secrets/injection, fail-open
metering by design). The dead/unwired code it flagged has been removed
in this branch (unenforced sub-cap control, an unused JDBC DAO + its
view, dead methods).
## Follow-ups (tracked, not blocking)
- **Enforce per-member sub-caps** — the control was removed because it
read for display but never gated a request; the per-member usage display
and `cap_units` column are retained for when enforcement is wired.
- **API/AI chaining billing model + `ProcessType` enum** — confirm
same-file dedup covers API chaining; define per-tool AI charging; decide
whether the unused enum values stay.
- **Activity feed** — hidden behind a flag until the meter-event surface
is polished.
---------
Co-authored-by: Reece <reece@stirlingpdf.com>
Added the create agent. Use [these
prompts](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/blob/main/docgen/backend/default_templates/sample_prompts.md)
to test or try your own :)
Here’s the one I use
```
Hey, I need to generate an employee expense report for reimbursement.
Company: Summit Consulting Partners Company address: 88 Riverside Plaza, Suite 1400, New York, NY 10069 Accounting department email: expenses@example.com
Employee details:
* Employee Name: Michael Tran
* Employee ID: EMP-1047
* Department: Client Services
* Report Date: January 20th, 2026
* Reporting Period: January 5th, 2026 – January 16th, 2026
* Manager Approver: Laura Simmons
Trip purpose: Client onsite meetings with Atlantic Energy Solutions in Boston, MA.
Expense items:
* Flight (NYC to Boston roundtrip) — $325.40 — January 5th, 2026 — Airline ticket
* Hotel (3 nights at Harborview Hotel) — $822.75 — January 5th-8th, 2026
* Taxi from airport to hotel — $48.00 — January 5th, 2026
* Client dinner (3 attendees) — $186.20 — January 6th, 2026
* Parking at JFK Airport — $72.00 — January 5th-8th, 2026
* Breakfast (per diem not used) — $18.50 — January 7th, 2026
* Uber to client office — $22.10 — January 7th, 2026
* Printing + presentation materials — $46.90 — January 8th, 2026
* Lunch with client — $39.75 — January 8th, 2026
* Office supplies (notebooks, pens) — $27.60 — January 10th, 2026
* Mileage reimbursement (client visit in NJ, 42 miles @ $0.67/mile) — $28.14 — January 14th, 2026
* Team lunch meeting (internal) — $64.30 — January 15th, 2026
Reimbursement method should be direct deposit.
Add a notes section stating: "All receipts attached. Expenses are business-related and comply with company travel policy."
```
---------
Co-authored-by: Anthony Stirling <77850077+frooodle@users.noreply.github.com>
Follow-up to #6598 (squash-merged into `SaaS`). These are the policy
refinements made after that merge, against the current `SaaS` tip.
## Changes
- **Simplify Security config + plain-language info buttons** — Redact
config reduced to the PII field; Sanitise has no config
(JavaScript-removal only) with a non-technical info button; per-tool
info buttons reworded to match the tool-steps style.
- **Hide 'Flatten PDF pages to images' from the watermark policy
config** — new `PolicyWatermarkConfig` wrapping the watermark settings
with the flatten checkbox gated off.
- **Flatten-to-image on by default for redact + watermark** — both
normalise `convertPDFToImage: true` on mount.
- **Self-heal a stale backing folder** — `ensurePolicyFolder` recreates
a backing folder whose `folderId` no longer resolves (preferring the
backend's stored automation), instead of hanging Edit Settings on a
permanent "Loading…".
- **Version the input file on 'new version' output mode** — completed
runs whose policy output mode is `new_version` replace the input file
with a versioned child (origin tool `automate`) rather than adding a
separate file; falls back to a new file if the input is gone.
`outputMode` is plumbed through `PolicyState`, the local-cache default,
and backend reconciliation.
## Verification
- `typecheck:proprietary` + `typecheck:core` clean
- policy + hooks vitest: 17 passing
- eslint + prettier clean on all changed files
# Description of Changes
* Improve typing of API (breaking change but unreleased, frontend also
updated in this PR)
* Add ownership concept to policies
* De-AI the comments
* Update the `task dev:saas` rule to spawn the engine as well
A configured frontendUrl/server_url already includes the subpath (e.g.
/bpp), but the code also applied withBasePath, producing /bpp/bpp/...
Append the route directly to a configured URL; reserve withBasePath for
the bare-origin fallback. Matches the ShareFileModal convention.
## Summary
Adds the **Policies** feature (proprietary, behind the
`POLICIES_ENABLED` flag): backend-driven enforcement that runs a fixed
tool pipeline on documents, docked in the right tool sidebar alongside
Tools.
## Highlights
- **Policy catalog** — 5 categories; **Security** is wired (redact PII +
sanitize), the others are marked "Coming soon".
- **Backend as source of truth** — policies persist via the Policies
engine (`/api/v1/policies`), one policy per category, with a local cache
+ offline fallback.
- **Auto-run** — enabled policies run on every uploaded file: dispatch →
poll → import outputs into the workspace.
- **Security redact config** — PII preset dropdown + custom word/regex
entry + advanced options; tool params map to the backend endpoint
fields.
- **Activity feed** with retry on failures; **file badges** showing
which policies ran on a file (sidebar + files page), tinted to the
policy colour.
- Reuses the **Watched Folders** engine for each policy's backing
folder; policy-owned folders are filtered out of the Watched Folders UI.
## Notes
- Gated by `POLICIES_ENABLED` (true in proprietary, false in core) —
unreachable in the open-source build.
- Frontend-only diff; depends on the backend Policies engine and the
merged Watched Folders feature.
# Description of Changes
Add team settings UI to SaaS, which is currently only available in
desktop. It'd be nice to refactor this so they're more shared, but
they're slightly different so needs to be done with some care. Leaving
for followup work.
The home page's bottom-left settings button is FileSidebar's bottom
bar, which hardcoded an initials circle - the avatar work in
useConfigButtonIcon only affects the QuickAccessBar rail, which the
home page doesn't render. Add a layered useProfilePictureUrl hook
(core stub returns null; saas returns the auth context URL) and render
the picture inside the existing avatar circle, falling back to the
initial when absent or on image load failure.
The saas chain authenticates bearer requests twice:
SupabaseAuthenticationFilter builds an EnhancedJwtAuthenticationToken
with the resolved User principal, but BearerTokenAuthenticationFilter
(oauth2ResourceServer) then re-authenticates the same token through the
static toAuthentication converter and overwrites the SecurityContext
with a token whose principal is the raw Jwt - so storage endpoints kept
returning 401 "Unsupported user principal" despite the principal fix.
Carry the User across in the converter: when the context already holds
an EnhancedJwtAuthenticationToken for the same subject with a User
principal, attach that User to the converter-built token. No extra DB
lookups; anonymous sessions and API-key auth unchanged. Covered by new
unit tests (carry, no-context, subject mismatch).
The bottom-left settings button and the settings page both read
profilePictureUrl, but only the settings page had a fallback (initials
avatar) - the button silently fell back to a gear. The URL itself was
usually null because fetchProfilePicture raced the background OAuth
avatar sync with a fixed 500ms delay and never retried, and a missing
bucket object simply resolved to null.
- useConfigButtonIcon: fall back to the same initials avatar as the
settings page instead of the gear when no picture URL is available.
- UseSession: fetch the profile picture when syncOAuthAvatar settles
(init and SIGNED_IN) instead of after an arbitrary 500ms.
- fetchProfilePicture: when the bucket copy is missing, fall back to
the OAuth provider's own photo URL so the picture shows immediately
on first login - unless the user explicitly uploaded/removed a
picture (metadata source 'upload'), preserving the remove flow.
FileStorageService.requireAuthenticatedUser and
FolderService.requireAuthenticatedUser authorize via
'principal instanceof User', but EnhancedJwtAuthenticationToken extends
JwtAuthenticationToken whose principal is the decoded Jwt - so every
/api/v1/storage/* request 401'd for JWT users AFTER Spring Security had
already authenticated them. This persistent 401-with-valid-session was
the trigger feeding the frontend login loop.
Attach the filter-resolved local User as the token principal for full
accounts (User implements UserDetails, matching the form-login
convention every shared instanceof check expects). Anonymous sessions
keep the raw Jwt principal, preserving their existing exclusions. All
other principal consumers verified safe: AuthenticationUtils checks
instanceof User first, extractSupabaseId/CreditController/Team
SecurityExpressions switch on the authentication type, not the
principal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit of every code path that can produce the login->/->login cycle
found the observed loop was one instance of a repeatable class: any
automatic API call that persistently 401s while the Supabase session is
valid triggers httpErrorHandler's hard redirect to /login, which sees
the valid session and bounces back. Close the class, not just the
instance:
- saas apiClient: a 401 that survives a refresh-and-retry means the
backend rejected a valid token (authz bug / wrong origin), not an
expired session - never redirect to /login for it. Also fix the stale
publicEndpoints entry ('endpoints-enabled' matched nothing; the real
routes are endpoints-availability and endpoint-enabled).
- httpErrorHandler: sessionStorage loop breaker - if a 401 redirect
already fired within 10s, suppress the repeat instead of cycling.
- Guard the remaining unflagged automatic callers: /api/v1/credits
(fires on session init and TOKEN_REFRESHED), endpoints-availability
(fires on app load), and ui-data/login (auto-called when a stale
stirling_jwt is present).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The deployed app looped /login -> / -> /login forever: Login sees a
valid Supabase session and navigates to /, the global FolderProvider
pulls GET /api/v1/storage/folders, the backend rejects it with 401, and
the global error handler hard-redirects back to /login?from=/bpp.
fileSyncService's /api/v1/storage/files pull already opts out via
suppressErrorToast + skipAuthRedirect, so its 401 fails silently;
folderSyncService.list() passed neither flag, so its 401 fell through to
the redirect. Add the same flags - FolderContext.pullFromServer already
handles 4xx locally (flips serverReachable, suppresses the banner).
Note: the underlying 401 on /api/v1/storage/* with a valid session is a
backend/deployment issue (storage endpoints rejecting the Supabase
token); this change makes the frontend resilient so it degrades to
"folder sync unavailable" instead of an auth loop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two issues seen on the hosted /bpp login screen:
1. GET /api/v1/storage/folders fired (and 401'd) on the login page. The
global FolderProvider pulls from the server whenever
appConfig.storageEnabled is true, with no auth gate, so it hits the
authenticated storage API before the user has signed in. Skip the pull
on auth routes (/login, /signup, /auth/*, /invite, /reset-password),
mirroring the existing LicenseContext / AppConfigContext guards. Tests
wrap FolderProvider in MemoryRouter (now uses useLocation).
2. manifest.json and modern-logo/favicon.ico 404'd from the domain root
instead of /bpp/. vite base for RUN_SUBPATH deploys was "/bpp" with no
trailing slash, so <base href="/bpp"> made the browser resolve relative
links against the parent (root). Use "/bpp/"; getBasePath() strips the
trailing slash, so BASE_PATH, routing and asset URLs are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Description of Changes
Add new triggers:
- Schedule (fires every X amount of time)
- Folder watch (fires whenever the OS tells us a folder has a new file
in it; on Mac this is technically a 2s schedule but that's just how Java
implements it)
Add new sources:
- Folder (reads from this directory)
Add new sinks:
- Inline (stores in FileStorage)
- Folder (stores in specified directory)
Still want to do S3 buckets and web hooks and stuff, but they can come
in a future PR. I'm hoping this should make it sufficient to be able to
integrate with processing folders frontend etc. I've also changed it so
that policies can have multiple sources and triggers at once, which
seems like it might be useful.
The console warned "Multiple GoTrueClient instances detected in the same
browser context" and storage endpoints (/api/v1/storage/folders,
/files) kept 401ing even after a successful token refresh.
Cause: the SaaS bundle instantiated TWO Supabase clients on the same
sb-<ref>-auth-token storage key. :saas/auth/supabase.ts creates the
primary client (used by UseSession + apiClient), while billing /
licensing / user-management code imports @app/services/supabaseClient,
which fell through to :proprietary/services/supabaseClient.ts and called
createClient() again. Each client runs its own autoRefreshToken timer,
so they rotate the refresh token out from under each other → "Already
Used" refresh failures and spurious 401s, plus a residual /login flash.
Add a :saas override of @app/services/supabaseClient that re-exports the
single instance from @app/auth/supabase. The path mapping
(@app/* → src/saas/* → src/proprietary/* → src/core/*) now resolves
every consumer to the same client, so the :proprietary createClient() is
never bundled in the SaaS build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On returning to the app with an expired Supabase access token, bootstrap
requests fired with the stale token and 401'd before Supabase finished
refreshing. The global 401 handler then hard-redirected to
/login?from=… (a full window.location navigation), and once the refresh
landed the app sent the user straight back in — the login/logout/login
flicker.
Two holes in the SaaS apiClient response interceptor caused it:
1. "public" endpoints (e.g. /api/v1/config/app-config) skipped the
refresh-and-retry path. The backend 401s any expired Bearer token
regardless of route, so those bootstrap calls 401'd and fell through
to handleHttpError, which redirected to /login. Now public endpoints
also refresh-and-retry, and a 401 on a public endpoint sets
skipAuthRedirect so it can never trigger the global login redirect.
2. Concurrent 401s each called supabase.auth.refreshSession()
independently. Supabase rotates the refresh token on first use, so
the racing refreshes failed with "Invalid Refresh Token: Already
Used" and bounced the app even though the session was recoverable.
Refreshes are now de-duplicated through a single in-flight promise.
Existing apiClient unit tests (refresh-and-retry on protected 401, bare
/login redirect on genuine refresh failure) are preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Description of Changes
Adds all the missing translations that I could find (they're all dynamic
ones that the existing test can't detect are required) and adds a new
test to find as many unused translations as possible. The test has an
ignore list for translations that are used, but dynamically so the test
can't find them (most of the settings UI translations are built up
dynamically like that).
This PR is scoped to just include en-GB translation changes, since
that's the main supported language. We'll need to do a translation PR to
trim all the dead keys from the other languages, and add the missing
ones.
Adds an optional MCP server (proprietary module) that exposes Stirling's
PDF operations and AI capabilities to MCP clients. Off by default, zero
footprint when disabled.
### What
- New `/mcp` endpoint: streamable-HTTP + JSON-RPC 2.0; 8 tools
(describe_operation, pages/convert/misc/security category tools, AI,
upload, download).
- Runs real operations over an internal loopback; results returned
inline as base64 (small) or by fileId (large).
### Auth (two modes)
- OAuth2 resource server: RFC 9728 protected-resource metadata, RFC 8707
audience binding, JWKS, `mcp.tools.read/write` scopes; binds each token
to a provisioned Stirling account.
- API-key mode: reuses Stirling per-user `X-API-KEY` (no IdP needed).
### Security
- Per-user file ownership in FileStorage: async/queued writes scoped to
the submitting user; legacy/owner-less files stay readable.
- Admin allow/block list controls which operations are exposed.
- Python engine gated behind a shared secret (`X-Engine-Auth`).
- MCP filter chain is isolated and cannot weaken the main app's
security.
- Hardened: no upstream error-body leakage, log injection sanitized,
fileId path/sidecar enumeration blocked.
### Config / footprint
- Disabled by default (`mcp.enabled=false`); all beans
`@ConditionalOnProperty`.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
## What this PR does
Bundles the **low-risk polish items** from the [multi-agent review of
#6519](https://github.com/Stirling-Tools/Stirling-PDF/pull/6519). Each
change is independent, mechanical, and ships with focused unit-test
coverage.
The medium-severity items (\`?async=true\` OUTPUT recording,
JSON-consumes endpoint coverage, SpringBootTest harness) are tracked
separately in [\`notes/PAYG_DESIGN.md\` §7.5
PR-S4](https://github.com/Stirling-Tools/Stirling-PDF/blob/payg-s4-hardening/notes/PAYG_DESIGN.md)
— they need design decisions + bigger infrastructure work, so this PR
sticks to the mechanical wins.
Stacked on #6519. When that merges to main, this rebases cleanly — no
code changes.
## Changes
| Area | What | Why |
|---|---|---|
| **\`tool_id\` becomes route pattern** |
\`PaygChargeInterceptor.resolveToolId()\` prefers
\`HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE\` over
\`request.getRequestURI()\`. Truncates to 128 + WARN log +
\`payg.filter.errors\` increment when truncation fires. | Audit rollups
aggregate by endpoint instead of by every individual request's
path-variable / matrix-param variant. Silent truncation now louder. |
| **Direct PDF magic-byte check** | \`PaygOutputExtractor.extract()\`
magic-checks the body even for direct \`application/pdf\` responses. |
Asymmetric with the ZIP-entry path which always magic-checks. A tool
that emits \`application/pdf\` for a JSON / HTML payload would otherwise
pollute \`job_artifact_hash\`. |
| **DESKTOP_APP detection** | \`X-Stirling-Client: desktop\` header →
\`JobSource.DESKTOP_APP\`. | The enum value was unreachable from
\`determineSource()\`; Tauri shell traffic was mis-classified as WEB. No
anti-spoof — V12 step limits are identical for WEB/DESKTOP_APP so the
worst-case abuse value is zero today. |
| **\`max-bytes\` sensible default** | 500 MiB instead of \`null\`
(unbounded). | Covers the largest realistic Stirling responses (full
split-to-ZIP on a 1000-page document) while preventing pathological
cases from tying up the interceptor for minutes. Set to \`null\` to
disable. |
| **\`BufferedOutputStream\` for spill** | Wraps the spill
\`OutputStream\` in 64 KiB \`BufferedOutputStream\`. | Previously every
Tomcat chunk (default 8 KiB) was a separate syscall. Big spilled
responses get a syscall-bound speedup. |
| **Duration timer per phase** | \`payg.filter.duration\` tagged
\`phase=preHandle\` vs \`phase=afterCompletion\`. | Two distinct latency
distributions were blended into one histogram; hard to alert on. |
## Tests
| Test | What it covers |
|---|---|
|
\`PaygOutputExtractorTest.pdfContentType_butBodyMissingPdfMagic_returnsEmpty\`
| New direct-PDF magic-byte gate. |
|
\`PaygChargeInterceptorTest.preHandle_desktopClientHeader_setsJobSourceDesktopApp\`
| New \`X-Stirling-Client: desktop\` → DESKTOP_APP path. |
|
\`PaygChargeInterceptorTest.preHandle_toolId_prefersBestMatchingPattern\`
| Route pattern wins over URI when both are set. |
|
\`PaygChargeInterceptorTest.preHandle_toolId_truncatesAndCountsWhenLongerThan128\`
| Oversized values truncate + increment errors counter. |
Full saas suite green (210 tests), coverage targets met.
## What's NOT in this PR (deliberately)
- **\`?async=true\` OUTPUT recording.** The JobExecutorService returns a
synchronous \`JobResponse{jobId}\` body before the async tool actually
runs; \`afterCompletion\` fires too early. Needs a design decision:
short-circuit PAYG when \`async=true\` OR hook into \`TaskManager\`
completion. Tracked in PR-S4 design doc.
- **JSON-consumes endpoint coverage.** The
\`MultipartHttpServletRequest\` cast skips endpoints with \`consumes =
APPLICATION_JSON_VALUE\` (e.g.
\`ConvertPdfJsonController.exportPartialPdf\`). Fix is either extract a
request-body hash for JSON or add a CI lint forbidding non-multipart
\`@AutoJobPostMapping\`. Design discussion needed.
- **SpringBootTest harness for filter + interceptor wiring.** Saas
module doesn't have one yet. Separate work — PR-S3 takes a different
approach (docker-compose + Behave); a SpringBootTest layer would be
additive in-process coverage.
These are tracked in \`notes/PAYG_DESIGN.md §7.5\` so they don't slip.
## Tracked in
\`notes/PAYG_DESIGN.md\` §7.5 PR-S4.
### Description of Changes
This Pull Request was automatically generated to synchronize updates to
translation files and documentation. Below are the details of the
changes made:
#### **1. Synchronization of Translation Files**
- Updated translation files
(`frontend/editor/public/locales/*/translation.toml`) to reflect changes
in the reference file `en-GB/translation.toml`.
- Ensured consistency and synchronization across all supported language
files.
- Highlighted any missing or incomplete translations.
- **Format**: TOML
#### **2. Update README.md**
- Generated the translation progress table in `README.md` using
`counter_translation_v3.py`.
- Added a summary of the current translation status for all supported
languages.
- Included up-to-date statistics on translation coverage.
#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.
---
Auto-generated by [create-pull-request][1].
[1]: https://github.com/peter-evans/create-pull-request
---------
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <anthony@stirlingpdf.com>
## Summary
Two Flyway migrations + matching JPA entity updates. **Part 1 of 2** in
the Stripe/Supabase wire-up (PR-SB-1 in
`payg-stripe-supabase-plan.html`); the companion SaaS PR carries the
twin Supabase migrations + new edge functions.
### V14 — payg_subscription_state.sql
- `payg_team_extensions.payg_subscription_id` — the single switch that
decides whether a team is billed. NULL = free-tier or block; NOT NULL =
post Stripe meter events.
- `pricing_policy.free_tier_units_per_cycle` — per-policy free allowance
before a card is required. Default 0.
- `payg_link_subscription(team_id, customer_id, sub_id)` RPC —
idempotent.
- `payg_unlink_subscription(team_id, reason)` RPC — called on
`subscription.deleted`.
- AFTER-INSERT trigger on `teams` so every new signup gets a
`payg_team_extensions` sidecar row from creation.
- Backfill for existing teams without a sidecar row.
- RLS: SELECT permissive (any team member), UPDATE restricted to LEADER.
Service-role bypasses (backend reads + day-1 migration writes).
### V15 — payg_audit_logs.sql
- `payg_meter_event_log` — backend audit of every Stripe meter event
POST attempt (idempotency-key UNIQUE; index on unposted rows for nightly
reconcile).
- `payg_subscription_change_log` — written by V14 RPCs on every
link/unlink.
### Entity updates
- `PaygTeamExtensions.paygSubscriptionId` — read-only field; RPC
functions are the only writers.
- `PricingPolicy.freeTierUnitsPerCycle` — read by upcoming
`PaygTeamUsageService` (PR-SB-4).
### Behaviour change
**None yet.** The columns + functions sit unused until PR-SB-4 wires
`PaygMeterReportingService` and the free-tier gate into
`JobChargeService`. This PR is pure schema + JPA wiring.
## Test plan
- [x] `./gradlew :saas:test` — BUILD SUCCESSFUL
- [x] Manual schema review: column types, FK directions, RLS scope
- [ ] Apply against v3-Supabase via `supabase db push` (after companion
SaaS PR merges)
- [ ] Smoke-test the trigger: `INSERT INTO teams(...)` → assert
`payg_team_extensions` row appears
- [ ] Smoke-test RPCs: SQL-only test of `payg_link_subscription` +
`payg_unlink_subscription` produces expected row + audit entries
## References
- `notes/PAYG_DESIGN.md` (revision note 2026-06-03)
- `payg-stripe-supabase-plan.html` §3.1 (RPC functions), §3.5 (RLS),
§3.10 (audit-log tables)
## What this PR is
End-to-end cucumber coverage for the PAYG shadow charging engine (the
filter + interceptor stack from #6519), wired into CI via a new
`docker-compose-tests-saas.yml` workflow that runs only on PAYG-touching
PRs.
Stacked on #6519.
## Automated scenarios (run by `docker-compose-tests-saas.yml`)
See
[`testing/cucumber/features/payg/shadow_charges.feature`](../tree/payg-s3-cucumber/testing/cucumber/features/payg/shadow_charges.feature):
| Scenario | Validates |
|---|---|
| First tool call writes a CHARGED row | Filter + interceptor fire
end-to-end |
| Lineage join — second call on output | `JobService.joinOrOpen`
matching; no new shadow row |
| 4xx leaves the row CHARGED | "Customer paid for the attempt" semantics
|
| ZIP-returning tool records per-PDF OUTPUT | `PaygOutputExtractor`
unpacks + records signatures |
| Multi-file input writes a single shadow row | Multi-input group sizing
|
| `X-Stirling-Automation` sets PIPELINE source | Header → `JobSource`
detection |
All 6 run locally via `./testing/test-payg.sh` and will run on CI for
any PR that touches `app/saas/**`, the PAYG cucumber features, the saas
compose stack, or the workflow itself.
## Manual-only scenarios — documented in design doc, not in this suite
Two parts of the shadow engine are deliberately not automated; the
engine paths are unit-tested in
`PaygChargeInterceptorTest.afterCompletion_5xx_opened_*`, and the manual
procedures (which require a temporary throw endpoint or a container
restart with a flag flipped) live in [`notes/PAYG_DESIGN.md` §7.5.2
"PAYG cucumber: manual-only
scenarios"](../tree/payg-s3-cucumber/notes/PAYG_DESIGN.md).
- **5xx first-step failure → REFUNDED + CLOSED.** No reliably-5xx-ing
endpoint exists; manual procedure adds a throw endpoint, runs, asserts,
removes.
- **Kill-switch (`PAYG_FILTER_ENABLED=false`).** Needs a container
restart mid-suite; manual procedure tears down, flips env, brings up,
asserts zero shadow rows.
If either gets a hot-reload path (test-only throw endpoint shipped
behind a profile gate, or admin endpoint for the kill switch), automate
it in a follow-up and drop the manual procedure.
## CI workflow
`.github/workflows/docker-compose-tests-saas.yml` (new) —
self-contained, not wired into `build.yml`'s `files-changed` matrix so
the saas-cucumber job fails and succeeds independently. Triggers only on
PAYG-relevant paths. No JaCoCo coverage in v1 (saas compose doesn't have
the coverage override; can add later).
## Test infrastructure (recap)
- **`testing/compose/docker-compose-saas.yml`** — Stirling-PDF backend
with `STIRLING_FLAVOR=saas` + Postgres holding the `stirling_pdf`
schema. Supabase JWT auto-config disabled; API-key auth via
`SECURITY_CUSTOMGLOBALAPIKEY` is the live path the cucumber tests
exercise.
- **`testing/compose/payg/saas-init.sql`** + **`saas-seed.sql`** —
schema bootstrap + idempotent seed (team / user / wallet_policy).
- **`testing/cucumber/features/payg/shadow_charges.feature`** — the 6
scenarios above.
- **`testing/cucumber/features/steps/payg_step_definitions.py`** — step
defs using `requests` (HTTP) + `psycopg` (direct DB inspection). Direct
DB reads are deliberate — we want to see the filter's side effects, not
relay them through another API layer.
- **`testing/test-payg.sh`** — companion runner to `testing/test.sh`.
Brings up the saas compose, waits for health, seeds, runs behave, tears
down.
- **`behave.ini`** excludes `features/payg` from the default behave run
(the saas-cucumber CI job invokes it explicitly).
## Why a separate harness from `testing/test.sh`
The existing `test.sh` covers the proprietary-flavour stack (no PAYG
tables, no saas profile). Coupling two CI matrices that fail and succeed
independently into one script is asking for trouble. Keep the
saas-cucumber job focused on its own concerns; once the harness is
mature, the wider team can decide whether to merge them.
## Tracked in
`notes/PAYG_DESIGN.md` §7.5 (PR-S3) + §7.5.2 (manual scenarios).
# Description of Changes
- Tool action button truncation - fixed by allowing Mantine <Button>
label to wrap (whiteSpace: normal, height: auto) instead of clipping
- Role badge truncation on People page - fixed by dropping the column's
fixed w={100} and letting the badge size to its content
- Settings nav item wraps to 3 lines - fixed by hiding the inline ALPHA
badge by default and revealing it on :hover/:focus-within/.active
- Zoom slider cramped on narrow desktop - fixed by removing the
toolbar's hardcoded minWidth: 30rem and giving the slider flexShrink: 0
+ minWidth: 6rem
- "Swipe left or right" hint on desktop - fixed by adding a useIsTouch()
hook (pointer: coarse) and gating the hint on isMobile && isTouch
- Logout doesn't redirect - fixed by replacing navigate('/login') with
window.location.assign('/login') in a finally block so auth context
fully re-bootstraps
- Viewer top toolbar clips icons on mobile - fixed by switching the
wrapped state to justify-content: flex-start + overflow-x: auto so the
icon strip is momentum-scrollable
- Mobile bottom toolbar overflows - fixed by gating layout on
useIsPhone() and reducing the inline bar to prev / page / next / ⋮ only
- Lost controls when shrinking mobile toolbar - fixed by adding a
Mantine <Menu> behind ⋮ that groups First/Last page, Zoom in/out (with
%), Dual-page, Dark/Sepia filter under Page navigation / Zoom / View
labels
- "Upload from computer" label clipped on hover - fixed by unmounting
the Add Files button entirely while Upload is hovered, so Upload claims
width: 100%
- Settings rows clip controls off-screen - fixed by adding flex: 1,
minWidth: 0 to the inner text-block <div> on 44 rows across 10 -files,
so labels shrink and wrap while controls stay anchored to the right
---
Screenshots
[report-before-after.html](https://github.com/user-attachments/files/28687621/report-before-after.html)
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
Disallow warnings and errors from being thrown in the browser console
during tests unless explicitly expected in the test. Also adds a
Playwright test to prod around some main UI areas and checks that no
warnings/errors have been thrown.
# Description of Changes
The changes in
[#6279](https://github.com/Stirling-Tools/Stirling-PDF/pull/6279) broke
the desktop app because the wasm URL handling didn't deal with
`tauri://` paths. Also I noticed that `task desktop:build:dev:mac`
failed locally because it was attempting to sign the app with
credentials that developers won't have (and shouldn't need), so I fixed
that too.
# Description of Changes
Add a backend for running any multi-step PDF operations. This is
designed to be used for the upcoming Policies feature, along with
anything else that will require automated running of PDF operations,
like the Automate tool or Processing Folders.
The implementation is not complete. I've tried to get all the
infrastructure in there so that we can add in whichever triggers we need
in the future (like cron triggers or watching folders on disk) but
currently it just supports manual triggering of the policy.
The basis of this work was the operation running from the Stirling
Engine, which this PR removes in favour of this new system. The only
currently accessible frontend way to test this work is to ask the AI
chat to execute multiple operations on a PDF, but I've also extensively
tested with direct API calls to make sure that the policies work and
persist properly.
## Summary
Make the remaining static ENTERPRISE badges in the admin settings
clickable so they navigate the user to `/settings/adminPlan`, matching
the pattern already used by the PRO badges in Connections / Features /
General sections.
### Before
Two ENTERPRISE badges were inert text chips with no affordance:
- `AdminSecuritySection.tsx` - Audit Logging
- `AdminDatabaseSection.tsx` - Database section header
### After
Both now use the same pattern as the existing clickable PRO badges:
- `cursor: pointer`
- `onClick={() => navigate("/settings/adminPlan")}`
- `title` tooltip with the existing
`admin.settings.badge.clickToUpgrade` i18n key ("Click to view plan
details")
No new strings, no new components - just wiring up existing behavior to
the two badges that were missing it.
### Existing already-clickable badges (kept identical for reference)
- `AdminConnectionsSection.tsx:585-596` - SSO Auto Login PRO
- `AdminFeaturesSection.tsx:175-186` - Server Certificate PRO
- `AdminGeneralSection.tsx:920-931` - Custom Metadata PRO
## Summary
- Set `dragDropEnabled: false` on the Tauri window so HTML5 drag events
reach the WebView. Previously the default `true` made Tauri intercept
all drag-drop at the OS level, silently breaking in-page drag-to-reorder
(Pragmatic Drag and Drop in `FileEditorThumbnail` /
`useFileItemDragDrop`) in the desktop build. The Active Files tab
reorder, which feeds Merge ordering, was the user-visible symptom.
- Browser builds are unaffected (tauri.conf.json is desktop-only).
- The OS file-drop pipeline now flows through the existing Mantine
`Dropzone` in `FileEditor.tsx` via HTML5 events instead of the Rust
`WindowEvent::DragDrop` handler in `lib.rs:215`. Verified working.
## Test plan
- [x] Desktop: drag a thumbnail in Active Files past another - row goes
semi-transparent, order updates on drop.
- [x] Desktop: drag a PDF from File Explorer onto the window - file is
added.
- [x] Web build: drag-to-reorder still works (unchanged code path; flag
is desktop-only).
- [x] Merge tool: order set by drag in Active Files is the order used by
the merge output.
## Follow-up (not in this PR)
- `WindowEvent::DragDrop` arm in
`frontend/editor/src-tauri/src/lib.rs:215-229` is now unreachable for
window drops. The `forward_files_to_window` helper still serves the
macOS Finder "Open With" path (`RunEvent::Opened` at lib.rs:230), so
only the DragDrop arm can be deleted. Worth a small cleanup pass later.
## Summary
Audit + bulk fix of hard-coded English UI strings - `aria-label`,
`title`, `placeholder`, `label`, and raw JSX literals that bypassed i18n
entirely. Each literal now goes through `t("key", "English Default")`
from `react-i18next`, and every new key has a corresponding entry in
`en-GB/translation.toml` so translators can pick it up.
## What this fixes
Strings were rendered untranslated in every non-EN locale because they
never went through `t()` at all (not just "value not translated yet").
Affects screen-reader labels, tooltips, form placeholders, empty/loading
states, plan card content, and the entire workflow ParticipantView.
## Coverage (~143 keys / 50 files)
- **Viewer chrome** - search bar (close, clear, prev/next, "of N"
results), link/signature/redaction actions, viewer error state, zoom
labels
- **Page editor** - undo/redo/rotate/delete toolbar tooltips, empty
state, bulk selection operator chip tooltips
- **Shared primitives** - Tooltip close, InfoBanner dismiss, TextInput
clear, Toast dismiss/toggle, UpdateModal close, EditableSecretField
edit, DropdownListWithFooter search, FileCard/FileDropdownMenu actions,
EmptyFilesState + AddFileCard upload
- **Tools** - Image upload + hint, ColorControl eyedropper, sign Use
Signature, CompressSettings, OCR loading, PageLayout
margin/border/row/col placeholders, FormFill switch + save + re-scan
- **Proprietary admin** - OverviewHeader signed-in line + logout,
AdminPremiumSection moved-features list (via `<Trans>`),
AdminPlanSection no-data alert, AdminAdvancedSection temp-dir
placeholders, AdminEndpointsSection multiselect placeholders,
AdminMailSection + AdminDatabaseSection password placeholders
- **Onboarding** - MFASetupSlide QR loading + auth code label,
SecurityCheckSlide role select + options
- **ParticipantView** - entire sign-document UI (~30 strings: loading,
error, badges, headings, cert-type Select, all input labels and
placeholders, action buttons, completion + expired alerts) - file
previously imported `useTranslation` but only used `t()` for cert
validation
- **planConstants.ts refactor** - replaced `PLAN_FEATURES` /
`PLAN_HIGHLIGHTS` const exports with `usePlanFeatures()` /
`usePlanHighlights()` hooks. Service layer (`licenseService.getPlans`)
updated to accept feature/highlight maps so it stays hook-free. Callers
(`usePlans`, `CheckoutContext`) resolve the hooks at the React boundary
- **Previously catalogued offenders** - `FileSidebarFileItem`
open/close-viewer aria-labels, `quickAccessBar/ActiveToolButton` "Back
to all tools" tooltip + aria, `AppConfigModal` close button
## Notes
- One small refactor in `usePageSelectionTips.ts` was needed to resolve
a TOML key-shape conflict: the existing scalar keys
`bulkSelection.operators.{and,not,comma}` needed to become tables to
hold the new `.title` subkeys for OperatorsSection's chip tooltips. The
existing descriptions moved to `[bulkSelection.operators.descriptions]`
and the three i18n key paths in usePageSelectionTips were updated to
match.
- Viewer sidebar close buttons
(Bookmark/Layer/Thumbnail/Attachment/Comments) were on the audit list
but are NOT on main - they're added by the unmerged PR #6552
(feat/viewer-sidebar-ux). Those particular strings will need wrapping
when that PR lands.
- TOML hook (`toml-sort-fix`) ran and re-sorted the translation file.
## Test plan
- [ ] `task frontend:typecheck` passes (core + proprietary + desktop
variants)
- [ ] `task frontend:lint` passes
- [ ] Switching language to Deutsch / Русский: previously-English
`aria-label`s + tooltips + placeholders + plan card bullets now render
translated (when the locale has values) or fall back to the English
default (when it doesn't)
- [ ] Plan page bullet points in EN render unchanged
- [ ] Sign-document flow (ParticipantView) renders unchanged in EN
# Description of Changes
Reconfigure linting of `any` type to an opt-out instead of an opt-in
strategy now that we're close enough to everything supporting it. Also
slightly expands the scope of things included in the linting.
# Description of Changes
[#6474](https://github.com/Stirling-Tools/Stirling-PDF/pull/6474)
updated the IndexedDB schema number to v9, but a couple of Playwright
tests were explicitly creating a DB in v4 schema, which then caused
inconsistently failing tests because the DB upgrade process is
asynchronous and sometimes was too slow to upgrade, causing the test to
get into an invalid state.
Also fixes the screenshots directory exclusion since the frontend folder
was restructured.
## Summary
Addresses two review comments from #6507:
- **`timeUtils.ts`** — route relative time strings (`just now`, `Xm
ago`, `Xh ago`, `Xd ago`) through i18n by accepting a `TFunction`
parameter and using new `time.relative.*` keys in `en-GB`
- **`ChatPanel.tsx`** — replace `ReturnType<typeof useTranslation>["t"]`
with `TFunction` from `i18next`
## Test plan
- [x] `task frontend:check` passes (695 tests)
## Summary
## What changed
### 1. Google Drive Picker now renders above the FileManager modal
`frontend/editor/src/core/services/googleDrivePickerService.ts`
The picker is opened from inside the FileManager modal
(`Z_INDEX_FILE_MANAGER_MODAL = 1200`), but Google's Picker defaults to
z-index ~1001 - so it landed *behind* the modal that invoked it. Added
`setZIndex(Z_INDEX_OVER_FILE_MANAGER_MODAL)` to the builder.
### 2. `Z_INDEX_AUTOMATE_DROPDOWN` no longer collides with
`Z_INDEX_FILE_MANAGER_MODAL`
`frontend/editor/src/core/styles/zIndex.ts`
Both constants were `1200`. The automate dropdown only needs to sit
above the automate modal (1100), so dropped it to `1150`. This keeps
automate dropdowns above their parent modal but reliably below the file
manager scrim when the two overlap.
Confirmed callers - all are dropdowns inside automate-modal tool
settings:
- `DropdownListWithFooter.tsx`
- `AddPageNumbersAppearanceSettings.tsx`
- `AddPasswordSettings.tsx`
- `StampPositionFormattingSettings.tsx`
- and several other `*Settings.tsx` files
All keep working as intended (1150 > 1100).
### 3. Tooltip z-index honours the documented hierarchy again
`frontend/editor/src/core/components/shared/tooltip/Tooltip.module.css`
`Tooltip.tsx` sets `zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE` (1300)
inline, but the CSS module had a hardcoded `z-index: 9999` that overrode
it. Removed the stale CSS rule so tooltips render at the intended 1300
level rather than floating above almost everything.
### To test
- Ask the agent to “list all the things you can do and put them in a
markdown table”. I know we’re explicitly asking it for markdown, but I
don’t want to update the system prompt to ask it to make tables when
necessary because it’ll probably turn everything into a table, not sure
though, we can test in future.
- Notice how the loading is different
- Notice how the user chat is in a bubble but the agent chat is flat
(super standard design practice in AI tools, and looks much better when
the agent outputs mardown, expecially tables and needs room to do so)
- Ask it to do something different, then close the chat, and see that
the agent is marked as running and has a green outline and a green dot.
- Play around with resizing the chat to make it bigger/smaller
Open to any and all criticisms on any of the design choices, and of
course the usual, code etc.
Resizing
<img width="1572" height="812" alt="Screenshot 2026-06-01 at 2 47 53 PM"
src="https://github.com/user-attachments/assets/ec0ac1d0-01da-4025-bf7e-eea4eb544181"
/>
Loading (cool animation not visible through screenshot obviously)
<img width="559" height="141" alt="Screenshot 2026-06-01 at 2 53 41 PM"
src="https://github.com/user-attachments/assets/99f0b1f5-1719-4d78-8947-21b142293052"
/>
Removed bubbles for agent chat (maybe controversial, let me know) and
markdown now renders properly again
<img width="654" height="1060" alt="Screenshot 2026-06-01 at 2 55 01 PM"
src="https://github.com/user-attachments/assets/445f0889-a632-4751-9a16-f80ae388c632"
/>
## What this PR does
Wires the **B-1 shadow charging engine** into real HTTP request flow.
After this lands, flipping an internal team to ``PAYG_SHADOW`` via SQL
begins populating ``payg_shadow_charge`` automatically — with **zero
impact** on the legacy credit deduction path.
**This is the load-bearing PR for shadow mode.** Without it, B-1's
engine sits idle — nothing in the codebase calls
``JobChargeService.openProcess()`` from a real HTTP request.
Stacks on top of #6477 (PR B-1).
## Components
| Class | Role |
|---|---|
| ``PaygResponseBodyWrapperFilter`` | Servlet filter, installs tee'ing
response wrapper. Defers wrapper close to ``AsyncListener`` for
``DeferredResult`` / ``CompletableFuture`` controllers so the lifetime
spans the async window. |
| ``PaygResponseBodyWrapper`` | ``HttpServletResponseWrapper`` —
in-memory ``ByteArrayOutputStream`` up to 10 MiB; spills to ``TempFile``
above. ``materialisedPath()`` always returns a uniform ``Path``
interface. |
| ``PaygChargeInterceptor`` | ``AsyncHandlerInterceptor`` mirroring
``UnifiedCreditInterceptor`` shape. ``preHandle`` gates on
``@AutoJobPostMapping``, materialises multipart inputs, calls
``JobChargeService.openProcess``. ``afterCompletion`` branches on HTTP
status. |
| ``PaygOutputExtractor`` | Pulls PDFs out of the response body. Direct
``application/pdf`` returns body verbatim; ``application/zip`` iterates
entries and keeps each ``.pdf`` entry whose first bytes match the
``%PDF-`` magic. |
| ``PaygWebMvcConfig`` | Registers filter at end of Spring filter chain
(after security); interceptor after ``UnifiedCreditInterceptor``. |
| ``PaygFilterProperties`` | ``payg.filter.enabled`` master switch +
in-memory threshold + optional max-bytes ceiling. |
## Status branching in afterCompletion
| HTTP status | Action |
|---|---|
| **2xx** | Append OK step; extract PDFs from response;
``JobService.recordOutput`` per PDF |
| **4xx** | Append FAILED step with ``errorCode``. No refund — customer
paid for the attempt. No OUTPUT recording. |
| **5xx + OPENED** (first-step) |
``JobChargeService.markFirstStepFailed`` → shadow row flipped to
``REFUNDED``, process CLOSED. Refund counter incremented. |
| **5xx + JOINED** (mid-chain) | ``JobChargeService.decrementStepCount``
— step slot returned without resetting ``lastStepAt`` (workflow window
stays active for retry). |
## New ``JobChargeService`` methods
- **``markFirstStepFailed(jobId, reason)``** — flips shadow row to
``REFUNDED`` with ``refundedAt`` + ``refundReason``, closes the process.
Idempotent. Mimics the eventual Stripe
``meter_event_adjustment(cancel)`` flow that real-mode will invoke at
the same callsite. **Refund implies close** so a same-input retry can't
lineage-join into a refunded chain for free work.
- **``decrementStepCount(jobId)``** — defensive floor at 1; never drives
count negative.
## Schema
- Backend: ``V13__payg_shadow_charge_status.sql`` adds ``status``
(``CHARGED`` | ``REFUNDED``) + ``refunded_at`` + ``refund_reason``.
``DEFAULT 'CHARGED'`` so existing B-1 rows stay correct without
backfill.
- Supabase: matching migration in
[Stirling-PDF-SaaS#payg-shadow-charge-status](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/tree/payg-shadow-charge-status)
## Fail-open semantics in shadow
Any ``RuntimeException`` in ``preHandle`` / ``afterCompletion`` is
logged at WARN, increments ``payg.filter.errors``, and lets the
customer's tool call proceed unbilled. This **reverses to fail-closed**
when ``wallet_policy.engine = PAYG`` (real charging) — that reversal
lives inside ``JobChargeService`` and ships with the cap evaluator PR
(PR-C1 in PAYG_DESIGN.md).
## Observability
Micrometer metrics:
- ``payg.filter.errors`` Counter — internal failures (preHandle +
afterCompletion). Alert source.
- ``payg.filter.calls`` Counter, tagged ``disposition`` (``OPENED`` |
``JOINED`` | ``SHORT_CIRCUIT``)
- ``payg.filter.refunds`` Counter — first-step 5xx refunds
- ``payg.filter.duration`` Timer — preHandle + afterCompletion
wall-clock per request
## Test coverage (38 tests across 4 classes)
- **PaygResponseBodyWrapperTest** (12 tests) — in-memory, spill,
threshold crossing mid-chunk, writer vs outputStream exclusivity,
``resetBuffer`` with and without spill, close idempotency, single-byte
writes across threshold.
- **PaygOutputExtractorTest** (7 tests) — direct PDF, parametrised
content type, ZIP with mixed entries + magic-byte gate, corrupt ZIP
fail-open, empty ZIP.
- **PaygChargeInterceptorTest** (13 tests) — all preHandle
short-circuits, OPENED disposition stash, fail-open on chargeService
exception, 2xx recordOutputs path, 5xx OPENED → markFirstStepFailed, 5xx
JOINED → decrementStepCount, 4xx FAILED step append, max-bytes ceiling
skip, PIPELINE header detection.
- **JobChargeServiceTest extended** (+6 tests) — markFirstStepFailed
happy path, idempotency, missing-shadow-row case, long-reason trim;
decrementStepCount happy path, floor-at-1 defence, missing-job no-op.
## What's NOT in this PR (deliberate)
- **No SpringBootTest layer.** The saas module doesn't have bootstrap
test infrastructure (Supabase JWT config + H2 schema harness).
Integration confidence comes from the manual staging deploy + SQL-flip
of an internal team. Bootstrap-test infra is a focused follow-up if
needed.
- **No saas-mode Behave / docker-compose.** Per design §17 — deferred.
Existing ``testing/cucumber/`` infrastructure doesn't yet have a
saas-profile compose target; that's its own PR when warranted.
- **No CreditService wire-in** (per design §13 decision). Per-row
comparison data moves to the reconciliation report PR (PR-S2).
``legacy_credits_charged`` + ``diff_pct`` columns stay at 0 in shadow
rows.
- **No reconciliation report endpoint.** Direct SQL queries against
``payg_shadow_charge`` cover the data-access need until patterns emerge.
## Rollback levers
| Symptom | Lever |
|---|---|
| Some / all tool calls breaking due to filter |
``payg.filter.enabled=false`` + restart (~20s) |
| Shadow rows look wrong for a specific team | ``UPDATE wallet_policy
SET engine = 'LEGACY' WHERE team_id = ?`` |
| Mass shadow weirdness | ``UPDATE wallet_policy SET engine = 'LEGACY'``
|
| Memory exhaustion from response tee | Lower
``payg.filter.response.in-memory-threshold-bytes`` |
## Test plan
- [ ] CI green (build + tests)
- [ ] Aikido / Snyk / SonarCloud clean
- [ ] Manual: deploy to staging
- [ ] Manual: flip one internal team via ``UPDATE wallet_policy SET
engine = 'PAYG_SHADOW' WHERE team_id = ?``
- [ ] Manual: hit ``/api/v1/security/add-password`` with that team's
JWT; verify a ``payg_shadow_charge`` row appears with
``status='CHARGED'``
- [ ] Manual: trigger a 503 (e.g. via temporary backend kill
mid-request); verify the resulting row is ``status='REFUNDED'`` + the
process is ``CLOSED``
- [ ] Manual: hit ``/api/v1/general/split`` with a multi-page PDF;
verify one OUTPUT signature per inner PDF appears in
``job_artifact_hash``
- [ ] Manual: chain ``add-password`` → ``compress`` on the output;
verify the second call JOINS the first process (no new shadow row) and
the inner output OUTPUT signature is what drove the lineage join
## Stacks on / references
- Stacks on: #6477 (B-1 — shadow charging engine)
- Schema mirror: Stirling-PDF-SaaS#payg-shadow-charge-status branch
- Design doc: ``notes/PAYG_FILTER_DESIGN.md`` (all 19 decisions DECIDED)
> 📌 **Stacked on
[#6464](https://github.com/Stirling-Tools/Stirling-PDF/pull/6464)**
(lineage primitives, still in review). #6469 has merged so its commits
are no longer in this PR's diff. Once #6464 merges, a final rebase
collapses the lineage-primitives commits out of this diff too — leaving
only the B-1 work.
## What this is
Process tracking + shadow charging engine. Bundles PR-I7 service half
with the non-filter piece of PR-I7a so the pieces ship together — none
of them is useful in isolation.
**Review focus:** the new files in:
- \`app/saas/src/main/java/stirling/software/saas/payg/job/\`
(\`JobService\`, \`JobContext\`, \`JoinOrOpenResult\`,
\`StaleJobCloser\`)
- \`app/saas/src/main/java/stirling/software/saas/payg/charge/\`
(\`JobChargeService\`, \`ChargeContext\`, \`ChargeOutcome\`,
\`JobInput\`)
-
\`app/saas/src/main/java/stirling/software/saas/payg/lineage/LineagePruneScheduler.java\`
- their tests
The 8 files inherited from #6464 (lineage primitives) are unchanged from
there — they ride along in this diff until #6464 lands.
The remaining work for shadow-in-staging is the ingress/egress filter
that wires controllers into this engine — that's PR B-2.
## Scope
### \`JobService\` — persistence + lineage policy
- **\`joinOrOpen\`** — the multi-input "any-match-joins, newest wins"
rule. Hash every input via the lineage detector; if any matches an open
process in the workflow window, attach to the one with the freshest
\`lastStepAt\`. Step-limit overflow on the matched job spawns a fresh
process; the new job's input signatures are still recorded so
\`mostRecentMatchWins\` routes future calls forward.
- **\`recordOutput\`** — post-tool-success path. Records OUTPUT
signatures so the next call that takes this file as input
lineage-matches into the same process.
- **\`appendStep\`** — audit-trail step row written after a tool
completes.
- **\`close\`** — idempotent; safe to call from multiple paths
(explicit, FE on-unload, scheduler). Returns the same row on re-close,
no state mutation.
- **\`findStale\` / \`closeStale\`** — workflow-window-based stale
closure used by the scheduler.
### \`JobChargeService\` — the orchestrator (shadow variant)
\`openProcess\` resolves the effective policy via
\`PricingPolicyService\` (now in main via #6469), derives the step-limit
for the current \`JobSource\` (with a defensive fallback if the policy
is missing an entry), delegates to \`JobService.joinOrOpen\`, and on
OPENED runs the \`DocumentClassifier\` + writes a \`payg_shadow_charge\`
row. Applies the policy-level \`minChargeUnits\` floor per design § 3.4.
Shadow variant only — never debits the ledger, never posts a Stripe
meter event. The real-charging follow-up reuses the same orchestration
and swaps the side-effect. \`legacyCreditsCharged\` on the shadow row
stays \`0\` until the legacy \`CreditService\` is wired in (PR B-2),
where the comparison becomes meaningful.
### Schedulers (both plain \`@Scheduled\`)
- **\`StaleJobCloser\`** — fixed-rate 60 s. Closes \`OPEN\` jobs idle
past the workflow window. API users never have to call close explicitly
— this is the safety net.
- **\`LineagePruneScheduler\`** — hourly cron, retention 1 h. Deletes
\`job_artifact_hash\` rows older than the retention window.
- **No \`@SchedulerLock\` / no \`shedlock\` table** — consistent with
the 5 existing unguarded \`@Scheduled\` tasks in \`:saas\`
(\`CreditResetScheduler\` and friends, none of which are guarded today).
Cluster-correctness across all 7 saas schedulers is tracked in design §
9 as a separate focused cleanup. Underlying operations are idempotent —
duplicate firings on multi-pod would be wasted DB load, not data
corruption.
### Records (call-shape glue for PR B-2's filter)
- \`JobContext\` / \`JoinOrOpenResult\` — input/output for
\`JobService\`.
- \`ChargeContext\` / \`ChargeOutcome\` — input/output for
\`JobChargeService\`.
- \`JobInput\` — paired \`(MultipartFile, materialised Path)\` so the
upcoming ingress filter can pass both views without re-materialising.
## Tests
**26 new, all green.**
- 14 × \`JobServiceTest\` — no-match → opens new, single-match → joins
existing, multi-input any-match-joins, multi-match newest-wins (older
job never even looked up), step-limit hit spawns fresh job (and original
\`stepCount\` is NOT mutated), empty inputs reject, stale-signature
handling, recordOutput delegation, close idempotency, closeStale,
appendStep persistence.
- 7 × \`JobChargeServiceTest\` — JOINED skips classifier + shadow write
entirely, OPENED writes shadow row + classifies (single + multi file
paths), \`minChargeUnits\` floor applied, step-limit resolved
per-\`JobSource\` from policy, missing source entry falls back to
conservative default of 10.
- 2 × \`StaleJobCloserTest\`, 3 × \`LineagePruneSchedulerTest\` —
scheduler-wiring smoke + constructor-validation tests.
\`ENABLE_SAAS=true ./gradlew :saas:test\` — BUILD SUCCESSFUL.
## What's not in this PR (lands in PR B-2)
- **Tool ingress/egress servlet filter.** The highest-risk piece —
materialises the request body into a \`JobInput\`, calls
\`JobChargeService.openProcess\` from every \`@AutoJobPostMapping\`,
records OUTPUT after success. Edge cases to validate: multipart parts,
async controllers, streaming responses, errored 5xx paths, very large
files. Design decisions for the filter are being worked through in
\`notes/PAYG_FILTER_DESIGN.md\` before any code is written.
- **Wire shadow path into legacy \`CreditService\`.** Every legacy debit
writes a comparison row carrying both the PAYG would-be units and the
legacy actual credits, populating \`diffPct\`.
## Design doc
\`notes/PAYG_DESIGN.md\` — PR-I7 + PR-I7a status updated to reflect this
bundle. § 9 carries the cluster-correctness deferral note alongside the
existing LISTEN/NOTIFY trade-off note. § 7.5.1 readiness summary shows
the path now needs just **1 more PR** (the filter half + CreditService
wire-in, both bundled into PR B-2).
# Description of Changes
`mockServiceWorker.js` is a third-party managed file, which is included
in our `.prettierignore` file, and is rewritten to be in the module's
standard format whenever `msw` runs. At some point, it was reformatted
in our style, but shouldn't have been. This puts it back to `msw`'s
style, which should make it stop appearing in diffs.
# Description of Changes
Currently, it's not possible to develop the backend on Mac without
manually signing the JPDFium binaries yourself since macOS will reject
running the unsigned binaries. [We've now updated JPDFium to sign the
Mac binaries in
v1.0.2](https://github.com/Stirling-Tools/JPDFium/releases/tag/v1.0.2),
so update to use that version.
# Description of Changes
Change Stirling Engine to support deleting documents automatically. This
happens both on user logout and after an amount of time specified by the
Java when ingesting a document (allowing for personal documents to have
short lifetimes but org documents to be left in the db with no expiry
date). Also sets up an [ACL
policy](https://en.wikipedia.org/wiki/Access-control_list) for the
documents so the database knows which users have access to which
documents. This is not fully implemented in the Java, so currently all
docs are treated as having a single owner, the uploader, but
theoretically when we need to support org storage, we shouldn't need to
change the db schema.
# Description of Changes
Make zoom key command behave the same regardless of mouse position.
Previously only zoomed the editor if the mouse was over the editor.
## What this is
Three orthogonal interfaces — each with one production impl — for
detecting whether an incoming tool call should join an existing process
via content-hash lineage. Groundwork for PR-I7a: nothing in this PR
calls the detector yet; the ingress/egress filter that wires it into
every controller lands separately.
Built to be modular along three axes. Swapping any of them should not
require changes elsewhere:
| Axis | Interface | V1 impl | Plausible future impl |
|---|---|---|---|
| Hash algorithm | `LineageSignatureExtractor` |
`ByteHashSignatureExtractor` (SHA-256) | `PdfMetadataSignatureExtractor`
(PDF `/ID`, content-stream hash) |
| Storage backend | `JobLineageStore` | `JpaJobLineageStore` |
`RedisJobLineageStore` |
| Matching policy | `HashLineageDetector` | `DefaultHashLineageDetector`
| strategy-driven variant (any-match-joins for multi-input — lives in
JobService) |
## Interfaces
### `LineageSignatureExtractor` — what counts as a fingerprint
```java
public interface LineageSignatureExtractor {
Set<LineageSignature> extract(Path file) throws IOException;
String name();
}
```
File-based (not stream-based) so a future PDF-aware extractor can open
the same file via jpdfium / PDFBox and pull `/ID[0]` or a content-stream
hash. Multiple extractors compose at the detector layer — Spring
auto-wires all `LineageSignatureExtractor` beans, the detector unions
their results.
Production impl: **`ByteHashSignatureExtractor`** — SHA-256 over the
file via 64 KiB-buffered `DigestInputStream`. Hardware-accelerated by
the JVM (Intel SHA-NI, ARM SHA).
### `JobLineageStore` — where signatures live
```java
public interface JobLineageStore {
void record(UUID jobId, Set<LineageSignature> signatures, ArtifactKind kind);
Optional<LineageMatch> findOpenJobForSignatures(Long userId, Set<LineageSignature> candidates, Duration window);
int pruneOlderThan(Instant cutoff);
}
```
Knows nothing about storage technology. Production impl
**`JpaJobLineageStore`** runs a single joined query against
`job_artifact_hash` ⋈ `processing_job` — status + window filtering
happen at the database. The query is bounded by `Limit.of(1)` on the hot
path so a job set sharing a popular signature doesn't materialise
unwanted rows. A future `RedisJobLineageStore` (or write-through hybrid)
is a drop-in.
### `HashLineageDetector` — the high-level API
```java
public interface HashLineageDetector {
Optional<LineageMatch> detect(Long userId, Path inputFile) throws IOException;
void record(UUID jobId, Path file, ArtifactKind kind) throws IOException;
}
```
**`DefaultHashLineageDetector`** delegates extraction to every
registered `LineageSignatureExtractor`, storage to the configured
`JobLineageStore`, and reads `payg.lineage.workflow-window` (default
`PT5M`) from config. When a single extractor throws (e.g. a future
PDF-aware extractor against a malformed PDF), the other extractors still
contribute — failures don't block the byte-hash from landing.
## Profile gating
All three `@Component` beans (`JpaJobLineageStore`,
`ByteHashSignatureExtractor`, `DefaultHashLineageDetector`) are
`@Profile("saas")` — consistent with every other `:saas` bean. Without
this guard the JPA store would fail to wire against its profile-gated
repository in non-saas profiles that pull `:saas` onto the classpath.
## Tests
Run entirely in-memory; no database required.
- **`LineageSignatureTest`** — storage-key encoding round-trips, rejects
malformed `"type:value"` keys.
- **`ByteHashSignatureExtractorTest`** — identical bytes → identical
sigs; empty file hashes to the well-known SHA-256-of-empty constant; 10
MiB file streams without OOM.
- **`DefaultHashLineageDetectorTest`** — same-user / within-window /
status=OPEN filtering, multi-signature matching (one extractor sees
`pdf-id` and matches even when bytes differ), most-recent-job-wins,
record+detect round-trip, extractor-throwing-doesn't-break-others.
**`InMemoryJobLineageStore`** (in test sources) implements the same
`JobLineageStore` interface as the JPA impl, plus a `registerJob` hook
for tests to model job state. Same contract — proves the abstraction is
portable. When the Redis impl lands it gets the same contract tests.
## What's not in this PR (deliberate)
- The tool ingress/egress filter that wires the detector into every
controller — separate, focused review.
- `JobChargeService.openProcess()` — uses the detector, part of the
charging machinery, separate PR.
- Prune scheduler that calls `pruneOlderThan` — small follow-up
alongside the `shedlock` foundational table.
- PDF-aware extractor (`PdfMetadataSignatureExtractor`) — to be added
when we measure how often byte-hash-only misses real workflows.
- Multi-input "any-match-joins" lineage policy — that's a `JobService`
decision (PR-I7), not a primitive.
## Self-review pass applied
An independent code-review on this PR caught:
- **HIGH:** Missing `@Profile("saas")` on the three `@Component` beans →
fixed.
- **MEDIUM:** `pruneOlderThan` missing `@Transactional` (its
`@Modifying` query would have thrown
`InvalidDataAccessApiUsageException`) → fixed.
- **MEDIUM:** `findOpenJobsForSignatures` fetching the whole match set
just to `get(0)` → now takes `Limit`, JPA store passes `Limit.of(1)` on
the hot path.
- **LOW:** `InMemoryJobLineageStore` used both `synchronized` methods
and `ConcurrentHashMap` → dropped the redundant `ConcurrentHashMap`.
Deferred: project-wide UTC unification (`LocalDateTime.now()`
system-zone is the established convention; flipping one file mid-stack
caused a real test failure — proper fix needs its own audit).
## Rollback
Straight `git revert`. No callers yet; deleting these classes wouldn't
break anything.
---
## Checklist
- [x] Tests pass: `ENABLE_SAAS=true ./gradlew :saas:test`
- [x] No new warnings
- [x] Self-review performed (HIGH + MEDIUM findings addressed)
# Description of Changes
## What & why
This PR introduces the **Stirling developer portal** — a new
control-plane frontend that sits alongside the existing PDF editor —
plus the shared design system and workspace structure needed to host
both apps in one frontend.
The portal is the parent product surface: where users connect sources,
compose pipelines, wire agents, and manage usage / billing /
infrastructure, with the PDF editor as one capability inside it. This PR
lays the **foundation** — workspace reshape, design system, app shell,
navigation, and a mock-driven home — rather than wiring real backends
(those surfaces are placeholders for follow-up phases).
## What's in this PR
**1. Frontend repo reshape (`frontend/src/` → `frontend/editor/`)**
The existing editor app moved under `frontend/editor/`, so `editor`,
`portal`, and `shared` are siblings in one workspace. All references
were updated accordingly: `LICENSE`, `.dockerignore`, `.gitignore`,
build/sign shell scripts, the GH language-check script, the Taskfile,
and Docker config. **No editor source logic changed — path references
only.**
**2. New shared design system (`frontend/shared/`)**
- **Design tokens** in `tokens.css` as the single runtime source of
truth (light/dark, category accents, gradients). `tokens.ts` now holds
only the `Tier` type — the old JS palette mirror was removed (nothing
consumed it and it had drifted).
- ~30 framework-light **components** (Card, Button, Input, Select, Tabs,
Modal, Drawer, Toast, MetricCard, StatusBadge, Skeleton, EmptyState, …)
with Storybook stories.
- **Typed data catalogues**: `endpoints.ts` (10 verticals / 64
endpoints) and `ops.ts`.
**3. New developer portal app (`frontend/portal/`)**
- App shell: `Header`, `Sidebar`, `AssistantPanel`, search modal,
notifications, tier switcher, theme toggle, MSW toggle.
- **Tier-aware** home (free / pay-as-you-go / enterprise): KPI strip,
30-day usage chart, onboarding checklist, quick actions, recent
activity, region health, product grid, and a curated **"Popular use
cases"** teaser.
- **Documents** view hosting the full, tab-filterable endpoint
catalogue.
- Placeholder views for Sources / Pipelines / Agents / Editor /
Infrastructure / Usage & Billing / Developer Docs / Settings (follow-up
phases).
- **MSW-mocked** API layer: `api/*` issues real `fetch`, intercepted by
mocks in dev/Storybook; pointing at a real backend is just a matter of
not registering MSW. `react-router` URLs; Tier / View / UI contexts.
**4. Tooling & guardrails**
- ESLint extended to `portal` + `shared`, with **layering-boundary
rules**: `shared/` may depend only on third-party packages and itself
(no `@app` / `@portal` / `@core` / `@proprietary` / Tauri), so it stays
cleanly extractable into a standalone package later.
- `dpdm` circular-dependency check now walks editor + portal + shared
(the old glob matched only 2 files).
- New **devDependencies only** — Storybook (+ a11y/docs/themes addons),
MSW. No runtime dependencies added.
- New tasks: `frontend:dev:portal`, `frontend:build:portal`.
## Testing done locally
- `tsc` for both `portal` and `shared` projects — clean
- `eslint --max-warnings=0` across the whole frontend — clean
- `dpdm` circular-dependency check — no cycles
- Editor builds clean: `vite build editor --mode core` (✓ built, only
the pre-existing >500 kB chunk-size advisory)
- Editor runs in dev (core mode) with **zero console errors**; portal
runs in dev across all three tiers
## Notes for reviewers
- The change is overwhelmingly **additive**: `shared/` and `portal/` are
brand-new; the existing editor is path-reference changes only.
- The portal is intentionally **mock-driven** at this stage — real
backends and the remaining views land in follow-up phases.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## What this is
PR-I1 service half from `notes/PAYG_DESIGN.md`. Built on top of the data
model from #6460 — answers "what pricing policy applies to this team
right now?" with a fast cache and an admin write surface.
## Scope
| Piece | Where |
|---|---|
| `PricingPolicyService` — `getEffectivePolicy(teamId)` with 30s
Caffeine cache + admin write paths |
`app/saas/.../payg/policy/PricingPolicyService.java` |
| `PolicyChangedEvent` — published after admin writes for in-process
cache invalidation | `app/saas/.../payg/policy/PolicyChangedEvent.java`
|
| Admin REST — list / get / create / set-default / set team override /
get effective |
`app/saas/.../payg/policy/admin/PricingPolicyAdminController.java` +
DTOs |
| `PricingPolicyRepository.clearDefaultFlag()` — atomic clear for
set-default | repository update |
| `SaasJpaConfigScanTest` — drift guard against the JPA scan paths going
stale (carried over from the #6460 review concern) | new test |
| V12 default-policy seed (`v1-initial`, 25 pages/unit, 5 MiB/unit,
per-`JobSource` step limits) | `V12__seed_default_payg_policy.sql` |
## Lookup precedence
1. `PaygTeamExtensions.pricingPolicyId` set → return that policy
2. Else return the `pricing_policy` row with `is_default = TRUE`
3. Override row points at a deleted policy → log warn, fall back to
default (safety net for racing deletes)
4. No default → `IllegalStateException` (V12 seed guarantees one exists)
## Cache behaviour
- 30s `expireAfterWrite` Caffeine, max 10k entries, keyed by `teamId`.
- **Single correctness model: the TTL.** Cross-instance propagation is
at-most-30-seconds. The writer instance sees its own change immediately
via the `PolicyChangedEvent` after-commit publish. Other instances pick
it up on the next TTL expiry.
- Admin reads use `getEffectivePolicyUncached` so admins always see
their own write straight back.
**Why no LISTEN/NOTIFY runner.** An earlier cut of this PR included a
Postgres `LISTEN policy_changed` runner so cross-instance propagation
was instant. Dropped — admin policy changes are events-per-week and the
30s TTL is already the correctness floor; the listener was ~250 lines of
nontrivial code (raw JDBC outside HikariCP, daemon thread, reconnect
loop, lock-protected connection lifecycle) for a use case that isn't on
the hot path. Trade-off is documented in `notes/PAYG_DESIGN.md` §9 with
three concrete triggers that would justify reintroducing it (aggressive
cap enforcement, Redis landing for other reasons, real-time admin UI).
## Writes — transactional, fire `PolicyChangedEvent` after commit
- `create(draft)` — rejects pre-set `policy_id` or `is_default=true`
(promotion must go through `setDefault` so the partial unique idx is
freed first).
- `setDefault(id)` — atomically clears the existing default via
`clearDefaultFlag()` then flips the new row. Idempotent: silent no-op if
the row is already default.
- `setTeamOverride(teamId, policyId | null)` — validates the policy
exists before save; `null` clears the override.
`publishOnCommit` uses `TransactionSynchronizationManager.afterCommit`
so listeners never see pre-commit state. Outside a transaction (test
paths) falls through to immediate publish.
## Admin REST surface — `/api/v1/admin/payg/...`
All endpoints `@PreAuthorize("hasRole('ADMIN')")`:
- `GET /policies` — list all
- `GET /policies/{id}` — read one
- `POST /policies` — create new (non-default)
- `POST /policies/{id}/set-default` — atomic promote
- `PUT /teams/{teamId}/policy-override` — set or clear per-team override
- `GET /teams/{teamId}/effective-policy` — cache-bypassing live read
Validation errors → 400, unknown rows → 404.
## Counterpart Supabase PR
[`Stirling-PDF-SaaS#298`](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/298)
— seeds the same V1 default policy on the Supabase side via
`20260528000002_payg_seed_default_policy.sql`.
## Tests
- 17 × `PricingPolicyServiceTest` — lookup precedence, cache hit/miss,
invalidation on event, mutation paths publishing event, error cases.
- 14 × `PricingPolicyAdminControllerTest` — every endpoint's happy path
+ error mapping, DTO defensive-copy invariant.
- 2 × `SaasJpaConfigScanTest` — reflection-based guard that
`payg.repository` is in `@EnableJpaRepositories` and `payg` is in
`@EntityScan`. Without this, new sub-packages can silently fail to wire
at runtime — same class of bug that the #6460 review caught.
Full `:saas:test` BUILD SUCCESSFUL.
## Design doc
`notes/PAYG_DESIGN.md` §7.4 PR-I1 — completes the service half (the
schema half landed in #6460). §9 carries the 30s-TTL trade-off note.
# Description of Changes
The production SaaS is currently on v8 of IndexedDB due to various
schema changes for Smart Folders, which haven't made their way into OSS.
OSS is currently on v4 of IndexedDB, so if we release an OSS build to
the SaaS deployment, existing users will not be able to use it because
the DB version is 'too old'.
This PR updates the IDB version number to v9 so both OSS and SaaS users
will be able to upgrade to it. Theoretically both types of user should
be able to keep their IDB files without issue. SaaS previously actively
wiped the user's files in an old version (v6/v7) and users who haven't
used it since then will have their DBs wiped, but that'd happen anyway
if they use current SaaS so I don't think that matters.
# Description of Changes
Main fixes:
- Fix the display of the username in the bottom left
- Now displays as "User" when not logged in on self-hosted (desktop) and
"Guest" on SaaS when logged in anonymously
- Now updates properly when the user logs in/out in SaaS, desktop and
self-hosted
- Fix incremental build issues in the desktop app that have been here
since the start (I hope at least - I think the issue is that the JLink
is built read-only and then on subsequent builds you get OS errors when
trying to override the JLink with the new version. There's no real need
for it to be read-only that I know of, so we might as well just make it
R/W and ship like that)
2026-05-29 14:35:47 +00:00
3417 changed files with 541668 additions and 111755 deletions
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-GB/translation.toml)"
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-US/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-US/translation.toml)"
'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."
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-GB/translation.toml" --branch main
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
- name:pre-commit run
- name:Sort translation TOML files
run:|
pre-commit run toml-sort-fix --all-files
task pre-commit:toml-sort FIX=1
- name:Commit translation files
run:|
@@ -100,7 +110,7 @@ jobs:
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
#### **1. Synchronization of Translation Files**
- Updated translation files (`frontend/editor/public/locales/*/translation.toml`) to reflect changes in the reference file `en-GB/translation.toml`.
- Updated translation files (`frontend/editor/public/locales/*/translation.toml`) to reflect changes in the reference file `en-US/translation.toml`.
- Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations.
@@ -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
@@ -152,7 +153,9 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Import Paths - CRITICAL
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
For a broader explanation of the frontend layering and override architecture, see [frontend/editor/DeveloperGuide.md](frontend/editor/DeveloperGuide.md).
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
@@ -169,7 +172,31 @@ import { useFileContext } from "@proprietary/contexts/FileContext";
- Building layer-specific override that wraps a lower layer's component
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/saas/desktop/cloud) and handles the fallback cascade — see "Frontend `cloud/` Layer" below for the full per-flavor order.
#### Frontend `cloud/` Layer
`@app/*` resolves through a per-flavor cascade — first existing file wins (shadow/override):
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
**Cloud feature flags on desktop.** The local `AppConfigContext` reads `/api/v1/config/app-config` from the LOCAL bundled backend, so cloud-only flags (`aiEngineEnabled`, `premiumEnabled`, …) are never seen on desktop. To read the cloud's view, use `useSaasAppConfig()` (`desktop/hooks/useSaasAppConfig.ts`, backed by the general `saasAppConfigService` — SaaS-mode-only, public endpoint, native HTTP, 5-min cache). It returns `null` outside SaaS mode, so cloud features stay off in local/self-hosted and the server keeps the on/off switch (no desktop release needed to flip a flag). Gate a feature behind a per-platform seam — e.g. `useAiEngineEnabled()` (core reads `useAppConfig()`, desktop reads `useSaasAppConfig()`) — rather than hardcoding the flag on.
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
@@ -426,8 +453,9 @@ The frontend is organized with a clear separation of concerns:
## Translation Rules
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
- **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`
@@ -52,6 +52,17 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
- Rust and Cargo (required for Tauri desktop app development)
- Tauri CLI (install with `cargo install tauri-cli`)
### Optional System Dependencies
These are not required to run the app but enable specific features. The app detects them at startup and disables the relevant features if they are missing.
@@ -81,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
@@ -264,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:
@@ -294,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
## 7. Testing
@@ -576,7 +587,7 @@ When adding a new feature or modifying existing ones in Stirling-PDF, you'll nee
Find the existing `messages.properties` files in the `stirling-pdf/src/main/resources` directory. You'll see files like:
- `messages.properties` (default, usually English)
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task install` to get started, or see the [Developer Guide](DeveloperGuide.md) for full details.
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task dev` to get started running the editor, run `task` to see the most common commands, or see the [Developer Guide](DeveloperGuide.md) for full details.
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
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.