Compare commits

...
Author SHA1 Message Date
Anthony Stirling bd999144f0 Version bump 2026-07-08 10:59:39 +01:00
Anthony Stirling f4ab39600d Fix Postgres user settings for some users 2026-07-08 10:59:38 +01:00
Anthony StirlingandGitHub f703a67817 Fix cert sign not showing under certain instances (#6908) 2026-07-07 22:39:57 +01:00
105af51100 Update Frontend 3rd Party Licenses (#6889)
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
2026-07-07 22:08:01 +01:00
57bf17d348 Fix missing app icon on Linux/Wayland (#6875)
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-07-07 21:58:21 +01:00
LudyandGitHub 11df30b914 feat(ui): add dedicated third-party license sections to settings (#6820) 2026-07-07 21:57:34 +01:00
LudyandGitHub 43162c40ad chore(frontend): remove unused OG images (#6826) 2026-07-07 21:56:16 +01:00
James BruntonandGitHub be57f11747 Improve type safety of tool definitions (#6895)
# 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.
2026-07-07 16:43:06 +00:00
EthanHealy01andGitHub 8ba8f69252 Consolidate buttons and related components (#6787)
SegmentedControl, Chip, ChipFlow. Bring in the portal dark mode theme
and other small fixes to issues I found during testing
2026-07-07 16:06:56 +00:00
Reece BrowneandGitHub be97268a7c SUI - setting up mantine backed SUI components (#6890)
## 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.
2026-07-07 14:29:55 +00:00
ConnorYohandGitHub 7bd3826178 Portal procurement: real pricing/trial/quote spine + linked-gated checkout (vertical slice) (#6861)
## 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).
2026-07-07 12:02:02 +00:00
James BruntonandGitHub 17aa71850c Convert to consistently use JS modules (#6854)
# 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.
2026-07-07 11:11:24 +00:00
James BruntonandGitHub 20204f0ddc Improve consistency and reliability of tools in Stirling Engine (#6855)
# 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.
2026-07-07 11:01:18 +00:00
ConnorYohandGitHub 1df6a1759c Set App version to v2.14.1 (#6891)
Upped version in build.gradle then ran build so version falls through
2026-07-07 09:37:35 +00:00
James BruntonandGitHub b4f7b1d8a9 Add bidirectional API types to frontend (#6867)
# 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.
2026-07-07 07:47:07 +00:00
James BruntonandGitHub f881828cd8 Fix intermittently failing Playwright tests (#6886)
# 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.
2026-07-06 21:37:21 +00:00
16cfbc170e Clean up typos in docs, comments, and UI copy (#6045)
# Description of Changes

Fix wording, numbering, path references, and minor grammar issues across
project guides, backend comments, and frontend strings.

This keeps documentation and user-facing text consistent without
changing application behavior.

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-07-06 14:07:06 +00:00
James BruntonandGitHub 355d736487 Skip enterprise tests for forks since they can't run without secrets (#6888)
# Description of Changes
OSS contributions which trigger the enterprise e2e tests will always
fail due to missing secrets (see
https://github.com/Stirling-Tools/Stirling-PDF/actions/runs/28776342146/job/85337748018?pr=6045).
This PR disables them for OSS PRs.
2026-07-06 13:26:10 +00:00
f201aa5915 feat(account-link): Phase 2 — instance metering + daily usage sync (#6839)
## 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>
2026-07-06 11:39:24 +00:00
James BruntonandGitHub b69b787d63 Fix Playwright tests in Firefox and Safari (#6868)
# 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).
2026-07-06 09:25:46 +00:00
James BruntonandGitHub e630d6697b Fix tooltip positioning on Add Page Numbers (#6885)
# Description of Changes
## Before

<img width="483" height="227" alt="image"
src="https://github.com/user-attachments/assets/4bf86eec-a9cc-4f63-84f0-4eb2bd535bab"
/>

## After

<img width="732" height="235" alt="image"
src="https://github.com/user-attachments/assets/101d2ea4-36e8-4e8f-990a-d72b33fa0ac2"
/>
2026-07-06 09:22:41 +00:00
LudyandGitHub a15e8227b4 fix(ci): upload Playwright reports from the correct frontend directory (#6859)
# 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.
2026-07-06 09:21:57 +00:00
LudyandGitHub 12563050a6 Generate frontend license report on push (#6877)
# Description of Changes

`app/allowed-licenses.json` has been modified in preparation for when
"org.springframework.boot" is upgraded to version "4.0.7".

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-06 09:21:41 +00:00
LudyandGitHub 1abd23cf94 fix(frontend): respect analytics config before initializing PostHog (#6812)
# 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.
2026-07-06 09:21:09 +00:00
James BruntonandGitHub 11ba3814e5 Restructure Portal code to be inside Editor (#6857)
# 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.
2026-07-03 13:20:02 +00:00
Anthony StirlingandGitHub 675afe9b71 Disable update check and notification in SaaS mode (#6863)
# Description of Changes

In SaaS mode the self-hosted "Update Available" notification could still
appear and the update-check code (external call to
`supabase.stirling.com/functions/v1/updates`) still ran, even though the
cloud owns app versioning. The web `UpdateStartupPopup` was already
SaaS-gated via a null override, but two other paths were not:

- **Desktop app in SaaS connection mode** - `useDesktopUpdatePopup()`
ran its startup check and rendered the `UpdateModal` regardless of
connection mode, so a self-hosted update popup appeared while connected
to SaaS.
- **Settings → General** - the core `GeneralSection` fired
`checkForUpdate()` on mount unconditionally, even when the update
section was hidden (as SaaS does), so the external call still ran.

**What changed**

- `useDesktopUpdatePopup.ts` - the startup timer now bails out
immediately when `connectionModeService.getCurrentMode() === "saas"`. No
mode lookup, no external fetch, no modal.
- `core/GeneralSection.tsx` - the mount `checkForUpdate()` now returns
early when `hideUpdateSection` is set, so hiding the section (web SaaS,
managed-disabled desktop) also stops the external call.
- `desktop/GeneralSection.tsx` - passes `hideUpdateSection` when
`useSaaSMode()` is true, which (via the above) suppresses the settings
check in desktop-SaaS too.

**Why** - in SaaS the update check should never be called and no update
notification should be shown; the cloud handles versioning.

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-03 08:55:07 +00:00
Anthony StirlingandGitHub 6c85200eb9 Add portal access control and S3/MCP/API integration configs (#6795) 2026-07-01 13:49:02 +01:00
Reece BrowneandGitHub 467f3a86c4 Portal policies (#6852) 2026-07-01 13:42:26 +01:00
Anthony StirlingandGitHub 9d3701a585 Fix rearrange-pages DUPLICATE producing shared page nodes (pypdf cyclic-references CI break) (#6851) 2026-07-01 13:40:27 +01:00
James BruntonandGitHub c22ecc6c09 Add counts to sources page (#6819) 2026-07-01 11:42:35 +01:00
Reece BrowneGitHubaikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>Connor Yoh
b38c849726 Portal: Procurement surface — layout rework + stateful mock backend (#6785)
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
Co-authored-by: Connor Yoh <con.yoh13@gmail.com>
2026-07-01 11:38:38 +01:00
41f1cb2c22 build(deps): bump test pypdf + add translations (#6831)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 23:40:26 +01:00
Anthony StirlingandGitHub ff3e3bd0fc Add desktop hardware token signing and trust-aware signature validation (#6765)
# Description of Changes

<img width="432" height="800" alt="image"
src="https://github.com/user-attachments/assets/a01ed9ac-220c-4911-9134-b51e0f321be8"
/>

<img width="408" height="859" alt="image"
src="https://github.com/user-attachments/assets/a9c285b6-5b75-493a-95ec-09e08d0f58f1"
/>

<img width="426" height="874" alt="image"
src="https://github.com/user-attachments/assets/a60db96e-be93-4cc5-ba0a-63512c2857ba"
/>

<img width="356" height="1076" alt="image"
src="https://github.com/user-attachments/assets/24d03674-94d3-40ed-99ee-73395bafae6a"
/>


---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-30 23:23:58 +01:00
EthanHealy01andGitHub 54042c8e5e Signing UI edge-case cleanup (#6849) 2026-06-30 23:04:10 +01:00
bb92ecc143 Update Backend 3rd Party Licenses + Translations and bump versio (#6794)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
2026-06-30 22:41:40 +01:00
EthanHealy01andGitHub 7ab30d2629 add file share to the top workbench bar and add shared signing (#6715) 2026-06-30 22:14:49 +01:00
James BruntonandGitHub 276eb8f2a7 Add pipelines page to portal (#6818)
# Description of Changes

Connect pipelines page to the backend. Note that this is really half an
implementation because the portal doesn't have access to the tools list
and their settings, but I can't fix that without re-architecture work,
which I'll do in another PR, then come back to finish this off in a new
PR.

<img width="786" height="579" alt="image"
src="https://github.com/user-attachments/assets/d3f06110-a35d-4d48-a2f9-1edb900c5c35"
/>

<img width="1232" height="519" alt="image"
src="https://github.com/user-attachments/assets/9f344648-ea45-498d-9e84-9558a3999838"
/>
2026-06-30 16:11:48 +00:00
James BruntonandGitHub e44da5c410 Fix missing refresh token on desktop (#6838)
# 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.
2026-06-30 14:07:12 +00:00
Reece BrowneandGitHub 0beff1a92b feat(shared): make @shared the single home for brand logo assets (#6714)
## 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.
2026-06-30 11:24:14 +00:00
ConnorYohandGitHub 425b76e9a7 fix(portal/i18n): add inline default values to account-link + billing t() calls (#6842)
## 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.
2026-06-30 09:23:13 +00:00
Reece BrowneandGitHub c8af6e3b7e feat(policies): enforce run-on-export policies on all PDF exit paths (#6788)
> **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.
2026-06-29 18:01:12 +00:00
James BruntonandGitHub 82ec2acaba Make explicit signed and unsigned desktop CI jobs (#6840)
# Description of Changes
Makes it easier to skip signing on nightlies, which we don't need to do
since we're just warming the Rust cache.
2026-06-29 16:14:40 +00:00
Anthony StirlingandGitHub 5e97746721 UX improvement for side menu bookmark, comments and attachments (#6552)
- Inline "Add bookmark" form in the bookmark sidebar (title + page,
defaults to current page) - saves via
/api/v1/general/edit-table-of-contents without leaving the viewer
- Persistent "+ Add" rows above the list in Bookmarks, Attachments,
Comments and Files sidebars (was only in empty state)
- Close (X) button in every viewer sidebar header (Bookmarks,
Attachments, Comments, Layers, Thumbnails)
- "Add comment" button morphs into "Click a page to place… (cancel)"
while textComment is armed, ESC to cancel
- "Add attachment" auto-closes the attachment sidebar so you don't end
up with two stacked panels
- Footer link in bookmark sidebar to the full Edit Table of Contents
tool for nesting/reordering
- Fix: bookmark/attachment sidebars getting stuck on "Loading…" after a
file swap (cache no longer caches `loading`, retry treats null bridge as
not-ready)
- Fix: Save silently routing to the editor tool on a fresh /read upload
when `activeFileId` is still null
- New Playwright tests (stubbed + live) covering Add buttons, Save flow
with PDF round-trip, and close buttons
<img width="720" height="1032" alt="06-thumbnails"
src="https://github.com/user-attachments/assets/62298d0d-8eba-4397-9bc2-96871be29b3c"
/>
<img width="790" height="1062" alt="01-bookmarks"
src="https://github.com/user-attachments/assets/1eb33667-c038-4b78-8711-97f354344fae"
/>
<img width="720" height="1032" alt="02-bookmarks-empty"
src="https://github.com/user-attachments/assets/3db263ef-9550-4bac-9ffa-c729263f42c3"
/>
<img width="1032" height="1032" alt="03-attachments"
src="https://github.com/user-attachments/assets/33580e64-020a-4e07-bf9a-595faf695fd8"
/>
<img width="919" height="1062" alt="04-comments"
src="https://github.com/user-attachments/assets/89ef01a8-35a6-406b-825a-f04beec02f29"
/>
<img width="720" height="1032" alt="05-layers"
src="https://github.com/user-attachments/assets/57d3cfe9-0a4c-468d-b497-ed855ddd69e5"
/>

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-29 14:05:58 +00:00
14245d33d1 feat(saas): account-link — connected self-hosted billing (Mode A) [WIP, flag-gated] (#6738)
> **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>
2026-06-29 13:35:07 +00:00
Anthony StirlingandGitHub 84739e8b0e Align settings.yml defaults and fix dead/mismapped settings (#6816)
# Description of Changes

Align settings.yml defaults and fix dead/mismapped settings

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-29 11:33:55 +00:00
Anthony StirlingandGitHub 0996277c41 Brand MSI installer and rename display name to Stirling PDF (#6764)
# Description of Changes

Add icons to stirling PDF installer and changed app name from
Stirling-PDF to Stirling PDF

<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/6f23b501-d765-43a6-a713-b330ea199a04"
/>
<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/83d50ac9-2220-474b-8269-bfcfad01166c"
/>
<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/f0113ef5-9567-46d0-820c-0891d33b2355"
/>

vs old

<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/d50fa652-cb42-4668-b951-4f2ce52eba14"
/>
<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/b113890b-f06d-4dea-9738-1b885a9ba125"
/>
<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/3b6792aa-48a5-425f-9ae2-13938fd297a5"
/>


---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-29 11:17:30 +00:00
d508bc41bf Fix PR docker CI when the base image changes (#6809)
# 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>
2026-06-29 09:59:17 +00:00
James BruntonandGitHub 6ff910f26c Expand any type linting in frontend (#6808)
# 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.
2026-06-29 08:32:30 +00:00
James BruntonandGitHub 013f145462 Upgrade to TS7 for local type-checking (#6815)
# 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.
2026-06-26 15:05:07 +00:00
James BruntonandGitHub eea9696bd4 Actually build the frontend for Playwright nightlies (#6817)
# 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.
2026-06-26 14:53:51 +00:00
James BruntonandGitHub 3f7e898c69 Add sources service and frontend (#6774)
# 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.
2026-06-26 13:21:53 +00:00
James BruntonandGitHub def3cf79f6 More desktop CI optimisations (#6786)
# 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
2026-06-26 11:08:08 +00:00
Matheus SaitoandGitHub 501a7199e0 Add bulk comment and annotation clearing to editor (#6792)
# 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.
2026-06-25 08:55:11 +00:00
Anthony StirlingandGitHub bc6f1a1ff5 Add login agreement disclaimer feature (#6766) 2026-06-24 22:07:19 +01:00
1640 changed files with 70294 additions and 32032 deletions
-1
View File
@@ -27,7 +27,6 @@ node_modules/
**/node_modules/
frontend/node_modules/
frontend/editor/dist/
frontend/dist-portal/
frontend/editor/playwright-report/
.npm/
.yarn/
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.13.2
pkgver=2.14.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.13.2
pkgver=2.14.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+15
View File
@@ -87,6 +87,21 @@ engine: &engine
- Taskfile.yml
- .taskfiles/engine.yml
# Files that can make the committed generated API models (frontend tool API
# types + engine tool models) go stale: the Java tool surfaces they derive from,
# the generators, the generated files themselves (to catch a hand-edit), and the
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
generated-models: &generated-models
- *openapi
- frontend/editor/scripts/generate-tool-api-types.mts
- frontend/editor/src/core/types/toolApiTypes.ts
- engine/scripts/generate_tool_models.py
- engine/src/stirling/models/tool_models.py
- .taskfiles/frontend.yml
- .taskfiles/engine.yml
- .github/workflows/check-generated-models.yml
licenses-frontend: &licenses-frontend
- ".github/workflows/frontend-backend-licenses-update.yml"
- "frontend/package.json"
+4 -99
View File
@@ -1,9 +1,9 @@
name: AI Engine CI
# Validates the Python AI engine: regenerates tool models and runs the
# engine quality gate (lint, type-check, format-check, tests). Called from
# build.yml on PRs and merge_group; also runs directly on push to main as
# a post-merge safety net.
# Runs the engine quality gate (lint, type-check, format-check, tests). Called
# from build.yml on PRs and merge_group; also runs directly on push to main as
# a post-merge safety net. Freshness of the generated tool_models.py is checked
# by the shared check-generated-models workflow.
on:
workflow_call:
push:
@@ -34,104 +34,9 @@ jobs:
with:
enable-cache: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Regenerate tool models
run: task engine:tool-models
- name: Verify tool models are up to date
id: tool-models-check
continue-on-error: true
run: git diff --exit-code engine/src/stirling/models/tool_models.py
- name: Comment on tool models check failure
# Only post a comment on PRs. github-script's PR helpers need an
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- tool-models-check -->';
const body = [
marker,
'### Tool Models Check Failed',
'',
'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.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if tool models check failed
if: steps.tool-models-check.outcome == 'failure'
run: |
echo "============================================"
echo " Tool Models Check Failed"
echo "============================================"
echo ""
echo "The generated engine/src/stirling/models/tool_models.py"
echo "is out of date with the Java OpenAPI spec and will"
echo "need to be regenerated before it can be merged in."
echo ""
echo "Run 'task engine:tool-models' to regenerate, then"
echo "commit the updated file."
echo "============================================"
exit 1
- name: Remove tool models check comment on success
if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- tool-models-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
- name: Quality-check engine
id: engine-check
run: task engine:check
+25 -4
View File
@@ -2,7 +2,7 @@ name: Enterprise E2E (Playwright)
# Enterprise Playwright suite — exercises premium-key gated features (audit,
# teams, analytics) plus full OAuth + SAML logins via the Keycloak compose
# stacks under testing/compose. Slow and secret-gated, so it runs in three
# stacks under testing/compose. Slow and secret-gated, so it runs in four
# situations:
#
# - PRs that touch proprietary / premium / SSO compose / enterprise tests
@@ -12,8 +12,6 @@ name: Enterprise E2E (Playwright)
# - on a nightly cron schedule (catches Keycloak image drift, license
# expiry, upstream proprietary changes),
# - manual workflow_dispatch.
#
# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot).
on:
workflow_call:
@@ -52,6 +50,10 @@ jobs:
playwright-e2e-enterprise:
needs: pick
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE
# (nor DEPOT_TOKEN), so the suite can't boot premium and would fail. See the
# header comment. GitHub reports the skipped reusable workflow as success.
if: needs.pick.outputs.is_fork != 'true'
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
timeout-minutes: 45
env:
@@ -165,6 +167,8 @@ jobs:
wait_for_backend
- name: Run enterprise OAuth Playwright tests
id: oauth-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-oauth.json
run: task e2e:enterprise -- --grep "OAuth"
- name: Stop backend + tear down OAuth Keycloak
if: always()
@@ -238,6 +242,8 @@ jobs:
wait_for_backend
- name: Run enterprise SAML Playwright tests
id: saml-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-saml.json
run: task e2e:enterprise -- --grep "SAML"
- name: Stop backend + tear down SAML Keycloak
if: always()
@@ -268,6 +274,8 @@ jobs:
wait_for_backend
- name: Run enterprise feature Playwright tests
id: feature-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-feature.json
run: task e2e:enterprise -- --grep "Enterprise license"
- name: Print backend log on failure
if: failure()
@@ -280,10 +288,23 @@ jobs:
run: |
source /tmp/helpers.sh
stop_backend
- name: Flag flaky tests
# Runs regardless of the test outcomes: a flaky test (passed on retry)
# leaves its step green, so this is the only place it surfaces. Merges
# all three phase reports (some may be absent if an earlier phase hard-
# failed and skipped the rest). Emits ::warning:: annotations + a job
# summary; never fails the job.
if: always()
working-directory: frontend
run: >
npx tsx editor/scripts/report-flaky-tests.mts
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-enterprise-${{ github.run_id }}
path: frontend/editor/playwright-report/
path: frontend/playwright-report/
retention-days: 7
+20
View File
@@ -43,6 +43,7 @@ jobs:
docker-base: ${{ steps.changes.outputs.docker-base }}
tauri: ${{ steps.changes.outputs.tauri }}
engine: ${{ steps.changes.outputs.engine }}
generated-models: ${{ steps.changes.outputs.generated-models }}
proprietary: ${{ steps.changes.outputs.proprietary }}
steps:
- name: Harden the runner (Audit all outbound calls)
@@ -171,6 +172,20 @@ jobs:
uses: ./.github/workflows/ai-engine.yml
secrets: inherit
# The generated frontend types and engine tool models are both derived from
# the Java OpenAPI spec. This job regenerates and diffs them; it boots the
# backend, so it is gated on the narrow generated-models filter (spec source,
# generators, generated files, generation tasks) rather than the broad
# frontend filter, so a CSS-only PR does not pay for a backend build.
generated-models:
if: needs.files-changed.outputs.generated-models == 'true'
needs: [files-changed]
permissions:
contents: read
pull-requests: write
uses: ./.github/workflows/check-generated-models.yml
secrets: inherit
pre-commit:
needs: [files-changed]
permissions:
@@ -202,6 +217,9 @@ jobs:
contents: read
uses: ./.github/workflows/coverage-aggregate.yml
secrets: inherit
with:
frontend-validation-result: ${{ needs.frontend-validation.result }}
playwright-e2e-live-result: ${{ needs.playwright-e2e-live.result }}
# Single status check that branch protection should mark as required.
# Succeeds when every upstream job is either `success` or `skipped` (path-
@@ -225,6 +243,7 @@ jobs:
- test-build-docker-images
- tauri-build
- ai-engine
- generated-models
- pre-commit
- dependency-review
runs-on: ubuntu-latest
@@ -250,6 +269,7 @@ jobs:
test-build-docker-images=${{ needs.test-build-docker-images.result }}
tauri-build=${{ needs.tauri-build.result }}
ai-engine=${{ needs.ai-engine.result }}
generated-models=${{ needs.generated-models.result }}
pre-commit=${{ needs.pre-commit.result }}
dependency-review=${{ needs.dependency-review.result }}
run: |
@@ -0,0 +1,148 @@
name: Check generated models
# Verifies the committed generated API models are still in sync with the Java
# OpenAPI spec: the frontend tool API types
# (frontend/editor/src/core/types/toolApiTypes.ts) and the engine tool
# models (engine/src/stirling/models/tool_models.py). Regenerates both with the
# single top-level `task tool-models` and fails if either committed file is
# out of date. Called from build.yml when the backend Java, frontend, or engine
# changes; also runs on push to main as a post-merge safety net.
on:
workflow_call:
push:
branches: [main]
permissions:
contents: read
jobs:
generated-models:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
# Rebuilds the OpenAPI spec from the current Java and regenerates both the
# frontend types and the engine tool models from it.
- name: Regenerate generated models
run: task tool-models
- name: Verify generated models are up to date
id: models-check
continue-on-error: true
run: |
git diff --exit-code \
frontend/editor/src/core/types/toolApiTypes.ts \
engine/src/stirling/models/tool_models.py
- name: Comment on generated models check failure
# Only post a comment on PRs. github-script's PR helpers need an
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.models-check.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- generated-models-check -->';
const body = [
marker,
'### Generated Models Check Failed',
'',
'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.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if generated models check failed
if: steps.models-check.outcome == 'failure'
run: |
echo "============================================"
echo " Generated Models Check Failed"
echo "============================================"
echo ""
echo "The generated frontend API types and/or engine tool"
echo "models are out of date with the Java OpenAPI spec and"
echo "will need to be regenerated before they can be merged in."
echo ""
echo "Run 'task tool-models' to regenerate both, then"
echo "commit the updated files."
echo "============================================"
exit 1
- name: Remove generated models check comment on success
if: steps.models-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- generated-models-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
+18 -7
View File
@@ -13,6 +13,17 @@ name: Aggregate backend coverage
# producers themselves
on:
workflow_call:
inputs:
frontend-validation-result:
description: Result of the frontend-validation producer job
required: false
type: string
default: skipped
playwright-e2e-live-result:
description: Result of the playwright-e2e-live producer job
required: false
type: string
default: skipped
permissions:
contents: read
@@ -196,9 +207,9 @@ jobs:
# --------------------------------------------------------------
- name: Download vitest coverage artifact
# frontend-validation uploads as `frontend-coverage`. Tolerate
# absence so a backend-only PR still produces the matrix with
# just backend rows populated.
if: always()
# absence on backend-only runs by skipping the download entirely
# when the producer job was not part of this workflow run.
if: inputs.frontend-validation-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
with:
name: frontend-coverage
@@ -206,12 +217,12 @@ jobs:
continue-on-error: true
- name: Download Playwright frontend coverage artifact
# e2e-live uploads as `playwright-frontend-coverage-<run_id>`.
# Same tolerance as vitest - matrix script handles missing inputs.
if: always()
# e2e-live uploads the artifact with a stable name. Skip the
# download entirely when the producer job did not run.
if: inputs.playwright-e2e-live-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
with:
name: playwright-frontend-coverage-${{ github.run_id }}
name: playwright-frontend-coverage
path: matrix-inputs/playwright/
continue-on-error: true
@@ -64,11 +64,19 @@ jobs:
gradle-version: 9.6.0
cache-disabled: true
# When the PR changes the base image, test.sh builds it locally
# (stirling-pdf-base:local) into the daemon image store. A buildx
# container builder can't see that store, so skip it here and let
# `docker buildx build` fall back to the default docker driver, which
# resolves the local base. The gha cache backend is also skipped (its
# runtime token isn't exposed) since the docker driver can't use it.
- name: Set up Docker Buildx
if: inputs.docker-base-changed != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
- name: Expose GitHub runtime for Buildx cache
if: inputs.docker-base-changed != 'true'
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
- name: Install Docker Compose
+11 -1
View File
@@ -62,7 +62,17 @@ jobs:
# .test-state/playwright/coverage-pw/ for the post-process step
# to aggregate. Chromium-only - other engines silently skip.
PW_COVERAGE: "1"
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:live
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Generate JaCoCo report from e2e:live .exec
if: always()
id: live-coverage
@@ -169,7 +179,7 @@ jobs:
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-frontend-coverage-${{ github.run_id }}
name: playwright-frontend-coverage
path: |
.test-state/playwright/coverage-pw-summary/
.test-state/playwright/coverage-pw/
+12 -1
View File
@@ -44,11 +44,22 @@ jobs:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run stubbed E2E tests (chromium)
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:stubbed -- --workers=3
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-stubbed-${{ github.run_id }}
path: frontend/editor/playwright-report/
path: frontend/playwright-report/
retention-days: 7
@@ -98,6 +98,13 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Generate frontend license report (Push only)
if: github.event_name == 'push'
env:
PR_IS_FORK: "false"
run: task frontend:licenses:generate
- name: Generate frontend license report (internal PR)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
env:
@@ -353,6 +360,7 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check licenses and generate report
id: license-check
run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
+8 -2
View File
@@ -41,6 +41,11 @@ jobs:
- name: Install all Playwright browsers
run: task e2e:install
- name: Build frontend (production bundle for vite preview)
env:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run E2E tests (all browsers)
run: task e2e:cross-browser
@@ -48,8 +53,8 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-nightly-${{ github.run_id }}
path: frontend/editor/playwright-report/
name: playwright-report-nightly-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 14
# Builds all desktop platforms on a schedule so the Rust dependency cache is
@@ -62,4 +67,5 @@ jobs:
uses: ./.github/workflows/tauri-build.yml
with:
platform: all
sign: false
secrets: inherit
+44 -13
View File
@@ -16,6 +16,11 @@ on:
required: false
type: string
default: "all"
sign:
description: "Sign and notarize the bundles."
required: false
type: boolean
default: true
workflow_dispatch:
inputs:
platform:
@@ -28,6 +33,11 @@ on:
- windows
- macos
- linux
sign:
description: "Sign and notarize the bundles."
required: false
default: true
type: boolean
permissions:
contents: read
@@ -177,7 +187,7 @@ jobs:
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -187,7 +197,7 @@ jobs:
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
@@ -222,7 +232,7 @@ jobs:
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
@@ -253,7 +263,7 @@ jobs:
}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -274,7 +284,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -297,7 +307,7 @@ jobs:
ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Preflight smctl
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -310,7 +320,7 @@ jobs:
if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" }
- name: Configure Windows code signing
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: bash
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -329,7 +339,7 @@ jobs:
EOF
- name: Import release GPG signing key (Linux)
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
if: inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
run: |
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-secret-keys --keyid-format=long
@@ -346,7 +356,8 @@ jobs:
exit 1
fi
- name: Build Tauri app
- name: Build Tauri app (signed)
if: inputs.sign
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -380,6 +391,26 @@ jobs:
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
- name: Build Tauri app (unsigned)
if: ${{ !inputs.sign }}
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: "0"
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
CI: true
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
# Linux: build deb+rpm only here. AppImage runs in its own
# continue-on-error step below so its persistent linuxdeploy
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
# AppImage is decoupled so its linuxdeploy run gets a fresh process
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
- name: Build Tauri app (Linux AppImage)
@@ -388,7 +419,7 @@ jobs:
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -403,7 +434,7 @@ jobs:
args: --bundles appimage
- name: Clear release GPG key from runner keyring (Linux)
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
if: always() && inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
env:
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
run: |
@@ -413,7 +444,7 @@ jobs:
fi
- name: Verify notarization (macOS only)
if: matrix.platform == 'macos-15'
if: inputs.sign && matrix.platform == 'macos-15'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/editor/src-tauri/target
@@ -451,7 +482,7 @@ jobs:
# Verify the MSI AND the inner exe extracted from it are signed.
# The inner exe is what gets installed on users' machines and what AV scans.
- name: Verify Windows Code Signature
if: matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
if: inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
shell: pwsh
run: |
$allSigned = $true
+16 -1
View File
@@ -155,6 +155,19 @@ jobs:
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
fi
# Base-changed PRs build the embedded image with the local docker driver
# so the locally-built stirling-pdf-base:pr-test (in the daemon image
# store) resolves. A buildx container builder cannot see it and would try
# to pull it from a registry, which fails. Single-platform, no gha cache.
- name: Build ${{ matrix.docker-rev }} against local base (PR base change)
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
run: |
DOCKER_BUILDKIT=1 docker build \
--build-arg BASE_IMAGE=${{ steps.build-params.outputs.base_image }} \
--file ./${{ matrix.docker-rev }} \
--tag stirling-pdf-embedded:pr-test \
.
- name: Build ${{ matrix.docker-rev }} (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
@@ -169,8 +182,10 @@ jobs:
provenance: true
sbom: true
# Fork PRs that did NOT change the base use the buildx container builder
# (multi-platform + gha cache) against the published base image.
- name: Build ${{ matrix.docker-rev }} (Docker fork fallback)
if: env.USE_DEPOT != 'true'
if: env.USE_DEPOT != 'true' && inputs.docker-base-changed != 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
builder: ${{ steps.buildx.outputs.name }}
+10 -2
View File
@@ -15,7 +15,15 @@ testing/compose/validate-mcp-test.sh:curl-auth-header:92
testing/compose/validate-mcp-test.sh:curl-auth-header:116
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api-key:30
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
.github/workflows/tauri-build.yml:generic-api-key:402
+8 -2
View File
@@ -26,9 +26,14 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
POLICIES_ENABLED: '{{.POLICIES_ENABLED}}'
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
# `dotenv:` reads from the root Taskfile's directory (".") because this
# subtaskfile is included with `dir: .`. Local overrides in
# .env.proprietary.local win over the committed .env.proprietary defaults.
dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
@@ -36,12 +41,13 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
POLICIES_ENABLED: '{{.POLICIES_ENABLED | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
+26 -10
View File
@@ -149,16 +149,32 @@ tasks:
# Pin jlink to JAVA_HOME so the bundled JRE matches the JDK the build
# uses. Bare `jlink` on PATH can resolve to an older system Java (the
# ubuntu runner ships Java 11), producing a runtime jlink:verify rejects.
- |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
#
# jdk.crypto.mscapi (the Windows certificate store / SunMSCAPI provider, used by
# hardware-backed cert signing) is a Windows-only module - it only exists in a Windows
# JDK's jmods, so it is added on Windows only or jlink fails to resolve it elsewhere.
- cmd: |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}},jdk.crypto.mscapi \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
platforms: [windows]
- cmd: |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
platforms: [linux, darwin]
# jlink emits its files mode 444 (read-only). Tauri's build-script
# resource copier preserves source permissions when staging
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
+49 -75
View File
@@ -128,37 +128,6 @@ tasks:
- task: dev:_run
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:portal:
desc: "Start developer portal dev server"
ignore_error: true
deps: [install]
vars:
PORT: '{{.PORT | default "5173"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
EDITOR_URL: '{{.EDITOR_URL | default ""}}'
OPEN: '{{.OPEN | default ""}}'
SUBPATH: '{{.SUBPATH | default ""}}'
MOCKS: '{{.MOCKS | default ""}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
cmds:
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}'
dev:portal:proxy:serve:
internal: true
vars:
PORT: '{{.PORT | default "3000"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}'
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}'
env:
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}'
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}'
cmds:
- npx tsx scripts/dev-origin-proxy.ts
# ============================================================
# Build
# ============================================================
@@ -205,29 +174,6 @@ tasks:
cmds:
- npx vite build editor --mode prototypes
build:portal:
desc: "Build developer portal"
deps: [install]
vars:
SUBPATH: '{{.SUBPATH | default ""}}'
cmds:
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal'
preview:portal:proxy:
desc: "Build + serve editor + portal behind one origin (prod-like auth testing)"
deps: [prepare]
vars:
PORT: '{{.PORT | default "3000"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
env:
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
cmds:
- task: build:proprietary
vars: { PREVIEW: '1' }
- task: build:portal
vars: { SUBPATH: portal }
- npx tsx scripts/dev-origin-proxy.ts
storybook:
desc: "Start Storybook dev server"
@@ -263,8 +209,8 @@ tasks:
deps: [install]
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
# shell-agnostic. Covers editor, portal, and the shared design system.
- npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
# shell-agnostic. Covers the whole editor tree, including the portal layer.
- npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
lint:fix:
desc: "Auto-fix lint issues"
@@ -295,17 +241,26 @@ tasks:
cmds:
- task: typecheck:proprietary
typecheck:_run:
internal: true
env:
CI: '{{ .CI | default "false" }}'
cmds:
- '{{ if eq .CI "true" }}npx tsc{{ else }}npx tsgo{{ end }} --noEmit --project {{.PROJECT}}'
typecheck:core:
desc: "Typecheck core build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/core/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/core/tsconfig.json }
typecheck:proprietary:
desc: "Typecheck proprietary build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/proprietary/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/proprietary/tsconfig.json }
typecheck:saas:
desc: "Typecheck SaaS build variant"
@@ -313,7 +268,8 @@ tasks:
- task: prepare
vars: { MODE: saas }
cmds:
- npx tsc --noEmit --project editor/src/saas/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/saas/tsconfig.json }
typecheck:desktop:
desc: "Typecheck desktop build variant"
@@ -321,38 +277,36 @@ tasks:
- task: prepare
vars: { MODE: desktop }
cmds:
- npx tsc --noEmit --project editor/src/desktop/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/desktop/tsconfig.json }
typecheck:cloud:
desc: "Typecheck cloud shared layer (standalone)"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/cloud/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/cloud/tsconfig.json }
typecheck:scripts:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- npx tsc --noEmit --project scripts/tsconfig.json
- npx tsc --noEmit --project editor/scripts/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/scripts/tsconfig.json }
typecheck:prototypes:
desc: "Typecheck prototypes build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/prototypes/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/prototypes/tsconfig.json }
typecheck:portal:
desc: "Typecheck developer portal build variant"
deps: [install]
cmds:
- npx tsc --noEmit --project portal/tsconfig.json
typecheck:shared:
desc: "Typecheck the shared design system"
deps: [install]
cmds:
- npx tsc --noEmit --project shared/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/portal/tsconfig.json }
typecheck:all:
desc: "Typecheck all build variants"
@@ -365,7 +319,6 @@ tasks:
- task: typecheck:scripts
- task: typecheck:prototypes
- task: typecheck:portal
- task: typecheck:shared
# ============================================================
# Quality Gate
@@ -394,7 +347,6 @@ tasks:
- task: lint
- task: format:check
- task: build
- task: build:portal
- task: test
- task: storybook:build
@@ -404,6 +356,11 @@ tasks:
test:
desc: "Run tests"
cmds:
- task: test:editor
test:editor:
desc: "Run editor tests"
deps: [prepare]
cmds:
- npx vitest run --root editor
@@ -439,6 +396,23 @@ tasks:
# Code Generation
# ============================================================
tool-models:
desc: "Generate tool API types from the Java OpenAPI spec"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts
sources:
- editor/scripts/generate-tool-api-types.mts
- ../SwaggerDoc.json
generates:
- editor/src/core/types/toolApiTypes.ts
tool-models:check:
desc: "Fail if committed tool API types are out of date"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --check
licenses:generate:
desc: "Generate frontend license report"
deps: [install]
@@ -452,7 +426,7 @@ tasks:
clean:
desc: "Clean build artifacts and caches"
cmds:
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist, dist-portal
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist
platforms: [windows]
- cmd: rm -rf node_modules/.vite editor/dist dist dist-portal
- cmd: rm -rf node_modules/.vite editor/dist dist
platforms: [linux, darwin]
+2 -1
View File
@@ -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
+3 -3
View File
@@ -92,7 +92,7 @@ Visit the [Lombok website](https://projectlombok.org/setup/) for installation in
5. Add environment variable
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
5. **Frontend Setup (Required for Stirling 2.0)**
6. **Frontend Setup (Required for Stirling 2.0)**
Navigate to the frontend directory and install dependencies using npm.
### Verify Setup
@@ -275,7 +275,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
1. Set the security environment variable:
```bash
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
export DISABLE_ADDITIONAL_FEATURES=true # or false to enable login and security features for builds
```
2. Build the project:
@@ -305,7 +305,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
```
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 use case
## 7. Testing
+2 -2
View File
@@ -20,8 +20,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
* All content that resides under the "frontend/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE".
* All content that resides under the "frontend/editor/src/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
+2 -2
View File
@@ -53,8 +53,8 @@ For full installation options (including desktop and Kubernetes), see our [Docum
## Support
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
- **Community**: [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [GitHub Issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
## Contributing
+14 -76
View File
@@ -79,78 +79,23 @@ tasks:
OPEN: "true"
dev:portal:
desc: "Start backend + developer portal concurrently on free ports"
desc: "Start backend + editor; the portal is an admin route at /portal"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
dev:portal:all:
desc: "Start backend + developer portal + editor concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# Point the portal's "Editor" app switcher at the editor we spawn here.
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
OPEN: "true"
- task: frontend:dev
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
dev:portal:proxy:
desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:proprietary
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
SUBPATH: portal
MOCKS: 'false'
- task: frontend:dev:portal:proxy:serve
vars:
PORT: '{{.PROXY_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}'
PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}'
OPEN: "true"
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
@@ -198,23 +143,6 @@ tasks:
- task: backend:build
- task: frontend:build
preview:portal:proxy:
desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
- task: frontend:preview:portal:proxy
vars:
PORT: '{{.PROXY_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# ============================================================
# Test
# ============================================================
@@ -257,6 +185,16 @@ tasks:
- task: frontend:format:check
- task: engine:format:check
# ============================================================
# Code generation
# ============================================================
tool-models:
desc: "Generate all API models from the Java OpenAPI spec"
cmds:
- task: frontend:tool-models
- task: engine:tool-models
# ============================================================
# Quality Gate
# ============================================================
+8
View File
@@ -0,0 +1,8 @@
# Committed defaults for `task backend:dev:proprietary` (self-hosted / proprietary
# flavor). Local overrides + secrets live in app/.env.proprietary.local (ignored).
# Combined-billing account link (Mode A). Feature-flagged: OFF until release.
# Flip to true in app/.env.proprietary.local to test linking locally.
STIRLING_BILLING_ACCOUNT_LINK_ENABLED=false
# SaaS base URL the linked instance calls (register + entitlement).
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL=https://stirling.com/app
+1
View File
@@ -1,3 +1,4 @@
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
# stays ignored via the root .gitignore.
!.env.saas
!.env.proprietary
+20
View File
@@ -80,10 +80,18 @@
"moduleName": ".*",
"moduleLicense": "Apache License Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License, version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "The Apache License, Version 2.0"
@@ -108,6 +116,10 @@
"moduleName": ".*",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
},
{
"moduleName": ".*",
"moduleLicense": "Mozilla Public License Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "CDDL+GPL License"
@@ -172,6 +184,14 @@
"moduleName": ".*",
"moduleLicense": "Eclipse Public License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "EPL-2.0"
},
{
"moduleName": ".*",
"moduleLicense": "LGPL-2.1-only"
},
{
"moduleName": ".*",
"moduleLicense": "Ubuntu Font Licence 1.0"
@@ -132,7 +132,7 @@ public class AppConfig {
return true;
}
Path mountInfo = Path.of("/proc/1/mountinfo");
// this should always exist, if not some unknown usecase
// this should always exist, if not some unknown use case
if (!Files.exists(mountInfo)) {
return true;
}
@@ -206,6 +206,11 @@ public class ApplicationProperties {
@Data
public static class Policies {
/**
* Master switch for the policy + sources subsystem (the PAYG-metered automation surface).
*/
private boolean enabled = false;
/**
* Absolute directories that policy folder input sources and output sinks may read from or
* write to. Empty (the default) disables folder access entirely, so a policy can never be
@@ -514,6 +519,14 @@ public class ApplicationProperties {
private String accessibilityStatement;
private String cookiePolicy;
private String impressum;
private LoginAgreement loginAgreement = new LoginAgreement();
@Data
public static class LoginAgreement {
private boolean enabled = false;
private boolean showInAnonymousMode = true;
private String fallbackText = "";
}
}
@Data
@@ -582,7 +595,7 @@ public class ApplicationProperties {
public static class SAML2 {
private String provider;
private Boolean enabled = false;
private Boolean autoCreateUser = false;
private Boolean autoCreateUser = true;
private Boolean blockRegistration = false;
private String registrationId = "stirling";
@@ -659,7 +672,7 @@ public class ApplicationProperties {
private String issuer;
private String clientId;
@ToString.Exclude private String clientSecret;
private Boolean autoCreateUser = false;
private Boolean autoCreateUser = true;
private Boolean blockRegistration = false;
private String useAsUsername;
private Collection<String> scopes = new ArrayList<>();
@@ -730,7 +743,6 @@ public class ApplicationProperties {
@Data
public static class Jwt {
private boolean enableKeystore = true;
private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
/**
@@ -834,8 +846,8 @@ public class ApplicationProperties {
@Data
public static class Trust {
private boolean serverAsAnchor = true;
private boolean useSystemTrust = false;
private boolean useMozillaBundle = false;
private boolean useSystemTrust = true;
private boolean useMozillaBundle = true;
private boolean useAATL = false;
private boolean useEUTL = false;
}
@@ -878,10 +890,10 @@ public class ApplicationProperties {
private Boolean enableAnalytics;
private Boolean enablePosthog;
private Boolean enableScarf;
private Boolean enableDesktopInstallSlide;
private Boolean enableDesktopInstallSlide = true;
private Datasource datasource;
private boolean disableSanitize;
private int maxDPI;
private int maxDPI = 500;
private boolean enableUrlToPDF;
private Html html = new Html();
private CustomPaths customPaths = new CustomPaths();
@@ -895,8 +907,9 @@ public class ApplicationProperties {
private String frontendUrl; // Frontend URL for invite email links (e.g.
// 'https://app.example.com'). If not set, falls back to backendUrl.
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
private ServerCertificate serverCertificate = new ServerCertificate();
@Data
public static class MobileScannerSettings {
@@ -906,6 +919,16 @@ public class ApplicationProperties {
private boolean stretchToFit = false; // Whether to stretch image to fill page
}
@Data
public static class ServerCertificate {
private boolean enabled =
true; // Enable server-side "Sign with Stirling-PDF" certificate
private String organizationName = "Stirling PDF Inc";
private int validity = 365; // Certificate validity in days
private boolean regenerateOnStartup =
false; // Generate a new certificate on each startup
}
public boolean isAnalyticsEnabled() {
return this.enableAnalytics != null && this.enableAnalytics;
}
@@ -990,7 +1013,7 @@ public class ApplicationProperties {
@Data
public static class Sharing {
private boolean enabled = false;
private boolean linkEnabled = false;
private boolean linkEnabled = true;
private boolean emailEnabled = false;
private int linkExpirationDays = 3;
}
@@ -1164,7 +1187,7 @@ public class ApplicationProperties {
@Data
public static class Metrics {
private boolean enabled;
private boolean enabled = true;
}
@Data
@@ -1216,7 +1239,7 @@ public class ApplicationProperties {
private boolean enableInvites = false;
private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days)
private String host;
private int port;
private int port = 587;
private String username;
@ToString.Exclude private String password;
private String from;
@@ -1243,10 +1266,10 @@ public class ApplicationProperties {
@ToString.Exclude private String botToken;
private String botUsername;
private String pipelineInboxFolder = "telegram";
private Boolean customFolderSuffix = false;
private Boolean enableAllowUserIDs = false;
private Boolean customFolderSuffix = true;
private Boolean enableAllowUserIDs = true;
private List<Long> allowUserIDs = new ArrayList<>();
private Boolean enableAllowChannelIDs = false;
private Boolean enableAllowChannelIDs = true;
private List<Long> allowChannelIDs = new ArrayList<>();
private long processingTimeoutSeconds = 180;
private long pollingIntervalMillis = 2000;
@@ -0,0 +1,204 @@
package stirling.software.common.service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
// Resolves login agreement text from customFiles/disclaimer/<locale>.md (read live);
// enable/visibility come from the legal.loginAgreement settings.
@Service
@Slf4j
public class LoginAgreementService {
// Locale codes only: rejects path separators and dots so the value can never escape the
// disclaimer directory. Matches e.g. en, en-GB, fr-FR, zh-Hant, pt-BR.
private static final Pattern LOCALE_PATTERN =
Pattern.compile("^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$");
// BCP-47 tags are well under this; the cap also prevents the regex's repetition group
// from recursing far enough to overflow the stack on a hostile over-length input.
private static final int MAX_LOCALE_LENGTH = 35;
// Disclaimers are short markdown; cap the read so an oversized file can't be loaded
// wholesale into heap on every public request.
private static final long MAX_FILE_BYTES = 256 * 1024;
private final ApplicationProperties applicationProperties;
public LoginAgreementService(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
public boolean isEnabled() {
return config().isEnabled();
}
public boolean isShowInAnonymousMode() {
return config().isShowInAnonymousMode();
}
/**
* Resolve the markdown to show for the requested language, falling back through the base
* language, the configured default locale (and its base), then the configured fallbackText.
* Returns an empty string when nothing is configured.
*/
public String resolveContent(String requestedLang) {
List<String> candidates = new ArrayList<>();
addLocaleCandidates(candidates, requestedLang);
addLocaleCandidates(candidates, applicationProperties.getSystem().getDefaultLocale());
for (String candidate : candidates) {
String content = readFileIfExists(candidate);
if (content != null && !content.isBlank()) {
return content;
}
}
String fallback = config().getFallbackText();
return fallback == null ? "" : fallback;
}
/**
* Admin read of a single locale's raw file. Returns null for an invalid locale, "" if absent.
*/
public String readRawForLocale(String locale) {
if (!isValidLocale(locale)) {
return null;
}
String content = readFileIfExists(locale);
return content == null ? "" : content;
}
/** Admin write. Blank content deletes the file so it falls back cleanly. */
public void writeForLocale(String locale, String content) throws IOException {
Path file = resolveLocaleFile(locale);
if (file == null) {
throw new IllegalArgumentException("Invalid locale: " + locale);
}
if (content == null || content.isBlank()) {
Files.deleteIfExists(file);
return;
}
Files.createDirectories(file.getParent());
// Write to a sibling temp file then atomically swap, so a concurrent reader (the public
// /login-disclaimer fetch is lockless) never observes a truncated/partial file.
Path tmp = Files.createTempFile(file.getParent(), "disclaimer", ".md.tmp");
try {
Files.writeString(tmp, content, StandardCharsets.UTF_8);
try {
Files.move(
tmp,
file,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(tmp);
}
}
/** Locales that currently have a markdown file, for the admin editor. */
public Set<String> listLocalesWithContent() {
Set<String> result = new TreeSet<>();
Path dir = disclaimerDir();
if (!Files.isDirectory(dir)) {
return result;
}
try (Stream<Path> files = Files.list(dir)) {
files.filter(Files::isRegularFile)
.map(path -> path.getFileName().toString())
.filter(name -> name.endsWith(".md"))
.map(name -> name.substring(0, name.length() - ".md".length()))
.filter(this::isValidLocale)
.forEach(result::add);
} catch (IOException e) {
log.warn("Failed listing login agreement files", e);
}
return result;
}
private ApplicationProperties.Legal.LoginAgreement config() {
return applicationProperties.getLegal().getLoginAgreement();
}
private Path disclaimerDir() {
return Path.of(InstallationPathConfig.getCustomFilesPath(), "disclaimer").normalize();
}
private void addLocaleCandidates(List<String> out, String locale) {
if (!isValidLocale(locale)) {
return;
}
if (!out.contains(locale)) {
out.add(locale);
}
String base = locale.split("[_-]", 2)[0];
if (!base.equals(locale) && !out.contains(base)) {
out.add(base);
}
}
private String readFileIfExists(String locale) {
Path file = resolveLocaleFile(locale);
if (file == null) {
return null;
}
try {
// NOFOLLOW_LINKS: a symlinked entry is treated as non-regular and skipped, so a
// planted symlink can't expose files outside the disclaimer dir via the public read.
if (Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
if (Files.size(file) > MAX_FILE_BYTES) {
log.warn(
"Login agreement file for locale {} exceeds {} bytes; ignoring",
locale,
MAX_FILE_BYTES);
return null;
}
return Files.readString(file, StandardCharsets.UTF_8);
}
} catch (IOException e) {
log.warn("Failed reading login agreement file for locale {}", locale, e);
}
return null;
}
private Path resolveLocaleFile(String locale) {
if (!isValidLocale(locale)) {
return null;
}
Path dir = disclaimerDir();
Path file = dir.resolve(locale + ".md").normalize();
// Defence in depth: the regex already blocks separators, but confirm containment.
if (!file.startsWith(dir)) {
return null;
}
return file;
}
private boolean isValidLocale(String locale) {
// Length check BEFORE the regex: LOCALE_PATTERN's repetition group recurses one stack
// frame per repeat in java.util.regex, so an unbounded input could overflow the stack.
return locale != null
&& locale.length() <= MAX_LOCALE_LENGTH
&& LOCALE_PATTERN.matcher(locale).matches();
}
}
@@ -0,0 +1,201 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mockStatic;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
/**
* Unit tests for {@link LoginAgreementService}. The service resolves per-language markdown from
* {@code <customFiles>/disclaimer/<locale>.md}; here {@link
* InstallationPathConfig#getCustomFilesPath()} is mocked to a {@link TempDir} so file IO is
* isolated.
*/
class LoginAgreementServiceTest {
@TempDir Path customFilesDir;
private ApplicationProperties properties;
private ApplicationProperties.Legal.LoginAgreement config;
private LoginAgreementService service;
private Path disclaimerDir;
@BeforeEach
void setUp() {
properties = new ApplicationProperties();
config = properties.getLegal().getLoginAgreement();
service = new LoginAgreementService(properties);
disclaimerDir = customFilesDir.resolve("disclaimer");
}
/**
* Run {@code action} with InstallationPathConfig.getCustomFilesPath() pointing at the temp dir.
*/
private void withMockedPath(Runnable action) {
try (MockedStatic<InstallationPathConfig> mocked =
mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getCustomFilesPath)
.thenReturn(customFilesDir.toString());
action.run();
}
}
private void writeFile(String locale, String content) throws IOException {
Files.createDirectories(disclaimerDir);
Files.writeString(disclaimerDir.resolve(locale + ".md"), content, StandardCharsets.UTF_8);
}
@Test
void flagsReflectConfig() {
config.setEnabled(true);
config.setShowInAnonymousMode(false);
assertTrue(service.isEnabled());
assertFalse(service.isShowInAnonymousMode());
}
@Test
void resolveContentReturnsExactLocaleFile() throws IOException {
writeFile("fr-FR", "# Avis");
withMockedPath(() -> assertEquals("# Avis", service.resolveContent("fr-FR")));
}
@Test
void resolveContentFallsBackToBaseLanguage() throws IOException {
// Only a language-only file exists; a region-specific request should fall back to it.
writeFile("de", "# Hinweis");
withMockedPath(() -> assertEquals("# Hinweis", service.resolveContent("de-DE")));
}
@Test
void resolveContentFallsBackToDefaultLocale() throws IOException {
properties.getSystem().setDefaultLocale("en-GB");
writeFile("en-GB", "# Notice");
// No file for the requested locale -> falls through to the configured default locale.
withMockedPath(() -> assertEquals("# Notice", service.resolveContent("es-ES")));
}
@Test
void resolveContentFallsBackToFallbackTextWhenNoFile() {
config.setFallbackText("# Fallback");
withMockedPath(() -> assertEquals("# Fallback", service.resolveContent("ja-JP")));
}
@Test
void resolveContentReturnsEmptyWhenNothingConfigured() {
withMockedPath(() -> assertEquals("", service.resolveContent("ja-JP")));
}
@Test
void resolveContentDoesNotEscapeDisclaimerDirectory() throws IOException {
// Plant a file outside the disclaimer dir; a traversal-style locale must not read it.
Files.writeString(
customFilesDir.resolve("secret.md"), "TOP SECRET", StandardCharsets.UTF_8);
config.setFallbackText("safe");
withMockedPath(
() -> {
assertEquals("safe", service.resolveContent("../secret"));
assertEquals("safe", service.resolveContent("..%2Fsecret"));
assertEquals("safe", service.resolveContent("/etc/passwd"));
});
}
@Test
void readRawRejectsInvalidLocale() {
withMockedPath(
() -> {
assertNull(service.readRawForLocale("../secret"));
assertNull(service.readRawForLocale("en/GB"));
assertNull(service.readRawForLocale("C:\\x"));
assertNull(service.readRawForLocale(null));
});
}
@Test
void readRawReturnsEmptyForValidButAbsentLocale() {
withMockedPath(() -> assertEquals("", service.readRawForLocale("pt-BR")));
}
@Test
void overlongLocaleIsRejectedWithoutStackOverflow() {
// Guards against the regex-recursion stack overflow on unbounded input.
String hostile = "en" + "-ab".repeat(4000);
withMockedPath(
() -> {
assertDoesNotThrow(() -> service.readRawForLocale(hostile));
assertNull(service.readRawForLocale(hostile));
assertDoesNotThrow(() -> service.resolveContent(hostile));
});
}
@Test
void writeThenReadRoundTrips() throws IOException {
withMockedPath(
() -> {
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", "# Bonjour"));
assertEquals("# Bonjour", service.readRawForLocale("fr-FR"));
});
assertTrue(Files.isRegularFile(disclaimerDir.resolve("fr-FR.md")));
}
@Test
void writeBlankDeletesFile() throws IOException {
writeFile("fr-FR", "# Bonjour");
withMockedPath(
() -> {
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", " "));
assertEquals("", service.readRawForLocale("fr-FR"));
});
assertFalse(Files.exists(disclaimerDir.resolve("fr-FR.md")));
}
@Test
void writeRejectsInvalidLocale() {
withMockedPath(
() ->
assertThrows(
IllegalArgumentException.class,
() -> service.writeForLocale("../escape", "x")));
}
@Test
void listLocalesWithContentReturnsOnlyValidMarkdownFiles() throws IOException {
writeFile("en-GB", "a");
writeFile("fr-FR", "b");
Files.writeString(disclaimerDir.resolve("notes.txt"), "x", StandardCharsets.UTF_8);
withMockedPath(
() -> {
var locales = service.listLocalesWithContent();
assertTrue(locales.contains("en-GB"));
assertTrue(locales.contains("fr-FR"));
assertEquals(2, locales.size());
});
}
@Test
void oversizedFileIsIgnored() throws IOException {
// Files beyond the read cap are skipped rather than loaded into heap.
byte[] big = new byte[300 * 1024];
java.util.Arrays.fill(big, (byte) 'x');
Files.createDirectories(disclaimerDir);
Files.write(disclaimerDir.resolve("en-GB.md"), big);
config.setFallbackText("small-fallback");
properties.getSystem().setDefaultLocale("en-GB");
withMockedPath(() -> assertEquals("small-fallback", service.resolveContent("en-GB")));
}
}
@@ -24,12 +24,14 @@ import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.Locale;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.bouncycastle.cert.jcajce.JcaCertStore;
@@ -50,6 +52,13 @@ public abstract class CreateSignatureBase implements SignatureInterface {
@Getter private Certificate[] certificateChain;
@Setter private String tsaUrl;
/**
* Provider that must service the signing operation. Set for hardware-held keys (SunPKCS11 for
* USB tokens, SunMSCAPI for the Windows store) so the {@link java.security.Signature} runs on
* the token. Left {@code null} for software keystores, which use the default provider.
*/
@Setter private Provider signingProvider;
/**
* Specifies whether the external signing scenario should be used. If set to {@code true},
* external signing will be performed and {@link SignatureInterface} will be used for signing.
@@ -80,25 +89,48 @@ public abstract class CreateSignatureBase implements SignatureInterface {
NoSuchAlgorithmException,
IOException,
CertificateException {
// grabs the first alias from the keystore and get the private key. An
// alternative method or constructor could be used for setting a specific
// alias that should be used.
this(keystore, pin, null);
}
/**
* Initialize the signature creator, optionally selecting a specific certificate by alias. A
* hardware token / the Windows store can hold several certificates, so the caller picks one;
* when {@code requestedAlias} is null the first usable entry is used (software keystore
* behaviour).
*
* @param keystore the keystore (software, PKCS#11 or Windows-MY)
* @param pin the keystore / token PIN, may be null for the Windows store
* @param requestedAlias the alias to sign with, or null to pick the first usable entry
*/
public CreateSignatureBase(KeyStore keystore, char[] pin, String requestedAlias)
throws KeyStoreException,
UnrecoverableKeyException,
NoSuchAlgorithmException,
IOException,
CertificateException {
if (requestedAlias != null
&& !requestedAlias.isBlank()
&& keystore.containsAlias(requestedAlias)) {
privateKey = (PrivateKey) keystore.getKey(requestedAlias, pin);
certificateChain = resolveChain(keystore, requestedAlias);
if (certificateChain == null) {
throw new IOException("Could not find certificate for alias " + requestedAlias);
}
checkValidity(certificateChain[0]);
return;
}
// grabs the first alias from the keystore and gets the private key.
Enumeration<String> aliases = keystore.aliases();
String alias;
Certificate cert = null;
while (cert == null && aliases.hasMoreElements()) {
alias = aliases.nextElement();
String alias = aliases.nextElement();
privateKey = (PrivateKey) keystore.getKey(alias, pin);
Certificate[] certChain = keystore.getCertificateChain(alias);
Certificate[] certChain = resolveChain(keystore, alias);
if (certChain != null) {
certificateChain = certChain;
cert = certChain[0];
if (cert instanceof X509Certificate) {
// avoid expired certificate
((X509Certificate) cert).checkValidity();
//// SigUtils.checkCertificateUsage((X509Certificate) cert);
}
checkValidity(cert);
}
}
@@ -107,6 +139,27 @@ public abstract class CreateSignatureBase implements SignatureInterface {
}
}
/**
* Resolve the certificate chain for an alias. PKCS#11 tokens and the Windows store frequently
* expose only the leaf certificate (a null chain), so fall back to the single certificate.
*/
private static Certificate[] resolveChain(KeyStore keystore, String alias)
throws KeyStoreException {
Certificate[] chain = keystore.getCertificateChain(alias);
if (chain != null && chain.length > 0) {
return chain;
}
Certificate single = keystore.getCertificate(alias);
return single != null ? new Certificate[] {single} : null;
}
private static void checkValidity(Certificate cert) throws CertificateException {
if (cert instanceof X509Certificate x509Cert) {
// avoid expired certificate
x509Cert.checkValidity();
}
}
public final void setPrivateKey(PrivateKey privateKey) {
this.privateKey = privateKey;
}
@@ -136,12 +189,18 @@ public abstract class CreateSignatureBase implements SignatureInterface {
try {
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
X509Certificate cert = (X509Certificate) certificateChain[0];
ContentSigner sha1Signer =
new JcaContentSignerBuilder("SHA256WithRSA").build(privateKey);
JcaContentSignerBuilder signerBuilder =
new JcaContentSignerBuilder(resolveSignatureAlgorithm(privateKey, cert));
// Hardware keys (PKCS#11 / Windows store) must sign on their own provider so the
// operation runs on the token; software keys use the default provider.
if (signingProvider != null) {
signerBuilder.setProvider(signingProvider);
}
ContentSigner signer = signerBuilder.build(privateKey);
gen.addSignerInfoGenerator(
new JcaSignerInfoGeneratorBuilder(
new JcaDigestCalculatorProviderBuilder().build())
.build(sha1Signer, cert));
.build(signer, cert));
gen.addCertificates(new JcaCertStore(Arrays.asList(certificateChain)));
CMSProcessableInputStream msg = new CMSProcessableInputStream(content);
CMSSignedData signedData = gen.generate(msg, false);
@@ -157,4 +216,26 @@ public abstract class CreateSignatureBase implements SignatureInterface {
throw new IOException(e);
}
}
/**
* Pick a SHA-256 signature algorithm that matches the key type. RSA keeps the historical
* default; EC / EdDSA tokens are common, so they are handled too.
*/
private static String resolveSignatureAlgorithm(PrivateKey key, X509Certificate cert) {
String alg = key.getAlgorithm();
if (alg == null || alg.isBlank()) {
alg = cert.getPublicKey().getAlgorithm();
}
alg = alg == null ? "" : alg.toUpperCase(Locale.ROOT);
if (alg.contains("ED25519") || alg.contains("EDDSA")) {
return "Ed25519";
}
if (alg.contains("EC")) { // EC, ECDSA
return "SHA256withECDSA";
}
if (alg.contains("DSA")) {
return "SHA256withDSA";
}
return "SHA256withRSA";
}
}
@@ -85,7 +85,6 @@ public class WebMvcConfig implements WebMvcConfigurer {
"/icons/**",
"/modern-logo/**",
"/classic-logo/**",
"/robots.txt",
"/3rdPartyLicenses.json",
"/pdfjs/**",
"/pdfjs-legacy/**",
@@ -3,9 +3,12 @@ package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
@@ -261,10 +264,19 @@ public class RearrangePagesPDFController {
log.info("newPageOrder = {}", newPageOrder);
log.info("totalPages = {}", totalPages);
// Snapshot the desired pages before mutating the source document's page tree.
// Snapshot desired pages before mutating the tree; clone repeats (e.g. DUPLICATE)
// so each slot is a distinct node, not one PDPage under multiple /Kids.
List<PDPage> newPages = new ArrayList<>(newPageOrder.size());
Set<Integer> seenIndices = new HashSet<>();
for (Integer idx : newPageOrder) {
newPages.add(document.getPage(idx));
PDPage page = document.getPage(idx);
if (!seenIndices.add(idx)) {
// Duplicate index: distinct page node sharing content/resources.
COSDictionary clonedDict = new COSDictionary();
clonedDict.addAll(page.getCOSObject());
page = new PDPage(clonedDict);
}
newPages.add(page);
}
// Rearrange in-place on the source document rather than copying pages into a
@@ -350,6 +350,18 @@ public class ConfigController {
"serverCertificateEnabled",
serverCertificateService != null && serverCertificateService.isEnabled());
// Hardware-backed signing (Windows store / USB PKCS#11 tokens) is only viable on the
// desktop bundle, where the backend runs locally in the user's session. The Tauri
// bundle signals this via STIRLING_PDF_TAURI_MODE (machineType is Server-jar there);
// the bare-jar desktop launcher signals it via a Client-* machineType.
boolean hardwareSigningAvailable =
Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"));
if (!hardwareSigningAvailable && applicationContext.containsBean("machineType")) {
String mt = applicationContext.getBean("machineType", String.class);
hardwareSigningAvailable = mt != null && mt.startsWith("Client-");
}
configData.put("hardwareSigningAvailable", hardwareSigningAvailable);
// Legal settings
configData.put(
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
@@ -0,0 +1,49 @@
package stirling.software.SPDF.controller.api.misc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.service.LoginAgreementService;
/**
* Serves the login agreement / disclaimer for the frontend. Shares the /api/v1/config access rules:
* it requires authentication when login is enabled (the modal is shown after login, never on the
* login screen) and is permit-all in anonymous/no-login mode and in SaaS. The text is read live
* from disk, so admin edits take effect on the next login without a restart.
*/
@ConfigApi
@Hidden
@RequiredArgsConstructor
public class LoginDisclaimerController {
private final LoginAgreementService loginAgreementService;
@GetMapping("/login-disclaimer")
@Operation(
summary = "Get the login agreement/disclaimer",
description =
"Returns whether the login agreement is enabled and, if so, the markdown to"
+ " display for the requested language.")
public LoginDisclaimerResponse getLoginDisclaimer(
@RequestParam(name = "lang", required = false) String lang) {
boolean showInAnonymousMode = loginAgreementService.isShowInAnonymousMode();
if (!loginAgreementService.isEnabled()) {
return new LoginDisclaimerResponse(false, showInAnonymousMode, "", "markdown");
}
String content = loginAgreementService.resolveContent(lang);
// Enabled but no resolvable text (no file for any candidate locale and no fallbackText):
// report disabled so clients don't try to render an empty agreement.
boolean hasContent = content != null && !content.isBlank();
return new LoginDisclaimerResponse(
hasContent, showInAnonymousMode, hasContent ? content : "", "markdown");
}
public record LoginDisclaimerResponse(
boolean enabled, boolean showInAnonymousMode, String content, String format) {}
}
@@ -70,10 +70,13 @@ import io.micrometer.common.util.StringUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -109,14 +112,17 @@ public class CertSignController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ServerCertificateServiceInterface serverCertificateService;
private final TempFileManager tempFileManager;
private final HardwareKeyStoreService hardwareKeyStoreService;
public CertSignController(
CustomPDFDocumentFactory pdfDocumentFactory,
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
TempFileManager tempFileManager) {
TempFileManager tempFileManager,
HardwareKeyStoreService hardwareKeyStoreService) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.serverCertificateService = serverCertificateService;
this.tempFileManager = tempFileManager;
this.hardwareKeyStoreService = hardwareKeyStoreService;
}
public static void sign(
@@ -170,7 +176,8 @@ public class CertSignController {
"This endpoint accepts a PDF file, a digital certificate and related"
+ " information to sign the PDF. It then returns the digitally signed PDF"
+ " file. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<Resource> signPDFWithCert(@ModelAttribute SignPDFWithCertRequest request)
public ResponseEntity<Resource> signPDFWithCert(
@ModelAttribute SignPDFWithCertRequest request, HttpServletRequest httpRequest)
throws Exception {
MultipartFile pdf = request.getFileInput();
String certType = request.getCertType();
@@ -196,6 +203,8 @@ public class CertSignController {
KeyStore ks = null;
String keystorePassword = password;
Provider signingProvider = null;
HardwareKeyStoreService.Pkcs11Session pkcs11Session = null;
switch (certType) {
case "PEM":
@@ -245,6 +254,31 @@ public class CertSignController {
ks = serverCertificateService.getServerKeyStore();
keystorePassword = serverCertificateService.getServerCertificatePassword();
break;
case "WINDOWS_STORE":
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
ks = hardwareKeyStoreService.loadWindowsKeyStore();
signingProvider = hardwareKeyStoreService.windowsProvider();
// PIN is prompted by the Windows CSP / token middleware, not passed here.
keystorePassword = password;
break;
case "PKCS11":
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
char[] pkcs11Pin = password != null ? password.toCharArray() : null;
try {
pkcs11Session =
hardwareKeyStoreService.openPkcs11(
request.getPkcs11LibraryPath(),
request.getPkcs11Slot(),
pkcs11Pin);
} finally {
if (pkcs11Pin != null) {
java.util.Arrays.fill(pkcs11Pin, '\0');
}
}
ks = pkcs11Session.keyStore();
signingProvider = pkcs11Session.provider();
keystorePassword = password;
break;
default:
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
@@ -252,7 +286,9 @@ public class CertSignController {
"certificate type: " + certType);
}
CreateSignature createSignature = new CreateSignature(ks, keystorePassword.toCharArray());
char[] pin = keystorePassword != null ? keystorePassword.toCharArray() : null;
CreateSignature createSignature =
new CreateSignature(ks, pin, request.getAlias(), signingProvider);
TempFile signedOut = tempFileManager.createManagedTempFile(".pdf");
try (OutputStream os = new FileOutputStream(signedOut.getFile())) {
sign(
@@ -269,6 +305,14 @@ public class CertSignController {
} catch (IOException e) {
signedOut.close();
throw e;
} finally {
// Clear the PIN copy and log out the token session once signing is done.
if (pin != null) {
java.util.Arrays.fill(pin, '\0');
}
if (pkcs11Session != null) {
pkcs11Session.close();
}
}
// Return the signed PDF
return WebResponseUtils.pdfFileToWebResponse(
@@ -324,7 +368,22 @@ public class CertSignController {
NoSuchAlgorithmException,
IOException,
CertificateException {
super(keystore, pin);
this(keystore, pin, null, null);
}
public CreateSignature(
KeyStore keystore, char[] pin, String alias, Provider signingProvider)
throws KeyStoreException,
UnrecoverableKeyException,
NoSuchAlgorithmException,
IOException,
CertificateException {
super(keystore, pin, alias);
setSigningProvider(signingProvider);
loadLogo();
}
private void loadLogo() throws IOException {
ClassPathResource resource = new ClassPathResource("static/images/signature.png");
try (InputStream is = resource.getInputStream()) {
logoFile = Files.createTempFile("signature", ".png").toFile();
@@ -0,0 +1,85 @@
package stirling.software.SPDF.controller.api.security;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.HardwareCertificateInfo;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
import stirling.software.SPDF.model.api.security.Pkcs11CertificatesRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
/**
* Lets the desktop frontend discover which hardware-backed signing options the local backend can
* reach (Windows certificate store, plugged-in USB / PKCS#11 tokens) and enumerate the certificates
* available to sign with. Enumeration endpoints are restricted to the desktop bundle, reached over
* loopback - see {@link HardwareKeyStoreService#assertLocalDesktop}.
*/
@RestController
@RequestMapping("/api/v1/security/cert-sign/hardware")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Security", description = "Security APIs")
public class HardwareSigningController {
private final HardwareKeyStoreService hardwareKeyStoreService;
@GetMapping("/capabilities")
@Operation(
summary = "Hardware signing capabilities",
description =
"Reports whether hardware-backed signing is available on this device and which"
+ " PKCS#11 driver libraries were detected. Returns desktop=false when"
+ " not running as the desktop app.")
public ResponseEntity<HardwareSigningCapabilities> getCapabilities() {
return ResponseEntity.ok(hardwareKeyStoreService.capabilities());
}
@GetMapping("/windows-certificates")
@Operation(
summary = "List Windows certificate store signing certificates",
description =
"Enumerates certificates with a usable private key from the current user's"
+ " Windows certificate store. Desktop-only, loopback-only.")
public ResponseEntity<List<HardwareCertificateInfo>> getWindowsCertificates(
HttpServletRequest request) throws Exception {
hardwareKeyStoreService.assertLocalDesktop(request);
return ResponseEntity.ok(hardwareKeyStoreService.listWindowsCertificates());
}
@PostMapping("/pkcs11-certificates")
@Operation(
summary = "List PKCS#11 token signing certificates",
description =
"Logs into a PKCS#11 token with the supplied PIN and enumerates its signing"
+ " certificates. The PIN is used only for this call. Desktop-only,"
+ " loopback-only.")
public ResponseEntity<List<HardwareCertificateInfo>> getPkcs11Certificates(
HttpServletRequest request, @RequestBody Pkcs11CertificatesRequest body)
throws Exception {
hardwareKeyStoreService.assertLocalDesktop(request);
char[] pin = body.pin() != null ? body.pin().toCharArray() : null;
try {
return ResponseEntity.ok(
hardwareKeyStoreService.listPkcs11Certificates(
body.libraryPath(), body.slot(), pin));
} finally {
if (pin != null) {
java.util.Arrays.fill(pin, '\0');
}
}
}
}
@@ -102,8 +102,27 @@ public class ValidateSignatureController {
try (PDDocument document = pdfDocumentFactory.load(file.getInputStream())) {
List<PDSignature> signatures = document.getSignatureDictionaries();
// Detect content appended outside every signature's ByteRange (added after signing). A
// properly signed document has its last signature cover all the way to EOF; if the
// furthest any signature reaches stops short of the file length, the tail is unsigned.
// Taking the max across all signatures avoids false positives on legitimately
// multi-signed PDFs, where an earlier signature intentionally omits later revisions.
long fileLength = file.getSize();
long maxCovered = 0;
for (PDSignature sig : signatures) {
int[] byteRange = sig.getByteRange();
if (byteRange != null && byteRange.length == 4) {
long end = (long) byteRange[2] + byteRange[3];
if (end > maxCovered) {
maxCovered = end;
}
}
}
boolean documentCovered = maxCovered <= 0 || maxCovered >= fileLength;
for (PDSignature sig : signatures) {
SignatureValidationResult result = new SignatureValidationResult();
result.setCoversEntireDocument(documentCovered);
try {
byte[] signedContent = sig.getSignedContent(file.getInputStream());
@@ -0,0 +1,30 @@
package stirling.software.SPDF.controller.web;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import stirling.software.common.model.ApplicationProperties;
/**
* Serves /robots.txt dynamically so the system.googlevisibility flag actually controls
* search-engine indexing. 'true' returns an allow-all policy; 'false' returns a disallow-all policy
* to keep the instance out of search engines (useful for embedded/internal deployments).
*/
@RestController
public class RobotsController {
private final ApplicationProperties applicationProperties;
public RobotsController(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@GetMapping(value = "/robots.txt", produces = MediaType.TEXT_PLAIN_VALUE)
@ResponseBody
public String robotsTxt() {
boolean allowIndexing = applicationProperties.getSystem().isGooglevisibility();
return "User-agent: *\n" + (allowIndexing ? "Allow: /\n" : "Disallow: /\n");
}
}
@@ -1,5 +1,7 @@
package stirling.software.SPDF.model.api.general;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -17,20 +19,8 @@ public class PosterPdfRequest extends PDFFile {
allowableValues = {"A4", "Letter", "A3", "A5", "Legal", "Tabloid"})
private String pageSize = "A4";
@Schema(
description = "Horizontal decimation factor (how many columns to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
private int xFactor = 2;
@Schema(
description = "Vertical decimation factor (how many rows to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
private int yFactor = 2;
@Schema(
@@ -38,4 +28,36 @@ public class PosterPdfRequest extends PDFFile {
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean rightToLeft = false;
@JsonProperty("xFactor")
@Schema(
description = "Horizontal decimation factor (how many columns to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
public int getXFactor() {
return xFactor;
}
@JsonProperty("xFactor")
public void setXFactor(int xFactor) {
this.xFactor = xFactor;
}
@JsonProperty("yFactor")
@Schema(
description = "Vertical decimation factor (how many rows to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
public int getYFactor() {
return yFactor;
}
@JsonProperty("yFactor")
public void setYFactor(int yFactor) {
this.yFactor = yFactor;
}
}
@@ -29,7 +29,8 @@ public class AddPasswordRequest extends PDFFile {
description = "The length of the encryption key",
type = "integer",
allowableValues = {"40", "128", "256"},
requiredMode = Schema.RequiredMode.REQUIRED)
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "256")
private int keyLength = 256;
@Schema(description = "Whether document assembly is prevented", defaultValue = "false")
@@ -0,0 +1,21 @@
package stirling.software.SPDF.model.api.security;
/**
* Metadata for a single signing certificate discovered on a hardware source (Windows certificate
* store or a PKCS#11 token). Returned to the desktop frontend so the user can pick which
* certificate to sign with. Never carries private key material - signing always happens on the
* token / OS.
*/
public record HardwareCertificateInfo(
String alias,
String source,
String subject,
String issuer,
String subjectCommonName,
String issuerCommonName,
String serialNumber,
String keyAlgorithm,
String notBefore,
String notAfter,
boolean expired,
boolean notYetValid) {}
@@ -0,0 +1,19 @@
package stirling.software.SPDF.model.api.security;
import java.util.List;
/**
* Describes what hardware-backed signing the local backend can offer. Only meaningful on the
* desktop bundle, where the backend runs as a local sidecar in the signed-in user's session and can
* reach the Windows certificate store / a plugged-in USB PKCS#11 token.
*/
public record HardwareSigningCapabilities(
boolean desktop,
String osName,
boolean windowsStoreSupported,
boolean pkcs11Supported,
List<Pkcs11LibraryInfo> detectedLibraries) {
/** A PKCS#11 driver library detected on disk (or supplied via configuration). */
public record Pkcs11LibraryInfo(String name, String path) {}
}
@@ -0,0 +1,7 @@
package stirling.software.SPDF.model.api.security;
/**
* Request body for enumerating the certificates on a PKCS#11 token. The PIN is required to log into
* the token; it is used only for the duration of the call and never stored.
*/
public record Pkcs11CertificatesRequest(String libraryPath, Integer slot, String pin) {}
@@ -14,8 +14,10 @@ import stirling.software.common.model.api.PDFFile;
public class SignPDFWithCertRequest extends PDFFile {
@Schema(
description = "The type of the digital certificate",
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER"},
description =
"The type of the digital certificate. WINDOWS_STORE and PKCS11 are"
+ " hardware-backed and only available in the desktop app.",
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER", "WINDOWS_STORE", "PKCS11"},
requiredMode = Schema.RequiredMode.REQUIRED)
private String certType;
@@ -39,9 +41,31 @@ public class SignPDFWithCertRequest extends PDFFile {
@Schema(description = "The JKS keystore file (Java Key Store)")
private MultipartFile jksFile;
@Schema(description = "The password for the keystore or the private key", format = "password")
@Schema(
description =
"The password for the keystore / private key, or the token PIN for PKCS11",
format = "password")
private String password;
@Schema(
description =
"The alias of the certificate to sign with. Required for WINDOWS_STORE and"
+ " recommended for PKCS11 tokens holding multiple certificates.")
private String alias;
@Schema(
description =
"Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must"
+ " be an allowed driver - a detected one or configured via"
+ " STIRLING_PKCS11_LIBRARIES.")
private String pkcs11LibraryPath;
@Schema(
description =
"Optional PKCS#11 slot index. When omitted the first slot with a token is"
+ " used.")
private Integer pkcs11Slot;
@Schema(
description = "Whether to visually show the signature in the PDF file",
defaultValue = "false",
@@ -18,6 +18,11 @@ public class SignatureValidationResult {
// Time validation
private boolean notExpired;
// Whether the document's signatures cover all of its bytes. False when content was appended
// outside every signature's ByteRange (i.e. added after signing), which the signature can't
// attest to even though the signed bytes themselves remain cryptographically intact.
private boolean coversEntireDocument = true;
// Revocation validation
private boolean revocationChecked; // true if PKIX revocation was enabled
private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
@@ -115,7 +115,8 @@ public class CertificateValidationService {
log.info("Enabled AIA certificate fetching and revocation checking");
}
// Trust only what we explicitly opt into:
// Trust only what we explicitly opt into. Desktop follows the same flags as the server -
// our own signing cert is trusted via serverAsAnchor, not by force-loading every system CA.
if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor();
if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore();
if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts();
@@ -0,0 +1,483 @@
package stirling.software.SPDF.service;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.Provider;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.security.auth.x500.X500Principal;
import org.bouncycastle.asn1.x500.RDN;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x500.style.IETFUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.HardwareCertificateInfo;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities.Pkcs11LibraryInfo;
import stirling.software.common.util.ExceptionUtils;
/**
* Bridges PDF signing to hardware-held keys: the Windows certificate store (via the JDK SunMSCAPI
* provider) and USB / smart-card PKCS#11 tokens (via SunPKCS11). The private key never leaves the
* token - the JCA routes the actual signing operation onto the hardware.
*
* <p>These code paths are gated to the desktop bundle. On a hosted server the backend cannot reach
* a remote user's USB token anyway, and loading an arbitrary PKCS#11 driver library is effectively
* native code execution, so PKCS#11 libraries are additionally restricted to an allowlist of
* detected / configured driver paths.
*/
@Service
@Slf4j
public class HardwareKeyStoreService {
public static final String SOURCE_WINDOWS_STORE = "WINDOWS_STORE";
public static final String SOURCE_PKCS11 = "PKCS11";
private static final String WINDOWS_KEYSTORE_TYPE = "Windows-MY";
private static final String MSCAPI_PROVIDER = "SunMSCAPI";
private static final String PKCS11_BASE_PROVIDER = "SunPKCS11";
/** Extra PKCS#11 driver libraries, absolute paths, comma/`File.pathSeparator` separated. */
private static final String PKCS11_LIBRARIES_ENV = "STIRLING_PKCS11_LIBRARIES";
/** Same as {@link #PKCS11_LIBRARIES_ENV} but as a JVM system property. */
private static final String PKCS11_LIBRARIES_PROP = "stirling.pkcs11.libraries";
private final String machineType;
public HardwareKeyStoreService(
@Autowired(required = false) @Qualifier("machineType") String machineType) {
this.machineType = machineType;
}
// ---------------------------------------------------------------------
// Gating
// ---------------------------------------------------------------------
/**
* True when running as the desktop bundle (local sidecar in the user's session). The Tauri
* bundle sets {@code STIRLING_PDF_TAURI_MODE=true} (with {@code BROWSER_OPEN=false}, so
* machineType is {@code Server-jar} there); the bare-jar desktop launcher instead yields a
* {@code Client-*} machineType. Accept either.
*/
public boolean isDesktop() {
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
return true;
}
return machineType != null && machineType.startsWith("Client-");
}
public boolean isWindows() {
return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win");
}
private boolean windowsStoreSupported() {
return isWindows() && Security.getProvider(MSCAPI_PROVIDER) != null;
}
private boolean pkcs11Supported() {
return Security.getProvider(PKCS11_BASE_PROVIDER) != null;
}
/** Reject anything that is not the desktop bundle reached over loopback. */
public void assertLocalDesktop(HttpServletRequest request) {
if (!isDesktop()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.hardwareSigningDesktopOnly",
"Hardware-backed signing is only available in the Stirling PDF desktop app");
}
if (request != null && !isLocalRequest(request.getRemoteAddr())) {
throw ExceptionUtils.createIllegalArgumentException(
"error.hardwareSigningLocalOnly",
"Hardware-backed signing can only be used from this device");
}
}
/**
* True when the request originates from this machine. Loopback (incl. IPv4-mapped IPv6 like
* {@code ::ffff:127.0.0.1}) counts, as does any address bound to a local interface - so it
* works whether the desktop app reaches the sidecar over {@code localhost} or a LAN IP, while
* still rejecting other machines on the network.
*/
static boolean isLocalRequest(String remoteAddr) {
if (remoteAddr == null || remoteAddr.isBlank()) {
return false;
}
try {
InetAddress addr = InetAddress.getByName(remoteAddr);
if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()) {
return true;
}
return NetworkInterface.networkInterfaces()
.anyMatch(nif -> nif.inetAddresses().anyMatch(local -> local.equals(addr)));
} catch (Exception e) {
return false;
}
}
// ---------------------------------------------------------------------
// Capabilities
// ---------------------------------------------------------------------
public HardwareSigningCapabilities capabilities() {
boolean desktop = isDesktop();
if (!desktop) {
return new HardwareSigningCapabilities(false, "", false, false, List.of());
}
return new HardwareSigningCapabilities(
true,
System.getProperty("os.name", ""),
windowsStoreSupported(),
pkcs11Supported(),
detectPkcs11Libraries());
}
/**
* Known driver install locations plus any paths configured via {@code
* STIRLING_PKCS11_LIBRARIES}.
*/
public List<Pkcs11LibraryInfo> detectPkcs11Libraries() {
Map<String, List<String>> candidates = new LinkedHashMap<>();
String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
if (os.contains("win")) {
candidates.put(
"OpenSC",
List.of(
"C:\\Program Files\\OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll"));
candidates.put(
"YubiKey (ykcs11)",
List.of("C:\\Program Files\\Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll"));
candidates.put("SafeNet eToken", List.of("C:\\Windows\\System32\\eTPKCS11.dll"));
candidates.put(
"Thales/Gemalto IDPrime", List.of("C:\\Windows\\System32\\IDPrimePKCS11.dll"));
candidates.put(
"SoftHSM2",
List.of(
"C:\\Program Files\\SoftHSM2\\lib\\softhsm2-x64.dll",
"C:\\SoftHSM2\\lib\\softhsm2-x64.dll"));
} else if (os.contains("mac")) {
candidates.put(
"OpenSC",
List.of(
"/Library/OpenSC/lib/opensc-pkcs11.so",
"/usr/local/lib/opensc-pkcs11.so"));
candidates.put(
"YubiKey (ykcs11)",
List.of("/usr/local/lib/libykcs11.dylib", "/opt/homebrew/lib/libykcs11.dylib"));
candidates.put(
"SoftHSM2",
List.of(
"/usr/local/lib/softhsm/libsofthsm2.so",
"/opt/homebrew/lib/softhsm/libsofthsm2.so"));
} else {
candidates.put(
"OpenSC",
List.of(
"/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so",
"/usr/lib/opensc-pkcs11.so",
"/usr/lib64/opensc-pkcs11.so"));
candidates.put(
"YubiKey (ykcs11)",
List.of(
"/usr/lib/x86_64-linux-gnu/libykcs11.so",
"/usr/local/lib/libykcs11.so"));
candidates.put(
"SoftHSM2",
List.of(
"/usr/lib/softhsm/libsofthsm2.so",
"/usr/lib64/softhsm/libsofthsm2.so",
"/usr/local/lib/softhsm/libsofthsm2.so"));
}
List<Pkcs11LibraryInfo> result = new ArrayList<>();
candidates.forEach(
(name, paths) ->
paths.stream()
.filter(p -> Files.exists(Path.of(p)))
.findFirst()
.ifPresent(p -> result.add(new Pkcs11LibraryInfo(name, p))));
for (String configured : configuredLibraries()) {
if (Files.exists(Path.of(configured))
&& result.stream().noneMatch(l -> sameFile(l.path(), configured))) {
result.add(new Pkcs11LibraryInfo(fileName(configured), configured));
}
}
return result;
}
private static List<String> configuredLibraries() {
String env = System.getenv(PKCS11_LIBRARIES_ENV);
String prop = System.getProperty(PKCS11_LIBRARIES_PROP);
StringBuilder combined = new StringBuilder();
if (env != null && !env.isBlank()) {
combined.append(env);
}
if (prop != null && !prop.isBlank()) {
if (combined.length() > 0) {
combined.append(java.io.File.pathSeparator);
}
combined.append(prop);
}
if (combined.length() == 0) {
return List.of();
}
return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
// ---------------------------------------------------------------------
// Windows certificate store
// ---------------------------------------------------------------------
public KeyStore loadWindowsKeyStore() throws Exception {
if (!windowsStoreSupported()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.windowsStoreUnavailable",
"The Windows certificate store is not available on this platform");
}
KeyStore ks = KeyStore.getInstance(WINDOWS_KEYSTORE_TYPE, MSCAPI_PROVIDER);
ks.load(null, null);
return ks;
}
public Provider windowsProvider() {
return Security.getProvider(MSCAPI_PROVIDER);
}
public List<HardwareCertificateInfo> listWindowsCertificates() throws Exception {
return listSigningCertificates(loadWindowsKeyStore(), SOURCE_WINDOWS_STORE);
}
// ---------------------------------------------------------------------
// PKCS#11 tokens
// ---------------------------------------------------------------------
/**
* A configured, logged-in PKCS#11 keystore plus the provider that must service signing. Closing
* logs the session out so the PIN-authenticated session does not outlive the request. The
* provider stays cached (logout is C_Logout, not C_Finalize) so the next call reuses the same
* C_Initialize. Single-user desktop model - logout is best-effort.
*/
public record Pkcs11Session(KeyStore keyStore, Provider provider) implements AutoCloseable {
@Override
public void close() {
if (provider instanceof java.security.AuthProvider authProvider) {
try {
authProvider.logout();
} catch (Exception e) {
// Not logged in / already logged out - nothing to clear.
}
}
}
}
// One SunPKCS11 provider per driver+slot, reused across enumerate + sign. A PKCS#11 module
// typically allows C_Initialize only once per process, so configuring a fresh provider on every
// call races with the previous (not-yet-GC'd) one - the cause of "first sign fails, second
// works". Reusing the provider keeps a single C_Initialize alive for the session.
private final java.util.concurrent.ConcurrentHashMap<String, Provider> pkcs11Providers =
new java.util.concurrent.ConcurrentHashMap<>();
public Pkcs11Session openPkcs11(String libraryPath, Integer slot, char[] pin) throws Exception {
validateLibraryAllowed(libraryPath);
if (!pkcs11Supported()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11Unavailable", "PKCS#11 support is not available in this runtime");
}
String cacheKey = libraryPath + "|" + slot;
Provider provider =
pkcs11Providers.computeIfAbsent(
cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
try {
KeyStore ks = KeyStore.getInstance("PKCS11", provider);
ks.load(null, pin);
return new Pkcs11Session(ks, provider);
} catch (Exception e) {
// A wrong PIN must not be retried: a second C_Login would burn the token's retry
// counter twice per attempt and can lock the token. Only rebuild on provider/init
// failures (e.g. token removed/re-inserted leaving a stale provider).
if (isAuthFailure(e)) {
throw e;
}
pkcs11Providers.remove(cacheKey, provider);
Provider fresh =
pkcs11Providers.computeIfAbsent(
cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
KeyStore ks = KeyStore.getInstance("PKCS11", fresh);
ks.load(null, pin);
return new Pkcs11Session(ks, fresh);
}
}
/** True when the failure is a bad/locked PIN rather than a provider/init/device problem. */
private static boolean isAuthFailure(Throwable t) {
while (t != null) {
if (t instanceof javax.security.auth.login.FailedLoginException) {
return true;
}
String msg = t.getMessage();
if (msg != null && msg.toUpperCase(Locale.ROOT).contains("CKR_PIN")) {
return true; // CKR_PIN_INCORRECT / CKR_PIN_LOCKED / CKR_PIN_INVALID / ...
}
t = t.getCause();
}
return false;
}
private Provider buildPkcs11Provider(String libraryPath, Integer slot) {
StringBuilder config = new StringBuilder();
config.append("--name=").append(providerName(libraryPath)).append('\n');
config.append("library=").append(libraryPath).append('\n');
if (slot != null) {
config.append("slot=").append(slot).append('\n');
}
try {
return Security.getProvider(PKCS11_BASE_PROVIDER).configure(config.toString());
} catch (Exception e) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11ConfigFailed",
"Failed to initialise the PKCS#11 driver: {0}",
e.getMessage());
}
}
public List<HardwareCertificateInfo> listPkcs11Certificates(
String libraryPath, Integer slot, char[] pin) throws Exception {
try (Pkcs11Session session = openPkcs11(libraryPath, slot, pin)) {
return listSigningCertificates(session.keyStore(), SOURCE_PKCS11);
}
}
/**
* Reject driver paths that are not detected on disk / configured - blocks arbitrary DLL loads.
*/
public void validateLibraryAllowed(String libraryPath) {
if (libraryPath == null || libraryPath.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11LibraryRequired", "A PKCS#11 driver library path is required");
}
Set<String> allowed =
detectPkcs11Libraries().stream()
.map(Pkcs11LibraryInfo::path)
.collect(Collectors.toSet());
boolean ok = allowed.stream().anyMatch(p -> sameFile(p, libraryPath));
if (!ok) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11LibraryNotAllowed",
"PKCS#11 driver is not in the allowed list. Add it via the"
+ " STIRLING_PKCS11_LIBRARIES setting: {0}",
libraryPath);
}
}
// ---------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------
private List<HardwareCertificateInfo> listSigningCertificates(KeyStore ks, String source)
throws Exception {
List<HardwareCertificateInfo> certs = new ArrayList<>();
Enumeration<String> aliases = ks.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
if (!ks.isKeyEntry(alias)) {
continue; // only entries we can sign with
}
Certificate cert = ks.getCertificate(alias);
if (cert instanceof X509Certificate x509) {
certs.add(toInfo(alias, x509, source));
}
}
return certs;
}
private static HardwareCertificateInfo toInfo(
String alias, X509Certificate cert, String source) {
java.util.Date now = new java.util.Date();
return new HardwareCertificateInfo(
alias,
source,
cert.getSubjectX500Principal().getName(),
cert.getIssuerX500Principal().getName(),
commonName(cert.getSubjectX500Principal()),
commonName(cert.getIssuerX500Principal()),
cert.getSerialNumber().toString(16),
cert.getPublicKey().getAlgorithm(),
cert.getNotBefore().toInstant().toString(),
cert.getNotAfter().toInstant().toString(),
now.after(cert.getNotAfter()),
now.before(cert.getNotBefore()));
}
private static String commonName(X500Principal principal) {
try {
X500Name x500Name = new X500Name(principal.getName());
RDN[] rdns = x500Name.getRDNs(BCStyle.CN);
if (rdns.length > 0) {
return IETFUtils.valueToString(rdns[0].getFirst().getValue());
}
} catch (Exception e) {
log.debug("Could not parse common name from {}", principal.getName());
}
return principal.getName();
}
private static String providerName(String libraryPath) {
String base = fileName(libraryPath).replaceAll("[^a-zA-Z0-9]", "");
if (base.isEmpty()) {
base = "token";
}
return "StirlingHW" + base;
}
private static String fileName(String path) {
try {
return Path.of(path).getFileName().toString();
} catch (Exception e) {
return path;
}
}
private static boolean sameFile(String a, String b) {
if (a == null || b == null) {
return false;
}
try {
Path pa = Path.of(a);
Path pb = Path.of(b);
if (Files.exists(pa) && Files.exists(pb)) {
return Files.isSameFile(pa, pb);
}
return pa.toAbsolutePath().normalize().equals(pb.toAbsolutePath().normalize());
} catch (Exception e) {
return a.equalsIgnoreCase(b);
}
}
}
@@ -62,8 +62,6 @@ security:
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
# Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
@@ -141,10 +139,10 @@ telegram:
botUsername: "" # Telegram bot username (without @)
pipelineInboxFolder: telegram # Name of the pipeline inbox folder for Telegram uploads
customFolderSuffix: true # set to 'true' to allow users to specify custom target folders via UserID
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users.
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels.
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs. NOTE: only takes effect when allowUserIDs is non-empty; with an empty list every user is still allowed even when this is 'true'
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users (the enableAllowUserIDs toggle has no effect until this list is populated).
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs. NOTE: only takes effect when allowChannelIDs is non-empty; with an empty list every channel is still allowed even when this is 'true'
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels (the enableAllowChannelIDs toggle has no effect until this list is populated).
processingTimeoutSeconds: 180 # Maximum time in seconds to wait for processing a Telegram request
pollingIntervalMillis: 2000 # Interval in milliseconds between polling for new messages
feedback:
@@ -165,10 +163,14 @@ legal:
accessibilityStatement: "" # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
cookiePolicy: "" # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
loginAgreement:
enabled: false # set to 'true' to show a login agreement/disclaimer popup after login (and on app launch when login is disabled). Per-language text is read from customFiles/disclaimer/<locale>.md (e.g. en-GB.md, fr-FR.md)
showInAnonymousMode: true # when login is disabled, set to 'false' to suppress the agreement in anonymous (no-login) mode
fallbackText: "" # optional markdown used for any language that has no customFiles/disclaimer/<locale>.md file (also settable via the LEGAL_LOGINAGREEMENT_FALLBACKTEXT env var for single-language headless installs)
system:
defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US
googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow
googlevisibility: false # 'true' serves an allow-all /robots.txt; 'false' serves a disallow-all /robots.txt to keep the instance out of search engines
enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes)
showUpdate: true # see when a new update is available
showUpdateOnlyAdmin: true # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
@@ -182,7 +184,7 @@ system:
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down.
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
@@ -193,7 +195,7 @@ system:
stretchToFit: false # Whether to stretch images to fill the entire page (may distort aspect ratio). If false, images are centered with preserved aspect ratio. Only applies when convertToPdf is true.
serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling-PDF # Organization name for generated certificates
organizationName: Stirling PDF Inc # Organization name for generated certificates
validity: 365 # Certificate validity in days
regenerateOnStartup: false # Generate new certificate on each startup
html:
@@ -300,7 +302,7 @@ autoPipeline:
allowedExtensions: [] # Optional extension allow-list (case-insensitive, without the leading dot). Empty list = accept all extensions. Example: ["pdf", "tiff"]
ui:
appNameNavbar: "" # name displayed on the navigation bar
appNameNavbar: "" # custom app/brand name. NOTE: no longer shown in the navbar (the navbar renders the logo). It IS used as the browser tab title and as the TOTP/2FA issuer label in authenticator apps. Empty falls back to "Stirling PDF"
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To restrict to specific languages, use a whitelist like ["de_DE", "pl_PL", "sv_SE"]. Empty list or not restricting any languages will enable all available languages.
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
@@ -179,6 +179,13 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.code.gson:gson",
"moduleUrl": "https://github.com/google/gson",
"moduleVersion": "2.14.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.errorprone:error_prone_annotations",
"moduleUrl": "https://errorprone.info/error_prone_annotations",
@@ -186,6 +193,13 @@
"moduleLicense": "Apache 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.errorprone:error_prone_annotations",
"moduleUrl": "https://errorprone.info/error_prone_annotations",
"moduleVersion": "2.48.0",
"moduleLicense": "Apache 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.guava:failureaccess",
"moduleUrl": "https://github.com/google/guava/",
@@ -629,13 +643,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-io:commons-io",
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
"moduleVersion": "2.21.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-io:commons-io",
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
@@ -9,6 +9,7 @@ import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import org.apache.pdfbox.Loader;
@@ -302,6 +303,11 @@ class RearrangePagesPDFControllerTest {
assertNotNull(response);
// 2 pages * 3 duplicates = 6 final pages
assertEquals(6, realDoc.getNumberOfPages());
// Each duplicate must be a distinct page node in the saved output; a shared
// node under multiple /Kids is an invalid tree readers reject as cyclic.
List<Object> savedPages = reloadAndSnapshot(response);
assertEquals(6, savedPages.size());
assertEquals(6, new HashSet<>(savedPages).size());
}
}
@@ -323,4 +329,29 @@ class RearrangePagesPDFControllerTest {
assertEquals(4, realDoc.getNumberOfPages());
}
}
@Test
void testRearrangePages_SideStitchBooklet_RepeatedPaddingPagesAreDistinctNodes()
throws IOException {
MockMultipartFile file = createMockPdf();
RearrangePagesRequest request = new RearrangePagesRequest();
request.setFileInput(file);
request.setPageNumbers("");
request.setCustomMode("SIDE_STITCH_BOOKLET_SORT");
// 6 pages is not a multiple of 4, so booklet padding repeats the last page index
// several times; each repeat must be a distinct page node, not one shared node.
try (PDDocument realDoc = buildRealPdf(6)) {
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
ResponseEntity<Resource> response = controller.rearrangePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
assertEquals(8, realDoc.getNumberOfPages());
List<Object> savedPages = reloadAndSnapshot(response);
assertEquals(8, savedPages.size());
assertEquals(8, new HashSet<>(savedPages).size());
}
}
}
@@ -0,0 +1,63 @@
package stirling.software.SPDF.controller.api.misc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.SPDF.controller.api.misc.LoginDisclaimerController.LoginDisclaimerResponse;
import stirling.software.common.service.LoginAgreementService;
@ExtendWith(MockitoExtension.class)
class LoginDisclaimerControllerTest {
@Mock LoginAgreementService loginAgreementService;
@InjectMocks LoginDisclaimerController controller;
@Test
void disabledReturnsEmptyContent() {
when(loginAgreementService.isEnabled()).thenReturn(false);
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(true);
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("en-GB");
assertFalse(resp.enabled());
assertEquals("", resp.content());
assertTrue(resp.showInAnonymousMode());
assertEquals("markdown", resp.format());
}
@Test
void enabledWithContentReturnsIt() {
when(loginAgreementService.isEnabled()).thenReturn(true);
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(false);
when(loginAgreementService.resolveContent("fr-FR")).thenReturn("# Avis");
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("fr-FR");
assertTrue(resp.enabled());
assertEquals("# Avis", resp.content());
assertFalse(resp.showInAnonymousMode());
}
@Test
void enabledButBlankContentReportsDisabled() {
// No file for any candidate locale and no fallbackText -> report disabled so clients
// don't render an empty agreement.
when(loginAgreementService.isEnabled()).thenReturn(true);
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(true);
when(loginAgreementService.resolveContent("ja-JP")).thenReturn(" ");
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("ja-JP");
assertFalse(resp.enabled());
assertEquals("", resp.content());
}
}
@@ -30,7 +30,10 @@ import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -51,6 +54,8 @@ class CertSignControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private HardwareKeyStoreService hardwareKeyStoreService;
@Mock private HttpServletRequest httpRequest;
@InjectMocks private CertSignController certSignController;
@@ -169,7 +174,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -195,7 +201,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -221,7 +228,7 @@ class CertSignControllerTest {
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> certSignController.signPDFWithCert(request));
() -> certSignController.signPDFWithCert(request, httpRequest));
assertTrue(exception.getMessage().contains("PKCS12 keystore"));
}
@@ -247,7 +254,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -278,7 +286,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -309,7 +318,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -340,7 +350,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -371,7 +382,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -89,9 +89,13 @@ class CertificateValidationServiceMoreTest {
}
private static ApplicationProperties defaultProps() {
// Real POJO defaults: trust all off, revocation "none".
// Test baseline: every trust source explicitly off so each test enables only what it
// exercises (the shipped POJO defaults now enable system + Mozilla trust).
ApplicationProperties props = new ApplicationProperties();
props.getSecurity().getValidation().getTrust().setServerAsAnchor(false);
var trust = props.getSecurity().getValidation().getTrust();
trust.setServerAsAnchor(false);
trust.setUseSystemTrust(false);
trust.setUseMozillaBundle(false);
return props;
}
@@ -0,0 +1,146 @@
package stirling.software.SPDF.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
/** Unit tests for the gating / allowlist logic that protects the hardware signing paths. */
class HardwareKeyStoreServiceTest {
private static final String PKCS11_PROP = "stirling.pkcs11.libraries";
private HardwareKeyStoreService service(String machineType) {
return new HardwareKeyStoreService(machineType);
}
@Test
void isDesktop_trueOnlyForClientMachineTypes() {
assertTrue(service("Client-windows").isDesktop());
assertTrue(service("Client-mac").isDesktop());
assertTrue(service("Client-unix").isDesktop());
assertFalse(service("Server-jar").isDesktop());
assertFalse(service("Docker").isDesktop());
assertFalse(service(null).isDesktop());
}
@Test
void isDesktop_trueInTauriModeEvenWithoutClientMachineType() {
// The Tauri bundle sets STIRLING_PDF_TAURI_MODE=true while machineType stays Server-jar.
String previous = System.getProperty("STIRLING_PDF_TAURI_MODE");
try {
System.setProperty("STIRLING_PDF_TAURI_MODE", "true");
assertTrue(service("Server-jar").isDesktop());
assertTrue(service(null).isDesktop());
} finally {
if (previous == null) {
System.clearProperty("STIRLING_PDF_TAURI_MODE");
} else {
System.setProperty("STIRLING_PDF_TAURI_MODE", previous);
}
}
}
@Test
void capabilities_notDesktop_reportsUnavailable() {
HardwareSigningCapabilities caps = service("Server-jar").capabilities();
assertFalse(caps.desktop());
assertFalse(caps.windowsStoreSupported());
assertFalse(caps.pkcs11Supported());
assertTrue(caps.detectedLibraries().isEmpty());
}
@Test
void capabilities_desktop_reportsOsName() {
HardwareSigningCapabilities caps = service("Client-windows").capabilities();
assertTrue(caps.desktop());
assertFalse(caps.osName().isBlank());
}
@Test
void assertLocalDesktop_rejectsNonDesktop() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
assertThrows(
IllegalArgumentException.class,
() -> service("Server-jar").assertLocalDesktop(request));
}
@Test
void assertLocalDesktop_rejectsRemoteCallerEvenOnDesktop() {
HttpServletRequest request = mock(HttpServletRequest.class);
// 203.0.113.0/24 is TEST-NET-3 (RFC 5737) - never a real local interface address.
when(request.getRemoteAddr()).thenReturn("203.0.113.5");
assertThrows(
IllegalArgumentException.class,
() -> service("Client-windows").assertLocalDesktop(request));
}
@Test
void assertLocalDesktop_allowsLoopbackOnDesktop() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
assertDoesNotThrow(() -> service("Client-windows").assertLocalDesktop(request));
// No servlet context (e.g. internal call) is also allowed.
assertDoesNotThrow(() -> service("Client-windows").assertLocalDesktop(null));
}
@Test
void isLocalRequest_acceptsLoopbackForms_rejectsRemote() {
assertTrue(HardwareKeyStoreService.isLocalRequest("127.0.0.1"));
assertTrue(HardwareKeyStoreService.isLocalRequest("::1"));
assertTrue(HardwareKeyStoreService.isLocalRequest("0:0:0:0:0:0:0:1"));
// IPv4-mapped IPv6 loopback - what Tomcat reports for the desktop webview.
assertTrue(HardwareKeyStoreService.isLocalRequest("::ffff:127.0.0.1"));
assertFalse(HardwareKeyStoreService.isLocalRequest("203.0.113.5"));
assertFalse(HardwareKeyStoreService.isLocalRequest(null));
}
@Test
void validateLibraryAllowed_blankPath_throws() {
assertThrows(
IllegalArgumentException.class,
() -> service("Client-windows").validateLibraryAllowed(" "));
}
@Test
void validateLibraryAllowed_unknownPath_throws() {
assertThrows(
IllegalArgumentException.class,
() ->
service("Client-windows")
.validateLibraryAllowed("/definitely/not/a/real/driver.so"));
}
@Test
void validateLibraryAllowed_configuredPath_isAllowed(@TempDir Path tempDir) throws Exception {
Path fakeDriver = Files.createFile(tempDir.resolve("fake-pkcs11.so"));
String previous = System.getProperty(PKCS11_PROP);
try {
System.setProperty(PKCS11_PROP, fakeDriver.toString());
HardwareKeyStoreService service = service("Client-windows");
assertDoesNotThrow(() -> service.validateLibraryAllowed(fakeDriver.toString()));
assertTrue(
service.detectPkcs11Libraries().stream()
.anyMatch(l -> l.path().equals(fakeDriver.toString())));
} finally {
if (previous == null) {
System.clearProperty(PKCS11_PROP);
} else {
System.setProperty(PKCS11_PROP, previous);
}
}
}
}
+4
View File
@@ -80,6 +80,10 @@ dependencies {
implementation "software.amazon.awssdk:s3:${awsSdkVersion}"
implementation "software.amazon.awssdk:url-connection-client:${awsSdkVersion}"
// @DataJpaTest slice (Boot 4 ships test slices as separate starters, like webmvc-test at the
// root) so policy.source repositories can be exercised against embedded H2.
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
// Testcontainers: real MinIO/LocalStack (S3) and Valkey for integration tests in CI without
// manually-started instances. Tests skip cleanly when Docker is unavailable.
testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}"
@@ -0,0 +1,20 @@
package stirling.software.proprietary.access.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import stirling.software.proprietary.access.service.DefaultTeamLeadLookup;
import stirling.software.proprietary.access.service.TeamLeadLookup;
/** Access-layer bean wiring. */
@Configuration
public class AccessConfig {
/** No-op {@link TeamLeadLookup} unless another bean is defined. */
@Bean
@ConditionalOnMissingBean(TeamLeadLookup.class)
TeamLeadLookup defaultTeamLeadLookup() {
return new DefaultTeamLeadLookup();
}
}
@@ -0,0 +1,101 @@
package stirling.software.proprietary.access.controller;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.access.model.AccessPermission;
import stirling.software.proprietary.access.model.PrincipalType;
import stirling.software.proprietary.access.model.ResourceGrant;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.access.service.ResourceAccessService;
import stirling.software.proprietary.security.model.User;
/** Admin endpoints to grant/revoke access to gated resources (the portal, integration configs). */
@RestController
@RequestMapping("/api/v1/admin/access")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Access Control", description = "Manage resource access grants (portal, integrations)")
public class ResourceGrantController {
private final ResourceAccessService accessService;
@GetMapping("/grants")
public ResponseEntity<?> list(
@RequestParam ResourceType resourceType,
@RequestParam(required = false, defaultValue = "") String resourceId) {
List<ResourceGrant> grants = accessService.listGrants(resourceType, resourceId);
return ResponseEntity.ok(grants.stream().map(this::toDto).toList());
}
@PostMapping("/grants")
public ResponseEntity<?> create(
@RequestBody GrantRequest request, @AuthenticationPrincipal User admin) {
if (request.resourceType() == null
|| request.principalType() == null
|| request.principalId() == null) {
return ResponseEntity.badRequest()
.body(
Map.of(
"error",
"resourceType, principalType and principalId are required"));
}
AccessPermission permission =
request.permission() == null ? AccessPermission.USE : request.permission();
// PORTAL is a singleton resource; its grants always target the whole type.
String resourceId =
request.resourceType() == ResourceType.PORTAL ? "" : request.resourceId();
ResourceGrant grant =
accessService.grant(
request.resourceType(),
resourceId,
request.principalType(),
request.principalId(),
permission,
admin);
return ResponseEntity.ok(toDto(grant));
}
@DeleteMapping("/grants/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
accessService.revoke(id);
return ResponseEntity.ok(Map.of("message", "Grant revoked"));
}
private Map<String, Object> toDto(ResourceGrant g) {
Map<String, Object> m = new HashMap<>();
m.put("id", g.getId());
m.put("resourceType", g.getResourceType());
m.put("resourceId", g.getResourceId());
m.put("principalType", g.getPrincipalType());
m.put("principalId", g.getPrincipalId());
m.put("permission", g.getPermission());
m.put("createdAt", g.getCreatedAt());
return m;
}
/** Request body for creating a grant. */
public record GrantRequest(
ResourceType resourceType,
String resourceId,
PrincipalType principalType,
Long principalId,
AccessPermission permission) {}
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.access.model;
/** Permission level a grant confers. MANAGE implies USE. */
public enum AccessPermission {
USE,
MANAGE
}
@@ -0,0 +1,14 @@
package stirling.software.proprietary.access.model;
/**
* Fallback policy applied when no explicit {@link ResourceGrant} matches. Admins (org owners)
* always pass regardless of this policy.
*/
public enum DefaultAccessPolicy {
// Every authenticated user in the deployment (org) may use the resource.
ORG_ALL,
// Only org admins and team leaders. This is the default for the portal.
ADMINS_AND_TEAM_LEADS,
// Nobody but the owner, admins, and explicit grantees.
EXPLICIT_ONLY
}
@@ -0,0 +1,57 @@
package stirling.software.proprietary.access.model;
import jakarta.persistence.Column;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
/** Base for a resource owned by a user, a team, or the server, with grant-based access. */
@MappedSuperclass
@Getter
@Setter
public abstract class OwnedResource {
@Enumerated(EnumType.STRING)
@Column(name = "scope", nullable = false, length = 32)
private OwnerScope scope;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_user_id")
private User ownerUser;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_team_id")
private Team ownerTeam;
@Column(name = "enabled", nullable = false)
private boolean enabled = true;
// Server resource that users cannot override with their own of the same kind.
@Column(name = "locked", nullable = false)
private boolean locked = false;
// Who, besides owner/admin/grantees, may use this resource.
@Enumerated(EnumType.STRING)
@Column(name = "default_access", nullable = false, length = 32)
private DefaultAccessPolicy defaultAccess = DefaultAccessPolicy.EXPLICIT_ONLY;
/** Subclass primary key. */
public abstract Long getId();
public Long getOwnerUserId() {
return ownerUser != null ? ownerUser.getId() : null;
}
public Long getOwnerTeamId() {
return ownerTeam != null ? ownerTeam.getId() : null;
}
}
@@ -0,0 +1,8 @@
package stirling.software.proprietary.access.model;
/** Ownership scope of an {@link OwnedResource}: a single user, a team, or the whole server. */
public enum OwnerScope {
USER,
TEAM,
SERVER
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.access.model;
/** Who a {@link ResourceGrant} is granted to. Org-wide access is expressed via default policy. */
public enum PrincipalType {
USER,
TEAM
}
@@ -0,0 +1,86 @@
package stirling.software.proprietary.access.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
/** Grants a user or team access to a resource. Owner and admin access are implicit. */
@Entity
@Table(
name = "resource_grants",
uniqueConstraints =
@UniqueConstraint(
name = "uk_resource_grant",
columnNames = {
"resource_type",
"resource_id",
"principal_type",
"principal_id",
"permission"
}),
indexes = {
@Index(name = "idx_resource_grants_lookup", columnList = "resource_type,resource_id"),
@Index(
name = "idx_resource_grants_principal",
columnList = "principal_type,principal_id")
})
@NoArgsConstructor
@Getter
@Setter
public class ResourceGrant implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "resource_grant_id")
private Long id;
@Enumerated(EnumType.STRING)
@Column(name = "resource_type", nullable = false, length = 64)
private ResourceType resourceType;
// Empty string (never null) for a whole-type grant such as the portal.
@Column(name = "resource_id", nullable = false, length = 255)
private String resourceId = "";
@Enumerated(EnumType.STRING)
@Column(name = "principal_type", nullable = false, length = 32)
private PrincipalType principalType;
@Column(name = "principal_id", nullable = false)
private Long principalId;
@Enumerated(EnumType.STRING)
@Column(name = "permission", nullable = false, length = 32)
private AccessPermission permission;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "granted_by_user_id")
private User grantedBy;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
}
@@ -0,0 +1,10 @@
package stirling.software.proprietary.access.model;
/** Types of resources whose access can be gated by {@link ResourceGrant}. */
public enum ResourceType {
// The admin portal / processor (frontend/editor/src/portal). Singleton resource (empty
// resourceId).
PORTAL,
// A stored S3/MCP/API integration configuration.
INTEGRATION_CONFIG
}
@@ -0,0 +1,28 @@
package stirling.software.proprietary.access.repository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.access.model.PrincipalType;
import stirling.software.proprietary.access.model.ResourceGrant;
import stirling.software.proprietary.access.model.ResourceType;
@Repository
public interface ResourceGrantRepository extends JpaRepository<ResourceGrant, Long> {
List<ResourceGrant> findByResourceTypeAndResourceId(
ResourceType resourceType, String resourceId);
List<ResourceGrant> findByResourceTypeAndPrincipalTypeAndPrincipalId(
ResourceType resourceType, PrincipalType principalType, Long principalId);
void deleteByResourceTypeAndResourceId(ResourceType resourceType, String resourceId);
boolean existsByResourceTypeAndResourceIdAndPrincipalTypeAndPrincipalId(
ResourceType resourceType,
String resourceId,
PrincipalType principalType,
Long principalId);
}
@@ -0,0 +1,44 @@
package stirling.software.proprietary.access.security;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.access.service.ResourceAccessService;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
/** {@code @PreAuthorize} bean for portal-access checks. Active in self-hosted and saas. */
@Component("resourceAccess")
@RequiredArgsConstructor
public class ResourceAccessSecurity {
private final ResourceAccessService accessService;
private final UserService userService;
public boolean canUsePortal() {
User user = currentUser();
return user != null && accessService.canAccessPortal(user);
}
private User currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return null;
}
Object principal = auth.getPrincipal();
if (principal instanceof User user) {
return user;
}
if (principal instanceof UserDetails userDetails) {
return userService.findByUsername(userDetails.getUsername()).orElse(null);
}
if (principal instanceof String username && !"anonymousUser".equals(username)) {
return userService.findByUsername(username).orElse(null);
}
return null;
}
}
@@ -0,0 +1,17 @@
package stirling.software.proprietary.access.service;
import stirling.software.proprietary.security.model.User;
/** No-op {@link TeamLeadLookup}: always false. */
public class DefaultTeamLeadLookup implements TeamLeadLookup {
@Override
public boolean isAnyTeamLeader(User user) {
return false;
}
@Override
public boolean isLeaderOfTeam(User user, Long teamId) {
return false;
}
}
@@ -0,0 +1,116 @@
package stirling.software.proprietary.access.service;
import java.util.Set;
import java.util.function.BooleanSupplier;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.access.model.OwnedResource;
import stirling.software.proprietary.access.model.OwnerScope;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
/** Ownership and access checks for {@link OwnedResource}, backed by the resource-grant ACL. */
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OwnershipService {
private final ResourceAccessService accessService;
private final TeamLeadLookup teamLeadLookup;
private final TeamRepository teamRepository;
/** Whether the user may use the resource. */
public boolean canUse(ResourceType type, OwnedResource resource, User user) {
if (!resource.isEnabled()) {
return isAdmin(user) || isOwner(resource, user);
}
return accessService.canUseResource(
type,
String.valueOf(resource.getId()),
resource.getOwnerUserId(),
resource.getDefaultAccess(),
user);
}
/** Whether the user may manage the resource. */
public boolean canManage(ResourceType type, OwnedResource resource, User user) {
return accessService.canManageResource(
type, String.valueOf(resource.getId()), resource.getOwnerUserId(), user);
}
/**
* Authorizes the scope and assigns ownership; {@code lockedOverrideBlocks} guards USER scope.
*/
public void assignOwnership(
OwnedResource resource,
OwnerScope scope,
Long teamId,
User user,
BooleanSupplier lockedOverrideBlocks) {
resource.setScope(scope);
switch (scope) {
case USER -> {
if (lockedOverrideBlocks.getAsBoolean() && !isAdmin(user)) {
throw forbidden(
"This is locked to the server configuration by an administrator");
}
resource.setOwnerUser(user);
}
case SERVER -> {
if (!isAdmin(user)) {
throw forbidden("Only administrators can create server-owned resources");
}
}
case TEAM -> {
if (teamId == null) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "ownerTeamId is required");
}
Team team =
teamRepository
.findById(teamId)
.orElseThrow(() -> notFound("Team not found"));
if (!isAdmin(user) && !teamLeadLookup.isLeaderOfTeam(user, team.getId())) {
throw forbidden("Only admins or team leaders can create team-owned resources");
}
resource.setOwnerTeam(team);
}
}
}
/** Resource ids of the given type the user or their team holds a grant on. */
public Set<String> grantedResourceIds(ResourceType type, User user) {
return accessService.grantedResourceIds(type, user);
}
public boolean isAdmin(User user) {
return user.getAuthorities().stream()
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority()));
}
public boolean isOwner(OwnedResource resource, User user) {
if (resource.getOwnerUserId() != null && resource.getOwnerUserId().equals(user.getId())) {
return true;
}
// Team-owned: the lead of the owning team owns it.
return resource.getOwnerTeamId() != null
&& teamLeadLookup.isLeaderOfTeam(user, resource.getOwnerTeamId());
}
private ResponseStatusException forbidden(String message) {
return new ResponseStatusException(HttpStatus.FORBIDDEN, message);
}
private ResponseStatusException notFound(String message) {
return new ResponseStatusException(HttpStatus.NOT_FOUND, message);
}
}
@@ -0,0 +1,188 @@
package stirling.software.proprietary.access.service;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.access.model.AccessPermission;
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
import stirling.software.proprietary.access.model.PrincipalType;
import stirling.software.proprietary.access.model.ResourceGrant;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.access.repository.ResourceGrantRepository;
import stirling.software.proprietary.security.model.User;
/** Resolves access to gated resources: owner, then admin, then grant, then default policy. */
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class ResourceAccessService {
private final ResourceGrantRepository grantRepository;
private final TeamLeadLookup teamLeadLookup;
@Value("${security.portal.defaultAccess:ADMINS_AND_TEAM_LEADS}")
private DefaultAccessPolicy portalDefaultPolicy;
// ---- public checks ----
/** Whether the user may use the portal / processor. */
public boolean canAccessPortal(User user) {
return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user);
}
/** Whether the user may use a resource, falling back to its default policy. */
public boolean canUseResource(
ResourceType type,
String resourceId,
Long ownerUserId,
DefaultAccessPolicy defaultPolicy,
User user) {
if (user == null) {
return false;
}
if (isOwner(ownerUserId, user) || isAdmin(user)) {
return true;
}
if (hasGrant(type, normalize(resourceId), user, AccessPermission.USE)) {
return true;
}
return matchesDefault(defaultPolicy, user);
}
/** Whether the user may manage (edit/delete/share) a resource. No default-policy fallback. */
public boolean canManageResource(
ResourceType type, String resourceId, Long ownerUserId, User user) {
if (user == null) {
return false;
}
if (isOwner(ownerUserId, user) || isAdmin(user)) {
return true;
}
return hasGrant(type, normalize(resourceId), user, AccessPermission.MANAGE);
}
// ---- grant management ----
@Transactional
public ResourceGrant grant(
ResourceType type,
String resourceId,
PrincipalType principalType,
Long principalId,
AccessPermission permission,
User grantedBy) {
String rid = normalize(resourceId);
ResourceGrant grant =
grantRepository.findByResourceTypeAndResourceId(type, rid).stream()
.filter(
g ->
g.getPrincipalType() == principalType
&& g.getPrincipalId().equals(principalId))
.findFirst()
.orElseGet(ResourceGrant::new);
grant.setResourceType(type);
grant.setResourceId(rid);
grant.setPrincipalType(principalType);
grant.setPrincipalId(principalId);
grant.setPermission(permission);
if (grantedBy != null) {
grant.setGrantedBy(grantedBy);
}
return grantRepository.save(grant);
}
@Transactional
public void revoke(Long grantId) {
grantRepository.deleteById(grantId);
}
public List<ResourceGrant> listGrants(ResourceType type, String resourceId) {
return grantRepository.findByResourceTypeAndResourceId(type, normalize(resourceId));
}
/** Resource ids of the given type that this user (or their team) holds any grant on. */
public Set<String> grantedResourceIds(ResourceType type, User user) {
if (user == null) {
return Set.of();
}
Set<String> ids = new HashSet<>();
for (ResourceGrant g :
grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId(
type, PrincipalType.USER, user.getId())) {
ids.add(g.getResourceId());
}
if (user.getTeam() != null) {
for (ResourceGrant g :
grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId(
type, PrincipalType.TEAM, user.getTeam().getId())) {
ids.add(g.getResourceId());
}
}
return ids;
}
// ---- internals ----
private boolean hasGrant(
ResourceType type, String resourceId, User user, AccessPermission required) {
Long teamId = user.getTeam() != null ? user.getTeam().getId() : null;
for (ResourceGrant g : grantRepository.findByResourceTypeAndResourceId(type, resourceId)) {
if (!permissionSatisfies(g.getPermission(), required)) {
continue;
}
if (g.getPrincipalType() == PrincipalType.USER
&& g.getPrincipalId().equals(user.getId())) {
return true;
}
if (g.getPrincipalType() == PrincipalType.TEAM
&& teamId != null
&& g.getPrincipalId().equals(teamId)) {
return true;
}
}
return false;
}
// MANAGE implies USE.
private boolean permissionSatisfies(AccessPermission held, AccessPermission required) {
if (required == AccessPermission.USE) {
return held == AccessPermission.USE || held == AccessPermission.MANAGE;
}
return held == AccessPermission.MANAGE;
}
private boolean matchesDefault(DefaultAccessPolicy policy, User user) {
if (policy == null) {
return false;
}
return switch (policy) {
case ORG_ALL -> true;
// Admins already pass above; only team leads here.
case ADMINS_AND_TEAM_LEADS -> teamLeadLookup.isAnyTeamLeader(user);
case EXPLICIT_ONLY -> false;
};
}
private boolean isOwner(Long ownerUserId, User user) {
return ownerUserId != null && ownerUserId.equals(user.getId());
}
private boolean isAdmin(User user) {
return user.getAuthorities().stream()
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority()));
}
private String normalize(String resourceId) {
return resourceId == null ? "" : resourceId;
}
}
@@ -0,0 +1,151 @@
package stirling.software.proprietary.access.service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Component;
/** Masks, merges and sanitizes secret values in a config map, recursing into nested maps/lists. */
@Component
public class SecretMasker {
public static final String MASK = "********";
// Cap recursion so a pathologically nested payload cannot overflow the stack.
private static final int MAX_DEPTH = 32;
private static final Set<String> SENSITIVE_HINTS =
Set.of(
"secret",
"password",
"token",
"apikey",
"accesskey",
"credential",
"privatekey");
/** Replace sensitive values with the mask (recursively) for safe display. */
public Map<String, Object> mask(Map<String, Object> config) {
return mask(config, 0);
}
/** Drop sensitive blank/masked values from an incoming create payload. */
public Map<String, Object> sanitize(Map<String, Object> config) {
return sanitize(config, 0);
}
/**
* Merge an update over the stored map, keeping stored secrets where the incoming is redacted.
*/
public Map<String, Object> merge(Map<String, Object> stored, Map<String, Object> incoming) {
return merge(stored, incoming, 0);
}
private Map<String, Object> mask(Map<String, Object> config, int depth) {
Map<String, Object> out = new LinkedHashMap<>();
for (Map.Entry<String, Object> e : config.entrySet()) {
out.put(e.getKey(), maskValue(e.getKey(), e.getValue(), depth));
}
return out;
}
private Map<String, Object> sanitize(Map<String, Object> config, int depth) {
if (config == null) {
return new LinkedHashMap<>();
}
Map<String, Object> out = new LinkedHashMap<>();
for (Map.Entry<String, Object> e : config.entrySet()) {
if (isSensitive(e.getKey()) && isRedacted(e.getValue(), depth)) {
continue;
}
out.put(
e.getKey(),
e.getValue() instanceof Map<?, ?> m && depth < MAX_DEPTH
? sanitize(castMap(m), depth + 1)
: e.getValue());
}
return out;
}
private Map<String, Object> merge(
Map<String, Object> stored, Map<String, Object> incoming, int depth) {
Map<String, Object> out = new LinkedHashMap<>(stored);
for (Map.Entry<String, Object> e : incoming.entrySet()) {
String key = e.getKey();
Object value = e.getValue();
if (isSensitive(key)) {
if (!isRedacted(value, depth)) {
out.put(key, value); // a real new secret replaces the stored one
}
continue; // redacted (blank / mask) -> keep stored
}
if (depth < MAX_DEPTH
&& out.get(key) instanceof Map<?, ?> s
&& value instanceof Map<?, ?> i) {
out.put(key, merge(castMap(s), castMap(i), depth + 1));
} else {
out.put(key, value);
}
}
return out;
}
// A sensitive key masks its whole value; recurse into non-sensitive containers.
private Object maskValue(String key, Object value, int depth) {
if (isSensitive(key)) {
if (value == null || (value instanceof String s && s.isBlank())) {
return value;
}
return MASK;
}
if (depth >= MAX_DEPTH) {
// Too deep to descend; mask containers rather than risk leaking an unmasked secret.
return value instanceof Map<?, ?> || value instanceof List<?> ? MASK : value;
}
if (value instanceof Map<?, ?> m) {
return mask(castMap(m), depth + 1);
}
if (value instanceof List<?> list) {
List<Object> out = new ArrayList<>();
for (Object item : list) {
out.add(item instanceof Map<?, ?> m ? mask(castMap(m), depth + 1) : item);
}
return out;
}
return value;
}
private boolean isSensitive(String key) {
String lower = key.toLowerCase(Locale.ROOT);
return SENSITIVE_HINTS.stream().anyMatch(lower::contains);
}
/** Blank, the mask placeholder, or any structure that still contains the mask. */
private boolean isRedacted(Object value, int depth) {
if (value == null) {
return true;
}
if (value instanceof String s) {
return s.isBlank() || MASK.equals(s);
}
if (depth >= MAX_DEPTH) {
return false;
}
if (value instanceof Map<?, ?> m) {
return m.values().stream().anyMatch(v -> isRedacted(v, depth + 1));
}
if (value instanceof List<?> list) {
return list.stream().anyMatch(v -> isRedacted(v, depth + 1));
}
return false;
}
@SuppressWarnings("unchecked")
private Map<String, Object> castMap(Map<?, ?> map) {
return (Map<String, Object>) map;
}
}
@@ -0,0 +1,13 @@
package stirling.software.proprietary.access.service;
import stirling.software.proprietary.security.model.User;
/** Resolves whether a user leads a team. */
public interface TeamLeadLookup {
/** Whether the user leads at least one team. */
boolean isAnyTeamLeader(User user);
/** Whether the user leads the given team. */
boolean isLeaderOfTeam(User user, Long teamId);
}
@@ -0,0 +1,363 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalDateTime;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
* A").
*
* <p>Calls:
*
* <ul>
* <li>{@link #register} relays the admin's short-lived Supabase JWT to {@code POST
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
* <li>{@link #fetchEntitlement} authenticates with the stored device credential against {@code
* GET /api/v1/instance/entitlement}; what the local gate consults.
* <li>{@link #reportUsage} daily usage sync ({@code POST /api/v1/instance/sync}); reports
* cumulative units and returns the refreshed entitlement.
* <li>{@link #revokeSelf} self-revokes the credential on local unlink ({@code POST
* /api/v1/instance/revoke-self}).
* </ul>
*
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
* {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkClient {
static final String HEADER_DEVICE_ID = "X-Device-Id";
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
private final AccountLinkProperties properties;
private final ObjectMapper mapper;
private final HttpClient httpClient;
@Autowired
public AccountLinkClient(AccountLinkProperties properties, ObjectMapper mapper) {
this(
properties,
mapper,
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(properties.getRequestTimeoutSeconds()))
.build());
}
/** Package-private: lets tests inject a stub {@link HttpClient}. */
AccountLinkClient(
AccountLinkProperties properties, ObjectMapper mapper, HttpClient httpClient) {
this.properties = properties;
this.mapper = mapper;
this.httpClient = httpClient;
}
/** The device credential a successful {@link #register} returns. */
public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {}
/**
* A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can
* map auth failures (401/403) through rather than masking everything as a 502.
*/
public static class UpstreamException extends IOException {
private final int status;
public UpstreamException(int status, String body) {
super("SaaS account-link returned HTTP " + status + ": " + body);
this.status = status;
}
public int status() {
return status;
}
}
/**
* Authoritative deny (401/403) the device credential is revoked or invalid. Unlike a
* transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
* this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
*/
public static final class RevokedException extends RuntimeException {
private final int status;
public RevokedException(int status) {
super("SaaS entitlement denied (credential revoked/invalid): HTTP " + status);
this.status = status;
}
public int status() {
return status;
}
}
/**
* Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted
* credential.
*
* @throws IOException on transport failure or a non-2xx response (caller surfaces to the
* admin).
*/
public RegisterResult register(String supabaseJwt, String instanceName) throws IOException {
String body =
instanceName == null || instanceName.isBlank()
? "{}"
: "{\"name\":" + mapper.writeValueAsString(instanceName) + "}";
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/account-link/register"))
.header("Authorization", "Bearer " + supabaseJwt)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = send(request);
if (response.statusCode() / 100 != 2) {
throw new UpstreamException(response.statusCode(), response.body());
}
JsonNode root = mapper.readTree(response.body());
String deviceId = text(root, "deviceId");
String deviceSecret = text(root, "deviceSecret");
if (deviceId == null || deviceSecret == null) {
throw new IOException("SaaS register response missing deviceId/deviceSecret");
}
Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null;
return new RegisterResult(deviceId, deviceSecret, teamId);
}
/**
* Revokes this instance's own credential on the SaaS side, authenticated by that credential.
* Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
* unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
*/
public boolean revokeSelf(String deviceId, String deviceSecret) {
try {
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/instance/revoke-self"))
.header(HEADER_DEVICE_ID, deviceId)
.header(HEADER_DEVICE_SECRET, deviceSecret)
.header("Accept", "application/json")
.timeout(timeout())
.POST(HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = send(request);
if (response.statusCode() / 100 != 2) {
log.debug("Self-revoke returned HTTP {}", response.statusCode());
return false;
}
return true;
} catch (Exception e) {
log.debug("Self-revoke failed: {}", e.getMessage());
return false;
}
}
/**
* Fetches the current entitlement using the stored device credential. Three outcomes:
*
* <ul>
* <li>2xx the parsed snapshot.
* <li>401/403 {@link RevokedException} (authoritative deny revoked/invalid credential);
* the caller must BLOCK, not fail open.
* <li>transport failure, other non-2xx (e.g. 5xx), or a malformed body {@code null}
* ("unknown" the caller fails open).
* </ul>
*/
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
HttpResponse<String> response;
try {
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/instance/entitlement"))
.header(HEADER_DEVICE_ID, deviceId)
.header(HEADER_DEVICE_SECRET, deviceSecret)
.header("Accept", "application/json")
.timeout(timeout())
.GET()
.build();
response = send(request);
} catch (Exception e) {
// Transport failure (timeout / connection refused / interrupted) unknown, fail open.
log.debug("Entitlement fetch failed: {}", e.getMessage());
return null;
}
int status = response.statusCode();
if (status == 401 || status == 403) {
// Authoritative deny the SaaS side rejected the credential (revoked/invalid).
throw new RevokedException(status);
}
if (status / 100 != 2) {
// Server / transient error unknown, fail open (do NOT treat as a deny).
log.debug("Entitlement fetch returned HTTP {}", status);
return null;
}
try {
return parseEntitlement(response.body());
} catch (IOException e) {
log.debug("Entitlement parse failed: {}", e.getMessage());
return null;
}
}
/**
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
* returns the fresh entitlement in the same reply one round-trip both reports and refreshes.
* SaaS bills the delta against its last-seen cumulative, so resending the same totals is
* idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
* not advance its last-synced markers so the usage retries next sync.
*/
public InstanceEntitlement reportUsage(
String deviceId,
String deviceSecret,
long syncSeq,
LocalDateTime periodStart,
long apiUnits,
long aiUnits,
long automationUnits) {
HttpResponse<String> response;
try {
ObjectNode root = mapper.createObjectNode();
root.put("syncSeq", syncSeq);
// Explicit ISO-8601 string so it round-trips regardless of the mapper's time config.
root.put("periodStart", periodStart.toString());
ObjectNode units = root.putObject("cumulativeUnits");
units.put("api", apiUnits);
units.put("ai", aiUnits);
units.put("automation", automationUnits);
String body = mapper.writeValueAsString(root);
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/instance/sync"))
.header(HEADER_DEVICE_ID, deviceId)
.header(HEADER_DEVICE_SECRET, deviceSecret)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
response = send(request);
} catch (Exception e) {
log.debug("Usage sync failed: {}", e.getMessage());
return null;
}
int status = response.statusCode();
if (status == 401 || status == 403) {
throw new RevokedException(status);
}
if (status / 100 != 2) {
log.debug("Usage sync returned HTTP {}", status);
return null;
}
try {
return parseEntitlement(response.body());
} catch (IOException e) {
log.debug("Usage sync parse failed: {}", e.getMessage());
return null;
}
}
private InstanceEntitlement parseEntitlement(String body) throws IOException {
JsonNode root = mapper.readTree(body);
boolean subscribed = root.path("subscribed").asBoolean(false);
long freeRemaining = root.path("freeRemainingUnits").asLong(0);
long periodSpend = root.path("periodSpendUnits").asLong(0);
Long periodCap =
root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null;
EntitlementState state = mapState(root.path("state").asText(null));
return new InstanceEntitlement(
subscribed,
freeRemaining,
periodSpend,
periodCap,
state,
parseUnitCalcPolicy(root),
parseDateTime(root, "periodStart"),
parseDateTime(root, "periodEnd"));
}
/** Parses the nested unit-calc policy; null if absent or any knob is invalid (e.g. zero). */
private static UnitCalcPolicy parseUnitCalcPolicy(JsonNode root) {
if (!root.hasNonNull("unitCalcPolicy")) {
return null;
}
JsonNode node = root.get("unitCalcPolicy");
try {
return new UnitCalcPolicy(
node.path("docPagesPerUnit").asInt(),
node.path("docBytesPerUnit").asLong(),
node.path("minChargeUnits").asInt(),
node.path("fileUnitCap").asInt());
} catch (RuntimeException e) {
// Malformed policy degrade to "none" rather than fail the whole entitlement parse.
return null;
}
}
/** ISO date-time field → LocalDateTime; null if absent or unparseable. */
private static LocalDateTime parseDateTime(JsonNode root, String field) {
if (!root.hasNonNull(field)) {
return null;
}
try {
return LocalDateTime.parse(root.get(field).asText(null));
} catch (RuntimeException e) {
return null;
}
}
/** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */
private static EntitlementState mapState(String raw) {
if (raw == null) {
return EntitlementState.UNKNOWN;
}
return switch (raw) {
case "OK", "ACTIVE", "SUBSCRIBED", "FREE" -> EntitlementState.OK;
case "OVER_LIMIT", "PAYG_LIMIT_REACHED", "BLOCKED" -> EntitlementState.OVER_LIMIT;
default -> EntitlementState.UNKNOWN;
};
}
private HttpResponse<String> send(HttpRequest request) throws IOException {
try {
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted calling SaaS account-link", e);
}
}
private URI uri(String path) {
String base = properties.getSaasBaseUrl().strip().replaceAll("/+$", "");
return URI.create(base + path);
}
private Duration timeout() {
return Duration.ofSeconds(properties.getRequestTimeoutSeconds());
}
private static String text(JsonNode node, String field) {
return node.hasNonNull(field) ? node.get(field).asText() : null;
}
}
@@ -0,0 +1,124 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
/**
* Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A").
*
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
* the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
* to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
* / test aid).
*
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
* stirling.billing.account-link.enabled} off bean absent 404.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/account-link")
@Profile("!saas")
@PreAuthorize("hasRole('ADMIN')")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkController {
private final AccountLinkService service;
private final LocalUsageService localUsageService;
// Present only when metering is on (its own flag); absent /sync-now reports 409.
private final ObjectProvider<UsageSyncService> syncServiceProvider;
public AccountLinkController(
AccountLinkService service,
LocalUsageService localUsageService,
ObjectProvider<UsageSyncService> syncServiceProvider) {
this.service = service;
this.localUsageService = localUsageService;
this.syncServiceProvider = syncServiceProvider;
}
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
public record LinkRequest(String supabaseJwt, String name) {}
@PostMapping("/link")
public ResponseEntity<?> link(@RequestBody LinkRequest req) {
if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) {
return ResponseEntity.badRequest()
.body(java.util.Map.of("error", "supabaseJwt is required"));
}
try {
return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name()));
} catch (AccountLinkClient.UpstreamException e) {
// Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so
// the portal can prompt a re-sign-in. Anything else upstream 502. Don't echo the
// raw upstream body back to the browser.
HttpStatus status =
e.status() == HttpStatus.UNAUTHORIZED.value()
|| e.status() == HttpStatus.FORBIDDEN.value()
? HttpStatus.valueOf(e.status())
: HttpStatus.BAD_GATEWAY;
log.warn("Account-link register rejected upstream: HTTP {}", e.status());
return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED"));
} catch (IOException e) {
// Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the
// configured SaaS host/IP. Log it server-side; return the same opaque body the
// UpstreamException branch does.
log.warn("Account-link failed (transport): {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "LINK_FAILED"));
}
}
@GetMapping("/status")
public ResponseEntity<AccountLinkService.LinkStatus> status() {
return ResponseEntity.ok(service.status());
}
@PostMapping("/unlink")
public ResponseEntity<Void> unlink() {
service.unlink();
return ResponseEntity.noContent().build();
}
/**
* Locally accrued usage not yet reported to SaaS the portal adds it to the SaaS-synced spend
* so "current usage" includes work done since the last daily sync.
*/
@GetMapping("/usage")
public ResponseEntity<LocalUsageService.LocalUsage> usage() {
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
}
/**
* Forces an immediate usage sync to SaaS the same work the daily scheduler does. An admin
* "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
* re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
* {@code 409} when metering is off (the sync bean is absent).
*/
@PostMapping("/sync-now")
public ResponseEntity<Void> syncNow() {
UsageSyncService sync = syncServiceProvider.getIfAvailable();
if (sync == null) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
sync.syncNow();
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,76 @@
package stirling.software.proprietary.accountlink;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Getter;
import lombok.Setter;
/**
* Self-hosted side of combined-billing "Mode A" (connected self-hosted).
*
* <p>Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag
* the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional
* code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is
* <b>off by default</b> and <b>dark</b> when off nothing gates and the link endpoints 404.
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "stirling.billing.account-link")
public class AccountLinkProperties {
/** Master switch. When {@code false} (default) the feature is fully inert. */
private boolean enabled = false;
/**
* Base URL of the SaaS backend this instance links to (register + entitlement live there).
*
* <p>STUB: defaults to the public cloud host; an operator overrides it for staging. There is no
* existing SaaS-base-url property in the self-hosted profile, so this is introduced here.
*/
private String saasBaseUrl = "https://stirling.com/app";
/** Cached entitlement is reused for this long before a refresh is attempted. */
private long entitlementCacheSeconds = 300;
/** Connect/read timeout for the outbound SaaS calls. */
private int requestTimeoutSeconds = 10;
/** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
private final Metering metering = new Metering();
/**
* Dedicated billing switch, <b>separate</b> from {@link #enabled} so the link plumbing can be
* enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
* enforcement. Both default off; metering requires the master flag too. This is the production
* safety key flipping it on is what actually bills linked instances.
*/
@Getter
@Setter
public static class Metering {
/** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
private boolean enabled = false;
/**
* How often the instance syncs usage + refreshes entitlement (matches the licence sync).
*/
private int syncIntervalHours = 24;
/**
* Block billable work after this many days with no successful sync (fail-open closed).
*/
private int graceDays = 3;
/**
* Dedup window for identical input sets. A re-run of the same inputs within this window is
* treated as workflow chaining and not re-charged; the same inputs run again after it are
* billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
* costs the same on the instance and in the cloud.
*/
private Duration workflowWindow = Duration.ofMinutes(5);
}
}
@@ -0,0 +1,92 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Linking orchestrator (self-hosted side of combined-billing "Mode A").
*
* <p>{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase
* JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest.
* The credential not the JWT authenticates all later unattended entitlement calls.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkService {
private final AccountLinkClient client;
private final DeviceCredentialStore credentialStore;
private final EntitlementCache entitlementCache;
public AccountLinkService(
AccountLinkClient client,
DeviceCredentialStore credentialStore,
EntitlementCache entitlementCache) {
this.client = client;
this.credentialStore = credentialStore;
this.entitlementCache = entitlementCache;
}
/** Status of this instance's link, for the portal's "Account link" card. */
public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {}
/**
* Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the
* credential.
*
* @throws IOException if the SaaS register call fails (surfaced to the admin as a link error).
*/
public LinkStatus link(String supabaseJwt, String instanceName) throws IOException {
AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName);
credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId());
entitlementCache.invalidate();
log.info("Account-link: instance linked to team {}", result.teamId());
return status();
}
/**
* Unlinks this instance best-effort tells SaaS to revoke first (so the row gets {@code
* revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear
* still proceeds (admin's intent must win); the orphan row can be revoked from the portal.
*/
public void unlink() {
credentialStore
.get()
.ifPresent(
c -> {
boolean ok = client.revokeSelf(c.getDeviceId(), c.getDeviceSecret());
if (!ok) {
log.warn(
"Account-link: SaaS self-revoke failed for device {};"
+ " clearing locally anyway (admin can revoke"
+ " from the portal).",
c.getDeviceId());
}
});
credentialStore.clear();
entitlementCache.invalidate();
log.info("Account-link: instance unlinked");
}
public LinkStatus status() {
Optional<DeviceCredential> cred = credentialStore.get();
return cred.map(
c ->
new LinkStatus(
true,
c.getDeviceId(),
c.getTeamId(),
c.getLinkedAt() != null
? c.getLinkedAt().toString()
: null))
.orElseGet(() -> new LinkStatus(false, null, null, null));
}
}
@@ -0,0 +1,46 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
*
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
* is strictly monotonic across restarts and partial failures SaaS dedups replays by comparing it,
* so a never-decreasing seq is the contract. {@link #lastSuccessAt} is the wall-clock of the last
* sync SaaS accepted and drives the fail-openclosed grace window.
*
* <p>Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated sync.
*/
@Entity
@Table(name = "account_link_sync_state")
@Getter
@Setter
@NoArgsConstructor
public class AccountLinkSyncState {
/** One instance links to one team → one bookkeeping row. */
public static final long SINGLETON_ID = 1L;
@Id private Long id;
// columnDefinition default keeps the ddl-auto ADD COLUMN safe on a populated external Postgres.
@Column(
name = "last_sync_seq",
nullable = false,
columnDefinition = "bigint not null default 0")
private long lastSyncSeq;
/** Null until the first sync SaaS accepts. */
@Column(name = "last_success_at")
private LocalDateTime lastSuccessAt;
}
@@ -0,0 +1,6 @@
package stirling.software.proprietary.accountlink;
import org.springframework.data.jpa.repository.JpaRepository;
/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
public interface AccountLinkSyncStateRepository extends JpaRepository<AccountLinkSyncState, Long> {}
@@ -0,0 +1,36 @@
package stirling.software.proprietary.accountlink;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Registers the account-link entitlement gate. Path patterns cover the billable API surface; the
* interceptor itself re-checks billability (and short-circuits manual tools), but scoping here
* keeps the gate off the bulk of interactive endpoints entirely.
*
* <p>Whole config is gated behind {@code stirling.billing.account-link.enabled} +
* {@code @Profile("!saas")}; absent when off, so no interceptor is registered.
*/
@Configuration
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkWebMvcConfig implements WebMvcConfigurer {
private final InstanceEntitlementInterceptor gateInterceptor;
public AccountLinkWebMvcConfig(InstanceEntitlementInterceptor gateInterceptor) {
this.gateInterceptor = gateInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// AI surface is always billable; the broad /api/v1/** catch lets automation-marked manual
// calls be gated too, while the interceptor lets genuine manual tools through.
registry.addInterceptor(gateInterceptor)
.addPathPatterns("/api/v1/**")
.excludePathPatterns("/api/v1/account-link/**");
}
}
@@ -0,0 +1,56 @@
package stirling.software.proprietary.accountlink;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.service.InternalApiClient;
import stirling.software.proprietary.billing.BillingCategory;
import stirling.software.proprietary.billing.BillingCategoryClassifier;
/**
* Buckets a request into a {@link BillingCategory} for the account-link gate + meter, using only
* HTTP-level signals (no dependency on the saas module):
*
* <ul>
* <li><b>AUTOMATION</b> the automation marker header ({@link
* InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy sub-steps);
* <li><b>AI</b> the AI surface ({@code /api/v1/ai/**});
* <li><b>API</b> an API-key authenticated tool call;
* <li><b>BYPASSED</b> a manual interactive tool call, never billed.
* </ul>
*
* <p>Same precedence as the SaaS classifier (AUTOMATION AI API BYPASSED) via the shared
* {@link BillingCategoryClassifier}; the AI signal is resolved by path prefix rather than the
* saas-only {@code @RequiresFeature} annotation. The {@code apiKey} signal is supplied by the
* caller (resolved from the security context), so this class stays free of any security-type
* dependency.
*/
public final class BillableOperationClassifier {
private static final String AI_PATH_PREFIX = "/api/v1/ai/";
private BillableOperationClassifier() {}
/**
* @param apiKey whether the request authenticated via an API key (an {@code
* ApiKeyAuthenticationToken} principal), resolved by the caller from the security context.
*/
public static BillingCategory categorize(HttpServletRequest request, boolean apiKey) {
boolean automation = request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null;
return BillingCategoryClassifier.classify(automation, isAiSurface(request), apiKey);
}
private static boolean isAiSurface(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri == null) {
return false;
}
// Prefix-match the AI surface (not a loose substring contains), stripping a deployment
// context path so /<ctx>/api/v1/ai/** still classifies as AI.
String ctx = request.getContextPath();
String path =
ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
? uri.substring(ctx.length())
: uri;
return path.startsWith(AI_PATH_PREFIX);
}
}
@@ -0,0 +1,53 @@
package stirling.software.proprietary.accountlink;
import java.io.Serializable;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* The device credential this self-hosted instance received when it linked a SaaS account
* (combined-billing "Mode A"). Singleton one instance links to exactly one SaaS team.
*
* <p>Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code
* deviceSecret} so it can present it on every unattended entitlement call. It lives in the local
* database (the same store that already holds API-key material and the license signature), so it is
* as secure-at-rest as the rest of the instance's secrets.
*/
@Entity
@Table(name = "account_link_device_credential")
@NoArgsConstructor
@Getter
@Setter
public class DeviceCredential implements Serializable {
private static final long serialVersionUID = 1L;
public static final Long SINGLETON_ID = 1L;
@Id
@Column(name = "id")
private Long id = SINGLETON_ID;
/** Public identifier minted by the SaaS register call; sent as {@code X-Device-Id}. */
@Column(name = "device_id", nullable = false, length = 64)
private String deviceId;
/** High-entropy secret returned once by register; sent as {@code X-Device-Secret}. */
@Column(name = "device_secret", nullable = false, length = 128)
private String deviceSecret;
/** SaaS team this instance is linked to; informational on the instance side. */
@Column(name = "team_id")
private Long teamId;
@Column(name = "linked_at", nullable = false)
private LocalDateTime linkedAt;
}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.accountlink;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface DeviceCredentialRepository extends JpaRepository<DeviceCredential, Long> {
/** The singleton credential, if this instance has linked. */
default Optional<DeviceCredential> findCredential() {
return findById(DeviceCredential.SINGLETON_ID);
}
}
@@ -0,0 +1,55 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Secure-at-rest persistence for this instance's device credential. Thin wrapper over the
* singleton-row repository so the rest of the feature never touches JPA directly.
*
* <p>Gated + {@code @Profile("!saas")}: only the self-hosted profile links outward to a SaaS team.
*/
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class DeviceCredentialStore {
private final DeviceCredentialRepository repo;
public DeviceCredentialStore(DeviceCredentialRepository repo) {
this.repo = repo;
}
@Transactional(readOnly = true)
public Optional<DeviceCredential> get() {
return repo.findCredential();
}
@Transactional(readOnly = true)
public boolean isLinked() {
return repo.findCredential().isPresent();
}
/** Persists (or replaces) the credential returned by a SaaS register call. */
@Transactional
public void save(String deviceId, String deviceSecret, Long teamId) {
DeviceCredential cred = repo.findCredential().orElseGet(DeviceCredential::new);
cred.setId(DeviceCredential.SINGLETON_ID);
cred.setDeviceId(deviceId);
cred.setDeviceSecret(deviceSecret);
cred.setTeamId(teamId);
cred.setLinkedAt(LocalDateTime.now());
repo.save(cred);
}
/** Unlinks this instance locally (idempotent). */
@Transactional
public void clear() {
repo.findCredential().ifPresent(repo::delete);
}
}
@@ -0,0 +1,127 @@
package stirling.software.proprietary.accountlink;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Caches the linked team's entitlement so the request-time gate needn't call SaaS on every billable
* request. Single-slot (one instance = one linked team), TTL-based.
*
* <p>A transport failure fails open {@link #current()} keeps serving the freshest snapshot it has
* and returns {@link Optional#empty()} ("unknown → allow") only when nothing was ever fetched. An
* authoritative deny ({@link AccountLinkClient.RevokedException}) does not: the snapshot is
* replaced with a {@link EntitlementState#REVOKED} entitlement so the gate blocks immediately.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class EntitlementCache {
private final DeviceCredentialStore credentialStore;
private final AccountLinkClient client;
private final Duration ttl;
/** Entitlement + fetch time, swapped atomically as one value so readers never tear. */
private record Snapshot(InstanceEntitlement entitlement, Instant fetchedAt) {}
private static final Snapshot EMPTY = new Snapshot(null, Instant.EPOCH);
/** Blocked entitlement synthesised on an authoritative deny (revoked/invalid credential). */
private static final InstanceEntitlement REVOKED =
new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
private volatile Snapshot snapshot = EMPTY;
/** Single-flight guard: one thread refreshes while others serve the current snapshot. */
private final AtomicBoolean refreshing = new AtomicBoolean(false);
public EntitlementCache(
DeviceCredentialStore credentialStore,
AccountLinkClient client,
AccountLinkProperties properties) {
this.credentialStore = credentialStore;
this.client = client;
this.ttl = Duration.ofSeconds(properties.getEntitlementCacheSeconds());
}
/**
* Current entitlement, refreshing if stale. {@link Optional#empty()} means "unknown" either
* not linked or the SaaS side is unreachable and we have no prior snapshot.
*/
public Optional<InstanceEntitlement> current() {
// Single-flight: when stale, exactly one thread refreshes while concurrent callers serve
// the last snapshot no thundering herd of round-trips on the billable hot path.
if (isStale(snapshot) && refreshing.compareAndSet(false, true)) {
try {
refresh();
} finally {
refreshing.set(false);
}
}
return Optional.ofNullable(snapshot.entitlement());
}
private boolean isStale(Snapshot snap) {
// fetchedAt is the last *attempt* time (stamped on success and failure), so a failed fetch
// backs off a full TTL instead of every request re-triggering a round-trip to a dead SaaS.
return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0;
}
/**
* Pulls a fresh snapshot. On a transport failure keeps the previous entitlement but stamps the
* attempt time so re-fetches throttle to the TTL; on an authoritative deny replaces it with a
* blocked snapshot.
*/
void refresh() {
Optional<DeviceCredential> cred = credentialStore.get();
if (cred.isEmpty()) {
// Unlinked: clear any stale snapshot so the gate sees "not linked".
snapshot = new Snapshot(null, Instant.now());
return;
}
try {
InstanceEntitlement fresh =
client.fetchEntitlement(cred.get().getDeviceId(), cred.get().getDeviceSecret());
if (fresh != null) {
snapshot = new Snapshot(fresh, Instant.now());
} else {
// Unreachable / server error: keep the last known entitlement but stamp the attempt
// so we don't hammer SaaS; the gate fails open meanwhile.
log.debug(
"Entitlement refresh failed; reusing last known snapshot, backing off a TTL");
snapshot = new Snapshot(snapshot.entitlement(), Instant.now());
}
} catch (AccountLinkClient.RevokedException e) {
// Authoritative deny block immediately rather than serving the stale entitled
// snapshot.
log.info(
"Entitlement denied (HTTP {}); blocking billable work for the revoked credential",
e.status());
snapshot = new Snapshot(REVOKED, Instant.now());
}
}
/** Forces a refresh on the next {@link #current()} (e.g. right after linking). */
public void invalidate() {
snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH);
}
/**
* Seeds the cache with an entitlement obtained out-of-band (the sync reply carries a fresh
* one), saving a redundant fetch. No-op on null.
*/
public void accept(InstanceEntitlement fresh) {
if (fresh != null) {
snapshot = new Snapshot(fresh, Instant.now());
}
}
}
@@ -0,0 +1,19 @@
package stirling.software.proprietary.accountlink;
/**
* Coarse entitlement state the local gate enforces against. Proprietary-local (no coupling to the
* saas billing module): the SaaS entitlement response is parsed into this minimal shape.
*/
public enum EntitlementState {
/** Within free pool or covered by an active subscription — billable work allowed. */
OK,
/** Free pool exhausted and no subscription / over the period cap — billable work blocked. */
OVER_LIMIT,
/**
* Device credential revoked/invalid on the SaaS side (authoritative 401/403 deny) billable
* work blocked. Synthesised locally by {@code EntitlementCache}, never sent by SaaS.
*/
REVOKED,
/** Unrecognised/malformed reply — the gate falls back to its numeric checks, not this flag. */
UNKNOWN
}
@@ -0,0 +1,39 @@
package stirling.software.proprietary.accountlink;
/**
* Outcome of {@link InstanceEntitlementGate}. {@link #allowed} is what the interceptor enforces;
* {@link #reason} carries the machine-readable signal the FE maps to a prompt (e.g. "link to
* activate"). Manual-tool and fail-open allows carry an informational reason but never block.
*/
public record GateDecision(boolean allowed, Reason reason) {
public enum Reason {
/** Feature flag is off — gate is fully inert. */
FLAG_OFF,
/** Operation is a manual tool — always free, never gated. */
MANUAL_FREE,
/** Linked + within entitlement — billable work allowed. */
ENTITLED,
/** Entitlement source unreachable — fail open, allow. */
FAIL_OPEN,
/**
* Linked + metering, but SaaS has been unreachable past the grace window block (the
* fail-open backstop expired) so unbounded free/unbilled billable work can't continue.
*/
GRACE_EXPIRED,
/** Not linked — block billable work; FE should prompt to link. */
NOT_LINKED,
/** Linked but over the limit / no subscription — block billable work. */
OVER_LIMIT,
/** Credential revoked/invalid on the SaaS side — block billable work. */
REVOKED
}
public static GateDecision allow(Reason reason) {
return new GateDecision(true, reason);
}
public static GateDecision block(Reason reason) {
return new GateDecision(false, reason);
}
}
@@ -0,0 +1,53 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import stirling.software.proprietary.billing.UnitCalcPolicy;
/**
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response.
* Mirrors the saas {@code EntitlementResponse} shape but carries no saas types.
*
* <p>The first five fields are what the <b>gate</b> enforces against; the trailing three are the
* metering inputs (Phase 2) the instance uses to cost + bucket its own usage and reset its
* per-period counters. The 5-arg constructor builds a gate-only view (metering fields null) for the
* revoked sentinel and unit tests that don't exercise metering.
*
* @param subscribed team has an active subscription
* @param freeRemainingUnits remaining free-pool units (>0 means free work is available)
* @param periodSpendUnits paid units spent this period
* @param periodCapUnits paid cap for the period; {@code null} = uncapped
* @param state coarse state classification (see {@link EntitlementState})
* @param unitCalcPolicy doc-unit pricing knobs for local unit computation; {@code null} if not
* supplied (older SaaS / gate-only sentinel)
* @param periodStart inclusive start of the current billing period; {@code null} if not supplied
* @param periodEnd exclusive end of the current billing period; {@code null} if not supplied
*/
public record InstanceEntitlement(
boolean subscribed,
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
EntitlementState state,
UnitCalcPolicy unitCalcPolicy,
LocalDateTime periodStart,
LocalDateTime periodEnd) {
/** Gate-only view with no metering config — used by the revoked sentinel and gate tests. */
public InstanceEntitlement(
boolean subscribed,
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
EntitlementState state) {
this(
subscribed,
freeRemainingUnits,
periodSpendUnits,
periodCapUnits,
state,
null,
null,
null);
}
}
@@ -0,0 +1,174 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
/**
* Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance.
*
* <p>Rules (in order):
*
* <ol>
* <li>Flag off always allow (feature inert).
* <li>Manual tool always allow (manual tools are free, never metered).
* <li>Billable + not linked block with {@code NOT_LINKED} ("link to activate").
* <li>Billable + linked + entitlement unknown (unreachable) <b>fail open</b>, allow unless
* metering is on and SaaS has been unreachable past the grace window, then block with {@code
* GRACE_EXPIRED} so the fail-open can't grant unbounded free/unbilled work forever.
* <li>Billable + linked + entitled allow.
* <li>Billable + linked + credential revoked block with {@code REVOKED}.
* <li>Billable + linked + over limit block with {@code OVER_LIMIT}.
* </ol>
*
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper supplies the live
* flag / linked-state / entitlement and computes whether the grace window has expired. This is the
* unit-tested core.
*/
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceEntitlementGate {
private final AccountLinkProperties properties;
private final DeviceCredentialStore credentialStore;
private final EntitlementCache entitlementCache;
private final AccountLinkSyncStateRepository syncStateRepository;
private final LocalUsageService localUsageService;
public InstanceEntitlementGate(
AccountLinkProperties properties,
DeviceCredentialStore credentialStore,
EntitlementCache entitlementCache,
AccountLinkSyncStateRepository syncStateRepository,
LocalUsageService localUsageService) {
this.properties = properties;
this.credentialStore = credentialStore;
this.entitlementCache = entitlementCache;
this.syncStateRepository = syncStateRepository;
this.localUsageService = localUsageService;
}
/** Evaluates the gate for a request, resolving live state from the store + cache. */
public GateDecision evaluate(boolean billable) {
if (!properties.isEnabled()) {
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
}
if (!billable) {
return GateDecision.allow(GateDecision.Reason.MANUAL_FREE);
}
boolean linked = credentialStore.isLinked();
Optional<InstanceEntitlement> entitlement =
linked ? entitlementCache.current() : Optional.empty();
boolean graceExpired = linked && entitlement.isEmpty() && isGraceExpired();
// Deplete the applicable ceiling free grant (unsubscribed) or spend cap (capped
// subscription) by local usage not yet synced, so the gate stops in real time instead of
// overshooting until the next sync. An uncapped subscription has no ceiling to deplete 0.
long pendingUnsynced =
entitlement.map(InstanceEntitlementGate::depletesCeiling).orElse(false)
? localUsageService.currentPeriodUnsynced().totalUnsyncedUnits()
: 0L;
return decide(true, true, linked, entitlement, graceExpired, pendingUnsynced);
}
/** Whether local unsynced usage pushes against a real ceiling (free grant or a spend cap). */
private static boolean depletesCeiling(InstanceEntitlement e) {
return !e.subscribed() || e.periodCapUnits() != null;
}
/**
* Pure decision function no Spring, no I/O. {@code entitlement} empty means "unknown"
* (unreachable): when linked, that fails open unless {@code graceExpired} (the metering grace
* window elapsed with no authoritative contact), in which case it blocks.
*
* @param pendingUnsyncedUnits billable units accrued locally since the last sync depletes the
* free grant (unsubscribed) or the spend cap (capped subscription) in real time so the gate
* stops without waiting for the next sync (0 for uncapped-subscribed / unknown-entitlement
* cases, where it has no effect).
*/
public static GateDecision decide(
boolean flagEnabled,
boolean billable,
boolean linked,
Optional<InstanceEntitlement> entitlement,
boolean graceExpired,
long pendingUnsyncedUnits) {
if (!flagEnabled) {
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
}
if (!billable) {
return GateDecision.allow(GateDecision.Reason.MANUAL_FREE);
}
if (!linked) {
return GateDecision.block(GateDecision.Reason.NOT_LINKED);
}
if (entitlement.isEmpty()) {
// Linked but entitlement unreachable: fail open, unless the grace window has expired
// (so
// the fail-open can't grant unbounded unbilled work forever).
return graceExpired
? GateDecision.block(GateDecision.Reason.GRACE_EXPIRED)
: GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
}
InstanceEntitlement e = entitlement.get();
if (e.state() == EntitlementState.REVOKED) {
// Credential revoked/invalid (authoritative deny) block, distinct from over-limit.
return GateDecision.block(GateDecision.Reason.REVOKED);
}
return entitled(e, pendingUnsyncedUnits)
? GateDecision.allow(GateDecision.Reason.ENTITLED)
: GateDecision.block(GateDecision.Reason.OVER_LIMIT);
}
/**
* True when metering is on and it's been {@code graceDays} since the last authoritative contact
* (last successful sync, or link time if never synced). {@code graceDays <= 0} or metering off
* disables the backstop.
*/
private boolean isGraceExpired() {
AccountLinkProperties.Metering metering = properties.getMetering();
if (!metering.isEnabled() || metering.getGraceDays() <= 0) {
return false;
}
LocalDateTime reference = lastAuthoritativeContact();
if (reference == null) {
return false; // can't determine elapsed time fail open
}
return reference.plusDays(metering.getGraceDays()).isBefore(LocalDateTime.now());
}
private LocalDateTime lastAuthoritativeContact() {
LocalDateTime lastSuccess =
syncStateRepository
.findById(AccountLinkSyncState.SINGLETON_ID)
.map(AccountLinkSyncState::getLastSuccessAt)
.orElse(null);
if (lastSuccess != null) {
return lastSuccess;
}
return credentialStore.get().map(DeviceCredential::getLinkedAt).orElse(null);
}
/** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */
private static boolean entitled(InstanceEntitlement e, long pendingUnsyncedUnits) {
if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) {
return false;
}
if (e.subscribed()) {
if (e.periodCapUnits() == null) {
return true; // uncapped subscription
}
// Project the cap the way the grant is projected: synced paid spend plus the paid part
// of local usage not yet synced (free grant is consumed first, so only the excess
// bills) stops at the cap in real time instead of overshooting until the next sync.
long pendingPaid = Math.max(0, pendingUnsyncedUnits - e.freeRemainingUnits());
return e.periodSpendUnits() + pendingPaid < e.periodCapUnits();
}
// Unsubscribed: free pool must cover SaaS-charged usage (in freeRemainingUnits) plus local
// usage not yet synced deplete by the pending delta so we stop at the grant in real time.
return e.freeRemainingUnits() - pendingUnsyncedUnits > 0;
}
}
@@ -0,0 +1,243 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.util.WebUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.proprietary.billing.BillingCategory;
import stirling.software.proprietary.billing.ContentHasher;
import stirling.software.proprietary.billing.DocumentUnitCalculator;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
* Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
* AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
* {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
*
* <p>Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
* prompt; fail-open and flag-off both let the request continue. Metering is separately gated behind
* {@code metering.enabled} via {@link ObjectProvider} switch off means the {@link
* UsageMeterService} bean is absent and nothing accrues, while the gate still works.
*/
@Slf4j
@Component
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceEntitlementInterceptor implements HandlerInterceptor {
private static final String ATTR_CATEGORY =
InstanceEntitlementInterceptor.class.getName() + ".category";
private final InstanceEntitlementGate gate;
private final EntitlementCache entitlementCache;
private final ObjectProvider<UsageMeterService> meterProvider;
private final TempFileManager tempFileManager;
public InstanceEntitlementInterceptor(
InstanceEntitlementGate gate,
EntitlementCache entitlementCache,
ObjectProvider<UsageMeterService> meterProvider,
TempFileManager tempFileManager) {
this.gate = gate;
this.entitlementCache = entitlementCache;
this.meterProvider = meterProvider;
this.tempFileManager = tempFileManager;
}
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
GateDecision decision;
try {
// API-key tool calls are billable (category API); stash the category for the meter.
boolean apiKey =
SecurityContextHolder.getContext().getAuthentication()
instanceof ApiKeyAuthenticationToken;
BillingCategory category = BillableOperationClassifier.categorize(request, apiKey);
request.setAttribute(ATTR_CATEGORY, category);
decision = gate.evaluate(category != BillingCategory.BYPASSED);
} catch (RuntimeException e) {
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
// turn into a hard block on billable work.
log.debug("Account-link gate evaluation failed; allowing request", e);
return true;
}
if (decision.allowed()) {
return true;
}
log.debug("Account-link gate blocked {} ({})", request.getRequestURI(), decision.reason());
response.setStatus(HttpStatus.PAYMENT_REQUIRED.value());
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"ACCOUNT_LINK_REQUIRED\",\"reason\":\""
+ decision.reason().name()
+ "\"}");
return false;
}
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
// Meter successful billable ops only.
if (ex != null || response.getStatus() >= 400) {
return;
}
UsageMeterService meter = meterProvider.getIfAvailable();
if (meter == null) {
return; // metering switch off
}
if (!(request.getAttribute(ATTR_CATEGORY) instanceof BillingCategory category)
|| category == BillingCategory.BYPASSED) {
return;
}
try {
InstanceEntitlement ent = entitlementCache.current().orElse(null);
if (ent == null || ent.unitCalcPolicy() == null || ent.periodStart() == null) {
// Not yet synced (no policy/period) can't compute units; skip until next sync.
return;
}
meterRequest(request, category, ent, meter);
} catch (RuntimeException e) {
// Metering must never affect the response that already completed.
log.debug("Usage metering failed for {}", request.getRequestURI(), e);
}
}
/**
* Computes doc-units (page + byte axes) and the input-set signature, then accrues. The instance
* is authoritative for units (SaaS bills the delta and never sees the file), so a page-heavy
* but small PDF must be page-counted or it under-bills. A fileless op has no input identity
* null signature (no dedup), billed the 1-unit floor each time.
*/
private void meterRequest(
HttpServletRequest request,
BillingCategory category,
InstanceEntitlement ent,
UsageMeterService meter) {
UnitCalcPolicy policy = ent.unitCalcPolicy();
MultipartHttpServletRequest mreq =
WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class);
if (mreq == null) {
long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy);
meter.accrue(ent.periodStart(), category, fileless, null);
return;
}
List<TempFile> temps = new ArrayList<>();
try {
List<FileSize> sizes = new ArrayList<>();
List<String> hashes = new ArrayList<>();
int fileCount = 0;
for (List<MultipartFile> files : mreq.getMultiFileMap().values()) {
for (MultipartFile f : files) {
fileCount++;
try {
TempFile temp = tempFileManager.createManagedTempFile(".bin");
temps.add(temp);
// Hash in the same pass that writes the temp file one read of the upload,
// not a second full read just to fingerprint it.
MessageDigest digest = ContentHasher.newSha256();
try (InputStream in = f.getInputStream();
DigestOutputStream out =
new DigestOutputStream(
Files.newOutputStream(temp.getPath()), digest)) {
in.transferTo(out);
}
sizes.add(new FileSize(pageCount(temp.getPath(), f), f.getSize()));
hashes.add(ContentHasher.toHex(digest.digest()));
} catch (IOException | RuntimeException perFile) {
// Couldn't materialise/hash this input bill on bytes only and, by leaving
// it out of `hashes`, drop dedup for the whole op rather than risk a
// mismatch.
log.debug(
"Metering materialise/hash failed for {}; bytes-only",
f.getOriginalFilename());
sizes.add(new FileSize(0, f.getSize()));
}
}
}
long units =
sizes.isEmpty()
? DocumentUnitCalculator.unitsForFile(0, 0, policy)
: DocumentUnitCalculator.unitsForGroup(sizes, policy);
// Only dedup when every input hashed; a partial signature could collide with a
// different input set, so fall back to no-dedup (bill it) if any file failed.
String opSignature =
fileCount > 0 && hashes.size() == fileCount ? opSignature(hashes) : null;
meter.accrue(ent.periodStart(), category, units, opSignature);
} finally {
for (TempFile temp : temps) {
try {
temp.close();
} catch (RuntimeException cleanup) {
log.debug("Temp file cleanup failed: {}", cleanup.getMessage());
}
}
}
}
/** Page count via jpdfium (parser-identical to SaaS); 0 for non-PDF / unreadable inputs. */
private static int pageCount(Path path, MultipartFile file) {
if (!isPdf(file)) {
return 0;
}
try (PdfDocument doc = PdfDocument.open(path)) {
return doc.pageCount();
} catch (RuntimeException e) {
// Malformed / encrypted byte axis only, matching the SaaS classifier.
log.debug(
"Page count unavailable for {}; metering on bytes only",
file.getOriginalFilename());
return 0;
}
}
/** Order-independent signature of the input set: sorted per-file hashes, hashed together. */
private static String opSignature(List<String> hashes) {
List<String> sorted = new ArrayList<>(hashes);
Collections.sort(sorted);
return ContentHasher.sha256(String.join("\n", sorted).getBytes(StandardCharsets.UTF_8));
}
private static boolean isPdf(MultipartFile file) {
String contentType = file.getContentType();
if (contentType != null && contentType.toLowerCase().contains("pdf")) {
return true;
}
String name = file.getOriginalFilename();
return name != null && name.toLowerCase().endsWith(".pdf");
}
}
@@ -0,0 +1,59 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.EnumMap;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Reads this instance's locally accrued but not-yet-synced usage for the current period. The portal
* adds this on top of SaaS-synced spend so "current usage" reflects work done since the last sync.
*
* <p>Unsynced per category = {@code cumulativeUnits lastSyncedUnits} (floored at 0), scoped to
* the current period so prior-period leftovers don't inflate it. Zeros when the period is unknown
* or metering is off.
*/
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class LocalUsageService {
private final UsageCounterRepository counters;
private final EntitlementCache entitlementCache;
public LocalUsageService(UsageCounterRepository counters, EntitlementCache entitlementCache) {
this.counters = counters;
this.entitlementCache = entitlementCache;
}
/** Per-category unsynced units for the current period; {@code periodStart} null = unknown. */
public record LocalUsage(
LocalDateTime periodStart,
long apiUnsyncedUnits,
long aiUnsyncedUnits,
long automationUnsyncedUnits,
long totalUnsyncedUnits) {}
public LocalUsage currentPeriodUnsynced() {
LocalDateTime period =
entitlementCache.current().map(InstanceEntitlement::periodStart).orElse(null);
if (period == null) {
return new LocalUsage(null, 0, 0, 0, 0);
}
EnumMap<BillingCategory, Long> unsynced = new EnumMap<>(BillingCategory.class);
for (UsageCounter c : counters.findByPeriodStart(period)) {
BillingCategory cat = c.billingCategory();
if (cat != null && cat != BillingCategory.BYPASSED) {
unsynced.merge(cat, c.unsyncedUnits(), Long::sum);
}
}
long api = unsynced.getOrDefault(BillingCategory.API, 0L);
long ai = unsynced.getOrDefault(BillingCategory.AI, 0L);
long automation = unsynced.getOrDefault(BillingCategory.AUTOMATION, 0L);
return new LocalUsage(period, api, ai, automation, api + ai + automation);
}
}
@@ -0,0 +1,73 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* The last time the instance metered a given input set this period the local equivalent of the
* cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling <b>workflow
* window</b>: an identical input set re-submitted within the window (see {@link
* AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
* same inputs run again after the window are billed afresh matching the cloud's 5-minute open-job
* window so the same operation costs the same on the instance and in the cloud.
*
* <p>{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
* artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
* makes the first-sighting insert an atomic claim under concurrency.
*
* <p>Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated meter.
*/
@Entity
@Table(
name = "account_link_metered_signature",
uniqueConstraints =
@UniqueConstraint(
name = "uk_account_link_metered_signature",
columnNames = {"period_start", "signature"}))
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MeteredInputSignature {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "period_start", nullable = false)
private LocalDateTime periodStart;
/** SHA-256 hex of the op's input set (64 chars); the dedup key within a period. */
@Column(name = "signature", nullable = false, length = 64)
private String signature;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
/**
* When this input set was last metered the anchor the workflow-window dedup compares against.
*/
@Column(name = "last_metered_at")
private LocalDateTime lastMeteredAt;
public MeteredInputSignature(LocalDateTime periodStart, String signature, LocalDateTime at) {
this.periodStart = periodStart;
this.signature = signature;
this.createdAt = at;
this.lastMeteredAt = at;
}
/** Slides the window forward — the input set was seen again. */
public void touch(LocalDateTime at) {
this.lastMeteredAt = at;
}
}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
public interface MeteredInputSignatureRepository
extends JpaRepository<MeteredInputSignature, Long> {
/** The existing row for a seen input set, so the meter can apply the workflow-window check. */
Optional<MeteredInputSignature> findByPeriodStartAndSignature(
LocalDateTime periodStart, String signature);
}
@@ -0,0 +1,105 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
* Each successful billable op increments its row; the daily sync reports the cumulative totals and
* SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
* nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
* category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
*/
@Entity
@Table(
name = "account_link_usage_counter",
uniqueConstraints =
@UniqueConstraint(
name = "uk_usage_counter_period_category",
columnNames = {"period_start", "category"}))
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UsageCounter {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* Inclusive start of the billing period this counter belongs to (from the entitlement sync).
*/
@Column(name = "period_start", nullable = false)
private LocalDateTime periodStart;
/** {@code BillingCategory} name — API / AI / AUTOMATION (never BYPASSED). */
@Column(name = "category", nullable = false, length = 32)
private String category;
/** Running total of metered units in this period+category. */
@Column(name = "cumulative_units", nullable = false)
private long cumulativeUnits;
/**
* {@link #cumulativeUnits} as of the last sync SaaS accepted; the difference is the unreported
* usage the portal shows on top of SaaS-synced spend. The {@code columnDefinition} default
* keeps the {@code ddl-auto=update} ADD COLUMN safe against a table an earlier build already
* populated (NOT NULL with no default would fail the ALTER).
*/
@Column(
name = "last_synced_units",
nullable = false,
columnDefinition = "bigint not null default 0")
private long lastSyncedUnits;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/** Fresh-accrual row: nothing synced yet. */
public UsageCounter(
LocalDateTime periodStart,
String category,
long cumulativeUnits,
LocalDateTime updatedAt) {
this(periodStart, category, cumulativeUnits, 0L, updatedAt);
}
public UsageCounter(
LocalDateTime periodStart,
String category,
long cumulativeUnits,
long lastSyncedUnits,
LocalDateTime updatedAt) {
this.periodStart = periodStart;
this.category = category;
this.cumulativeUnits = cumulativeUnits;
this.lastSyncedUnits = lastSyncedUnits;
this.updatedAt = updatedAt;
}
/** This row's category as the enum, or {@code null} for an unrecognised stored value. */
public BillingCategory billingCategory() {
try {
return BillingCategory.valueOf(category);
} catch (IllegalArgumentException unknown) {
return null;
}
}
/** Units accrued but not yet accepted by SaaS (floored at 0). */
public long unsyncedUnits() {
return Math.max(0, cumulativeUnits - lastSyncedUnits);
}
}
@@ -0,0 +1,57 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
public interface UsageCounterRepository extends JpaRepository<UsageCounter, Long> {
/**
* Atomically adds {@code delta} to an existing counter row. Returns the number of rows updated
* (0 when the row doesn't exist yet the caller then inserts). Doing the add in SQL avoids a
* read-modify-write race between concurrent billable requests.
*/
@Modifying
@Transactional
@Query(
"UPDATE UsageCounter c SET c.cumulativeUnits = c.cumulativeUnits + :delta,"
+ " c.updatedAt = :now"
+ " WHERE c.periodStart = :periodStart AND c.category = :category")
int increment(
@Param("periodStart") LocalDateTime periodStart,
@Param("category") String category,
@Param("delta") long delta,
@Param("now") LocalDateTime now);
/** All counters for a period — the daily sync reads these to report cumulative totals. */
List<UsageCounter> findByPeriodStart(LocalDateTime periodStart);
/**
* Periods (oldest first) that still hold usage not yet accepted by SaaS. The sync reports each
* so end-of-period usage isn't stranded when the billing period rolls over between syncs.
*/
@Query(
"SELECT DISTINCT c.periodStart FROM UsageCounter c"
+ " WHERE c.cumulativeUnits > c.lastSyncedUnits ORDER BY c.periodStart")
List<LocalDateTime> findPeriodsWithUnsyncedUsage();
/**
* Marks a counter synced up to {@code syncedUnits} (the cumulative value just accepted by
* SaaS), not the live cumulative concurrent accruals during the sync stay correctly unsynced.
*/
@Modifying
@Transactional
@Query(
"UPDATE UsageCounter c SET c.lastSyncedUnits = :syncedUnits"
+ " WHERE c.periodStart = :periodStart AND c.category = :category")
int markSynced(
@Param("periodStart") LocalDateTime periodStart,
@Param("category") String category,
@Param("syncedUnits") long syncedUnits);
}
@@ -0,0 +1,115 @@
package stirling.software.proprietary.accountlink;
import java.time.Duration;
import java.time.LocalDateTime;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Accrues metered usage into the durable per-(period, category) {@link UsageCounter}; the daily
* sync later reports the cumulative totals to SaaS.
*
* <p>Workflow-window dedup: an identical input set re-submitted within {@code metering.workflow-
* window} is treated as chaining and not re-charged; the same inputs run again after the window are
* billed afresh matching the cloud's open-job lineage window so the same op costs the same on the
* instance and in the cloud. Fileless ops pass a null signature and always accrue. {@link #accrue}
* is best-effort: callers need not handle persistence errors.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(
name = "stirling.billing.account-link.metering.enabled",
havingValue = "true")
public class UsageMeterService {
private final UsageCounterRepository repo;
private final MeteredInputSignatureRepository signatureRepo;
private final Duration workflowWindow;
public UsageMeterService(
UsageCounterRepository repo,
MeteredInputSignatureRepository signatureRepo,
AccountLinkProperties properties) {
this.repo = repo;
this.signatureRepo = signatureRepo;
this.workflowWindow = properties.getMetering().getWorkflowWindow();
}
/**
* Adds {@code units} to the {@code (periodStart, category)} counter (creating the row on first
* use), unless {@code opSignature} was already metered this period. No-ops for non-billable
* categories, non-positive units, or a missing period.
*/
public void accrue(
LocalDateTime periodStart, BillingCategory category, long units, String opSignature) {
if (periodStart == null
|| category == null
|| category == BillingCategory.BYPASSED
|| units <= 0) {
return;
}
if (opSignature != null && !shouldCharge(periodStart, opSignature)) {
return; // identical inputs seen within the workflow window chaining, already billed
}
incrementOrInsert(periodStart, category.name(), units);
}
/**
* True when this input set should be charged: unseen this period, or last seen outside the
* workflow window. Records a first sighting (an atomic insert-as-claim under concurrency) and
* slides the window on a repeat. Fails toward charging so a store hiccup never drops a charge.
*/
private boolean shouldCharge(LocalDateTime periodStart, String opSignature) {
LocalDateTime now = LocalDateTime.now();
MeteredInputSignature seen =
signatureRepo.findByPeriodStartAndSignature(periodStart, opSignature).orElse(null);
if (seen == null) {
try {
signatureRepo.saveAndFlush(
new MeteredInputSignature(periodStart, opSignature, now));
return true; // first sighting this period
} catch (DataIntegrityViolationException raced) {
return false; // a concurrent op just claimed it within window chaining
} catch (RuntimeException e) {
log.debug("Signature claim failed for {}: {}", periodStart, e.getMessage());
return true;
}
}
LocalDateTime last = seen.getLastMeteredAt() != null ? seen.getLastMeteredAt() : now;
boolean withinWindow = last.isAfter(now.minus(workflowWindow));
try {
seen.touch(now);
signatureRepo.save(seen);
} catch (RuntimeException e) {
log.debug("Signature touch failed for {}: {}", periodStart, e.getMessage());
}
return !withinWindow;
}
private void incrementOrInsert(LocalDateTime periodStart, String category, long units) {
LocalDateTime now = LocalDateTime.now();
try {
if (repo.increment(periodStart, category, units, now) > 0) {
return;
}
try {
repo.saveAndFlush(new UsageCounter(periodStart, category, units, now));
} catch (DataIntegrityViolationException raceLostInsert) {
// A concurrent request inserted the row first increment the now-existing row.
repo.increment(periodStart, category, units, now);
}
} catch (RuntimeException e) {
// Metering must never break the request it rode in on; a lost accrual self-heals on the
// next increment and the daily sync reports the cumulative total either way.
log.debug("Usage accrual failed for {}/{}: {}", periodStart, category, e.getMessage());
}
}
}
@@ -0,0 +1,189 @@
package stirling.software.proprietary.accountlink;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.EnumMap;
import java.util.List;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.FixedDelayTask;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
* usage to SaaS, which bills the delta against its own last-seen totals.
*
* <p>Resilience: the sync seq is persisted before the report so it never regresses across
* restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
* usage rolls into the next sync; and reporting the same cumulative twice bills nothing. All
* periods with unsynced usage are reported so nothing is stranded when the period rolls over
* between syncs.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(
name = "stirling.billing.account-link.metering.enabled",
havingValue = "true")
public class UsageSyncService implements SchedulingConfigurer {
// First run waits out startup churn; then every interval.
private static final Duration INITIAL_DELAY = Duration.ofMinutes(5);
private final UsageCounterRepository counters;
private final AccountLinkSyncStateRepository syncState;
private final DeviceCredentialStore credentialStore;
private final AccountLinkClient client;
private final EntitlementCache entitlementCache;
private final AccountLinkProperties properties;
public UsageSyncService(
UsageCounterRepository counters,
AccountLinkSyncStateRepository syncState,
DeviceCredentialStore credentialStore,
AccountLinkClient client,
EntitlementCache entitlementCache,
AccountLinkProperties properties) {
this.counters = counters;
this.syncState = syncState;
this.credentialStore = credentialStore;
this.client = client;
this.entitlementCache = entitlementCache;
this.properties = properties;
}
/**
* Registers the daily sync, binding the interval from {@code metering.sync-interval-hours} in
* code rather than a {@code @Scheduled} SpEL string so a bad interval fails at boot/test rather
* than only on a flags-on run.
*/
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
Duration interval = Duration.ofHours(properties.getMetering().getSyncIntervalHours());
registrar.addFixedDelayTask(
new FixedDelayTask(this::scheduledSync, interval, INITIAL_DELAY));
}
public void scheduledSync() {
try {
syncNow();
} catch (RuntimeException e) {
log.debug("Scheduled usage sync failed", e);
}
}
/**
* Reports every period with unsynced usage and refreshes the cached entitlement from the reply.
* Single daily caller (non-reentrant {@code fixedDelay}), so no internal locking. No-op when
* unlinked or when nothing is pending.
*/
public void syncNow() {
Optional<DeviceCredential> cred = credentialStore.get();
if (cred.isEmpty()) {
return; // not linked
}
List<LocalDateTime> periods = counters.findPeriodsWithUnsyncedUsage();
if (periods.isEmpty()) {
// Nothing to report, but a sync is also our cue to pick up an out-of-band entitlement
// change (e.g. the admin just subscribed) that otherwise wouldn't surface until the
// cache TTL lapses. Force an immediate refresh so the gate reflects the new plan now.
entitlementCache.invalidate();
entitlementCache.current();
return;
}
InstanceEntitlement latest = null;
try {
for (LocalDateTime period : periods) {
InstanceEntitlement fresh = syncPeriod(cred.get(), period);
if (fresh != null) {
latest = fresh;
}
}
} catch (AccountLinkClient.RevokedException e) {
// Authoritative deny stop reporting; the entitlement cache blocks billable work on
// its
// own next refresh, so we don't synthesise the blocked state here.
log.info(
"Usage sync denied (HTTP {}); credential revoked/invalid — gate blocks on next"
+ " refresh",
e.status());
return;
}
// Adopt the freshest entitlement the sync returned, saving the cache a redundant fetch.
entitlementCache.accept(latest);
}
/** Reports one period; returns the fresh entitlement, or null on a transport/server failure. */
private InstanceEntitlement syncPeriod(DeviceCredential cred, LocalDateTime period) {
EnumMap<BillingCategory, Long> cumulative = new EnumMap<>(BillingCategory.class);
for (UsageCounter c : counters.findByPeriodStart(period)) {
BillingCategory cat = c.billingCategory();
if (cat != null && cat != BillingCategory.BYPASSED) {
cumulative.merge(cat, c.getCumulativeUnits(), Long::sum);
}
}
AccountLinkSyncState state = loadState();
long seq = reserveNextSeq(state);
InstanceEntitlement fresh =
client.reportUsage(
cred.getDeviceId(),
cred.getDeviceSecret(),
seq,
period,
cumulative.getOrDefault(BillingCategory.API, 0L),
cumulative.getOrDefault(BillingCategory.AI, 0L),
cumulative.getOrDefault(BillingCategory.AUTOMATION, 0L));
if (fresh == null) {
// Transport/server failure: leave the synced markers untouched. The burned seq is
// harmless (seqs need only be monotonic) and the delta bills on the next successful
// sync.
return null;
}
recordSuccess(period, cumulative, state);
return fresh;
}
/** Reserves and persists the next strictly-increasing sequence before the report goes out. */
private long reserveNextSeq(AccountLinkSyncState state) {
long next = state.getLastSyncSeq() + 1;
state.setLastSyncSeq(next);
syncState.save(state);
return next;
}
/**
* Advances the per-category synced markers to the reported totals + stamps the success time.
*/
private void recordSuccess(
LocalDateTime period,
EnumMap<BillingCategory, Long> cumulative,
AccountLinkSyncState state) {
cumulative.forEach(
(category, units) -> {
if (units > 0) {
counters.markSynced(period, category.name(), units);
}
});
state.setLastSuccessAt(LocalDateTime.now());
syncState.save(state);
}
private AccountLinkSyncState loadState() {
return syncState
.findById(AccountLinkSyncState.SINGLETON_ID)
.orElseGet(
() -> {
AccountLinkSyncState s = new AccountLinkSyncState();
s.setId(AccountLinkSyncState.SINGLETON_ID);
return s;
});
}
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.billing;
/**
* The billing / analytics axis for a metered operation. PAYG runs on a single flat-priced meter, so
* category is metadata only and never affects price.
*
* <p>Classification precedence is {@code AUTOMATION AI API BYPASSED} (see {@link
* BillingCategoryClassifier}); {@link #BYPASSED} is a manual interactive tool call that is never
* billed.
*
* <p>Mirrors the value set of the SaaS {@code payg.model.BillingCategory}. A linked self-hosted
* instance reports usage per category to SaaS as the lower-case names ({@code api} / {@code ai} /
* {@code automation}) in the daily sync, and SaaS maps them back so the two enums must keep the
* same names. (We deliberately do not share one enum across the modules: that would drag the SaaS
* billing enum through ~20 hot-path files for what is JSON-string metadata on the wire.)
*/
public enum BillingCategory {
BYPASSED,
API,
AI,
AUTOMATION
}
@@ -0,0 +1,29 @@
package stirling.software.proprietary.billing;
/**
* Pure precedence for bucketing a request into a {@link BillingCategory}, so the SaaS engine and a
* linked self-hosted instance classify identically. Each backend resolves the three signals from
* its own types the automation marker header; an AI-surface signal (a {@code @RequiresFeature}
* annotation / route on SaaS, a path prefix on the instance); API-key authentication and this
* applies the order {@code AUTOMATION AI API BYPASSED}.
*
* <p>An AI tool dispatched inside a pipeline / workflow therefore bills as {@code AUTOMATION} (the
* automation header dominates), while a direct call to it bills as {@code AI}.
*/
public final class BillingCategoryClassifier {
private BillingCategoryClassifier() {}
public static BillingCategory classify(boolean automation, boolean ai, boolean apiKey) {
if (automation) {
return BillingCategory.AUTOMATION;
}
if (ai) {
return BillingCategory.AI;
}
if (apiKey) {
return BillingCategory.API;
}
return BillingCategory.BYPASSED;
}
}

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