Compare commits

...
Author SHA1 Message Date
ConnorYoh 67ee091ca9 Add procurement storybook preview: sb-modal-po 2026-06-30 16:09:39 +01:00
ConnorYoh 997d30e9d1 Add procurement storybook preview: sb-modal-sign 2026-06-30 16:09:38 +01:00
ConnorYoh 025a7b2152 Add procurement storybook preview: sb-locked 2026-06-30 16:09:36 +01:00
ConnorYoh c737447afa Add procurement storybook preview: sb-ledger 2026-06-30 16:09:35 +01:00
ConnorYoh b6f78f7a04 Add procurement storybook preview: sb-journey-live 2026-06-30 16:09:33 +01:00
ConnorYoh 71d8e1b0e3 Add procurement storybook preview: sb-journey-trial 2026-06-30 16:09:32 +01: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
394 changed files with 19632 additions and 4787 deletions
@@ -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
+6
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
@@ -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 }}
+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:
+39 -10
View File
@@ -295,17 +295,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 +322,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 +331,45 @@ 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: 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
- task: typecheck:_run
vars: { 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: shared/tsconfig.json }
typecheck:all:
desc: "Typecheck all build variants"
@@ -404,10 +421,22 @@ tasks:
test:
desc: "Run tests"
cmds:
- task: test:editor
- task: test:portal
test:editor:
desc: "Run editor tests"
deps: [prepare]
cmds:
- npx vitest run --root editor
test:portal:
desc: "Run portal tests"
deps: [prepare]
cmds:
- npx vitest run --root portal
test:watch:
desc: "Run tests in watch mode"
deps: [prepare]
+4
View File
@@ -90,6 +90,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
@@ -109,6 +110,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
@@ -135,6 +137,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:proprietary
vars:
PORT: '{{.EDITOR_PORT}}'
@@ -210,6 +213,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:preview:portal:proxy
vars:
PORT: '{{.PROXY_PORT}}'
+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
@@ -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")));
}
}
@@ -85,7 +85,6 @@ public class WebMvcConfig implements WebMvcConfigurer {
"/icons/**",
"/modern-logo/**",
"/classic-logo/**",
"/robots.txt",
"/3rdPartyLicenses.json",
"/pdfjs/**",
"/pdfjs-legacy/**",
@@ -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) {}
}
@@ -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");
}
}
@@ -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
@@ -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());
}
}
@@ -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,265 @@
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 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 tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
* A").
*
* <p>Two 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.
* </ul>
*
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see
* {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS
* endpoint.
*/
@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) from the entitlement endpoint — the device credential is revoked
* or invalid. Distinct from a transport/server failure (which returns {@code null} and fails
* open): the cache must BLOCK billable work on this rather than serve a stale entitled
* snapshot. Unchecked so it propagates cleanly 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 ({@code POST
* /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is
* allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable
* or rejects the call, so the caller (local unlink) can still clear locally and log the orphan
* row for follow-up. Idempotent on SaaS (already-revoked → still 204).
*/
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;
}
}
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);
}
/** 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,88 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
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.
*
* <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;
public AccountLinkController(AccountLinkService service) {
this.service = service;
}
/** {@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();
}
}
@@ -0,0 +1,39 @@
package stirling.software.proprietary.accountlink;
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;
}
@@ -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,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,38 @@
package stirling.software.proprietary.accountlink;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.service.InternalApiClient;
/**
* Classifies a request as <b>billable</b> (AI / automation) or free (a manual tool).
*
* <p>Mirrors the saas billing categorisation at a coarse level, without depending on the saas
* module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation
* marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy
* sub-steps). Everything else — interactive manual PDF tools — is always free.
*/
public final class BillableOperationClassifier {
private static final String AI_PATH_PREFIX = "/api/v1/ai/";
private BillableOperationClassifier() {}
public static boolean isBillable(HttpServletRequest request) {
if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) {
return true;
}
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 billable.
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,124 @@
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 does not call the SaaS backend on
* every billable request. Single-slot (one instance = one linked team), TTL-based.
*
* <p>Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it
* has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has
* ever been fetched <i>and</i> the latest refresh failed (the gate treats empty as "unknown →
* allow").
*
* <p>But an AUTHORITATIVE deny (revoked/invalid credential → {@link
* AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a
* {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately
* rather than serving a stale entitled snapshot.
*/
@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 (blocking on the SaaS
// call) while concurrent callers serve the last snapshot — no thundering herd of
// synchronous round-trips on the billable hot path. Safe because the gate fails open.
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 for a full TTL instead of every billable request re-triggering a
// blocking round-trip against a dead/slow SaaS endpoint.
return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0;
}
/**
* Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but
* still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny
* (revoked credential) replaces it with a blocked snapshot so the gate stops billable work.
*/
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 (may be null) but
// stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime.
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 — credential revoked/invalid. Do NOT fail open: block immediately
// rather than serving the stale entitled snapshot until the next unlink.
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);
}
}
@@ -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,34 @@
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,
/** 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,19 @@
package stirling.software.proprietary.accountlink;
/**
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response —
* just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no
* saas types.
*
* @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})
*/
public record InstanceEntitlement(
boolean subscribed,
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
EntitlementState state) {}
@@ -0,0 +1,104 @@
package stirling.software.proprietary.accountlink;
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.
* <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 just supplies the
* live flag / linked-state / entitlement. 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;
public InstanceEntitlementGate(
AccountLinkProperties properties,
DeviceCredentialStore credentialStore,
EntitlementCache entitlementCache) {
this.properties = properties;
this.credentialStore = credentialStore;
this.entitlementCache = entitlementCache;
}
/** 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();
return decide(true, true, linked, entitlement);
}
/**
* Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown"
* (unreachable): when linked, that fails open.
*/
public static GateDecision decide(
boolean flagEnabled,
boolean billable,
boolean linked,
Optional<InstanceEntitlement> entitlement) {
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 source unreachable — never hard-block billable work on our
// inability to reach billing.
return 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)
? GateDecision.allow(GateDecision.Reason.ENTITLED)
: GateDecision.block(GateDecision.Reason.OVER_LIMIT);
}
/** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */
private static boolean entitled(InstanceEntitlement e) {
if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) {
return false;
}
if (e.subscribed()) {
// Subscribed: allowed unless a period cap is set and exceeded.
return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits();
}
// Unsubscribed: only the free pool covers billable work.
return e.freeRemainingUnits() > 0;
}
}
@@ -0,0 +1,65 @@
package stirling.software.proprietary.accountlink;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/**
* Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and
* blocks it when the instance is unlinked or over its limit; manual tools pass straight through.
*
* <p>Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code
* {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to
* activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit
* responses). Fail-open and flag-off both let the request continue.
*
* <p>Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link
* AccountLinkWebMvcConfig} never registers it, so there is no per-request cost.
*/
@Slf4j
@Component
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceEntitlementInterceptor implements HandlerInterceptor {
private final InstanceEntitlementGate gate;
public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) {
this.gate = gate;
}
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
GateDecision decision;
try {
decision = gate.evaluate(BillableOperationClassifier.isBillable(request));
} 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;
}
}
@@ -4,14 +4,16 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.SourceStore;
/**
* Authority on which filesystem locations a policy may read/write. Checked at save time and again
@@ -28,7 +30,7 @@ import stirling.software.proprietary.policy.model.Policy;
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
*/
@Component
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderAccessGuard {
public static final String FOLDER_TYPE = "folder";
@@ -36,12 +38,17 @@ public class FolderAccessGuard {
private final boolean saasActive;
private final List<Path> allowedRoots;
private final List<Path> protectedRoots;
private final SourceStore sourceStore;
public FolderAccessGuard(ApplicationProperties applicationProperties, Environment environment) {
public FolderAccessGuard(
ApplicationProperties applicationProperties,
Environment environment,
SourceStore sourceStore) {
this.saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas");
this.allowedRoots =
normalizeAll(applicationProperties.getPolicies().getAllowedFolderRoots());
this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath())));
this.sourceStore = sourceStore;
}
/** Returns the normalised absolute path; throws if not permitted. */
@@ -72,7 +79,10 @@ public class FolderAccessGuard {
/** Whether this policy touches a folder source/sink, and so is subject to these rules. */
public boolean usesFolderAccess(Policy policy) {
boolean readsFolder =
policy.sources().stream().anyMatch(spec -> FOLDER_TYPE.equals(spec.type()));
policy.sourceIds().stream()
.map(sourceStore::get)
.flatMap(Optional::stream)
.anyMatch(source -> FOLDER_TYPE.equals(source.type()));
boolean writesFolder =
policy.output() != null && FOLDER_TYPE.equals(policy.output().type());
return readsFolder || writesFolder;
@@ -3,7 +3,7 @@ package stirling.software.proprietary.policy.config;
import java.util.List;
import java.util.Objects;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Policies are scoped to a team: a user may view, run, edit, and delete only the policies belonging
@@ -22,7 +23,7 @@ import stirling.software.proprietary.policy.model.Policy;
*/
@Component
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyAccessGuard {
private final UserServiceInterface userService;
@@ -47,13 +48,16 @@ public class PolicyAccessGuard {
return Objects.equals(policy.teamId(), policyManagementAuthority.currentUserTeamId());
}
/** The subset of {@code policies} scoped to the current user's team. */
public List<Policy> visible(List<Policy> policies) {
/**
* The policies visible to the caller: their whole team's, loaded scoped rather than fetched
* globally and filtered, so on SaaS it never pulls another team's policies into memory. Login
* disabled (single-user) returns everything.
*/
public List<Policy> visibleFrom(PolicyStore store) {
if (!enforced()) {
return policies;
return store.all();
}
Long teamId = policyManagementAuthority.currentUserTeamId();
return policies.stream().filter(policy -> Objects.equals(policy.teamId(), teamId)).toList();
return store.findByTeam(policyManagementAuthority.currentUserTeamId());
}
private boolean enforced() {
@@ -6,7 +6,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
@@ -53,7 +53,10 @@ import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.model.PolicyRunView;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
/**
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
@@ -65,15 +68,18 @@ import stirling.software.proprietary.policy.store.PolicyStore;
@Hidden
@RequiredArgsConstructor
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyController {
private final PolicyRunner policyRunner;
private final PolicyRunRegistry runRegistry;
private final PolicyStore policyStore;
private final SourceStore sourceStore;
private final SourceAccessGuard sourceAccessGuard;
private final PolicyValidator policyValidator;
private final PolicyAccessGuard policyAccessGuard;
private final PolicyManagementAuthority policyManagementAuthority;
private final PolicyTriggerManager policyTriggerManager;
private final ApplicationProperties applicationProperties;
private final TempFileManager tempFileManager;
private final JobOwnershipService jobOwnershipService;
@@ -187,12 +193,33 @@ public class PolicyController {
public ResponseEntity<Policy> savePolicy(@RequestBody Policy policy) {
requirePolicyEditingAllowed();
Policy owned = resolveOwnership(policy);
requireAccessibleSources(owned);
try {
policyValidator.validate(owned);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
return ResponseEntity.ok(policyStore.save(owned));
Policy saved = policyStore.save(owned);
// Re-sync trigger registrations now so a new/changed folder-watch policy starts being
// watched immediately instead of after the next reconcile sweep.
policyTriggerManager.notifyPoliciesChanged();
return ResponseEntity.ok(saved);
}
/**
* Every {@code sourceId} a policy references must resolve to a source in the caller's team, so
* a client can neither reference a non-existent source nor reach across teams to use another
* team's connection. A bad reference is a client error.
*/
private void requireAccessibleSources(Policy policy) {
for (String sourceId : policy.sourceIds()) {
boolean accessible =
sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).isPresent();
if (!accessible) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Unknown or inaccessible source: " + sourceId);
}
}
}
/**
@@ -225,7 +252,7 @@ public class PolicyController {
owner,
policy.enabled(),
policy.trigger(),
policy.sources(),
policy.sourceIds(),
policy.steps(),
policy.output(),
teamId);
@@ -257,7 +284,7 @@ public class PolicyController {
summary = "List policies",
description = "Lists the policies belonging to the caller's team.")
public List<Policy> listPolicies() {
return policyAccessGuard.visible(policyStore.all());
return policyAccessGuard.visibleFrom(policyStore);
}
@GetMapping("/{policyId}")
@@ -278,6 +305,9 @@ public class PolicyController {
boolean accessible =
policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent();
if (accessible && policyStore.delete(policyId)) {
// Cancel any now-orphaned folder watch promptly rather than leaving the WatchKey open
// until the next reconcile sweep.
policyTriggerManager.notifyPoliciesChanged();
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
@@ -9,7 +9,7 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import org.slf4j.MDC;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -54,7 +54,7 @@ import stirling.software.proprietary.service.DownstreamEntitlementError;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyEngine {
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
@@ -9,7 +9,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
@@ -29,7 +29,7 @@ import stirling.software.proprietary.policy.model.PolicyRun;
*/
@Slf4j
@Service
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyRunRegistry {
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();
@@ -4,7 +4,7 @@ import java.io.IOException;
import java.util.List;
import java.util.function.Consumer;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -19,33 +19,51 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
/**
* Turns a policy's configured {@link InputSpec sources} into runs. Triggers decide <em>when</em>
* and call {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points.
* Turns a policy's referenced sources into runs: each {@code sourceId} is resolved live to its
* persisted {@link Source}, then to an {@link InputSpec}. Triggers decide <em>when</em> and call
* {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyRunner {
private final PolicyEngine policyEngine;
private final List<InputSource> inputSources;
private final SourceStore sourceStore;
/**
* Trigger entry point. Pulls every configured source; each yielded unit becomes its own run so
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
* one failure does not affect the others. No sources means one run with no input (generator
* pipeline).
* pipeline). Missing or disabled sources are skipped so one broken reference does not stop the
* rest.
*/
public void run(Policy policy) {
List<InputSpec> sources = policy.sources();
if (sources.isEmpty()) {
List<String> sourceIds = policy.sourceIds();
if (sourceIds.isEmpty()) {
startRun(policy, PolicyInputs.of(List.of()), unused -> {});
return;
}
for (InputSpec spec : sources) {
pullAndRun(policy, spec);
for (String sourceId : sourceIds) {
Source source = sourceStore.get(sourceId).orElse(null);
if (source == null) {
log.warn("Policy {} references missing source {}; skipping", policy.id(), sourceId);
continue;
}
if (!source.enabled()) {
log.debug(
"Source {} ({}) is disabled; skipping for policy {}",
sourceId,
source.name(),
policy.id());
continue;
}
pullAndRun(policy, source.toInputSpec());
}
}
@@ -2,7 +2,7 @@ package stirling.software.proprietary.policy.engine;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -13,31 +13,44 @@ import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.output.PolicyOutputSink;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
/**
* Validates a policy at save time by delegating each facet (trigger, sources, output) to the bean
* that handles its type, so a misconfiguration fails fast rather than at run time. A null trigger
* is a manual-only policy and skips trigger validation.
* is a manual-only policy and skips trigger validation. Each referenced {@code sourceId} must
* resolve to a persisted {@link Source} whose config its {@link InputSource} bean accepts.
*/
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyValidator {
private final List<PolicyTrigger> triggers;
private final List<InputSource> inputSources;
private final List<PolicyOutputSink> outputSinks;
private final SourceStore sourceStore;
/**
* @throws IllegalArgumentException if any facet's type is unknown or its config is invalid
* @throws IllegalArgumentException if any facet's type is unknown, a referenced source does not
* exist, or any config is invalid
*/
public void validate(Policy policy) {
if (policy.trigger() != null) {
triggerFor(policy.trigger()).validate(policy);
}
for (InputSpec source : policy.sources()) {
inputSourceFor(source).validate(source);
for (String sourceId : policy.sourceIds()) {
Source source =
sourceStore
.get(sourceId)
.orElseThrow(
() ->
new IllegalArgumentException(
"unknown source: " + sourceId));
InputSpec spec = source.toInputSpec();
inputSourceFor(spec).validate(spec);
}
outputSinkFor(policy.output()).validate(policy.output());
}
@@ -9,7 +9,7 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@@ -34,7 +34,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderInputSource implements InputSource {
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -6,8 +6,9 @@ import java.util.List;
* A stored automation: ordered tool steps, input sources, and an output destination.
*
* <p>Always runnable on demand. An optional {@link TriggerConfig} fires it automatically; a {@code
* null} trigger means manual-only. Trigger decides when, {@link InputSpec sources} decide where
* files come from; a run pulls from every source.
* null} trigger means manual-only. Trigger decides when; {@code sourceIds} reference the persisted
* {@code Source} connections (resolved live at run time) that decide where files come from; a run
* pulls from every referenced source.
*/
public record Policy(
String id,
@@ -15,13 +16,13 @@ public record Policy(
String owner,
boolean enabled,
TriggerConfig trigger,
List<InputSpec> sources,
List<String> sourceIds,
List<PipelineStep> steps,
OutputSpec output,
Long teamId) {
public Policy {
sources = sources == null ? List.of() : List.copyOf(sources);
sourceIds = sourceIds == null ? List.of() : List.copyOf(sourceIds);
steps = steps == null ? List.of() : steps;
output = output == null ? OutputSpec.inline() : output;
}
@@ -36,10 +37,10 @@ public record Policy(
String owner,
boolean enabled,
TriggerConfig trigger,
List<InputSpec> sources,
List<String> sourceIds,
List<PipelineStep> steps,
OutputSpec output) {
this(id, name, owner, enabled, trigger, sources, steps, output, null);
this(id, name, owner, enabled, trigger, sourceIds, steps, output, null);
}
/** A policy with no configured sources (a generator, or files supplied directly to a run). */
@@ -9,7 +9,7 @@ import java.util.List;
import java.util.UUID;
import org.apache.commons.io.FilenameUtils;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -31,7 +31,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderOutputSink implements PolicyOutputSink {
static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -5,7 +5,7 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -23,7 +23,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
*/
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class InlineOutputSink implements PolicyOutputSink {
private static final String TYPE = "inline";
@@ -0,0 +1,58 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory {@link SourceStore} for tests and any future no-database mode. {@link JpaSourceStore}
* is the runtime bean.
*/
public class InProcessSourceStore implements SourceStore {
private final Map<String, Source> sources = new ConcurrentHashMap<>();
@Override
public Source save(Source source) {
String id =
source.id() == null || source.id().isBlank()
? UUID.randomUUID().toString()
: source.id();
Source stored =
new Source(
id,
source.name(),
source.type(),
source.options(),
source.enabled(),
source.owner(),
source.teamId());
sources.put(id, stored);
return stored;
}
@Override
public Optional<Source> get(String id) {
return Optional.ofNullable(sources.get(id));
}
@Override
public List<Source> all() {
return List.copyOf(sources.values());
}
@Override
public List<Source> findByTeam(Long teamId) {
return sources.values().stream()
.filter(source -> Objects.equals(source.teamId(), teamId))
.toList();
}
@Override
public boolean delete(String id) {
return sources.remove(id) != null;
}
}
@@ -0,0 +1,81 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import tools.jackson.databind.ObjectMapper;
/**
* Durable {@link SourceStore} backed by JPA; the runtime store. Sources are persisted as JSON via
* {@link SourceEntity}, with scalar columns kept in sync for querying.
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaSourceStore implements SourceStore {
private final SourceRepository repository;
private final ObjectMapper objectMapper;
@Override
public Source save(Source source) {
String id =
source.id() == null || source.id().isBlank()
? UUID.randomUUID().toString()
: source.id();
Source stored =
new Source(
id,
source.name(),
source.type(),
source.options(),
source.enabled(),
source.owner(),
source.teamId());
SourceEntity entity = new SourceEntity();
entity.setId(id);
entity.setName(stored.name());
entity.setType(stored.type());
entity.setOwner(stored.owner());
entity.setTeamId(stored.teamId());
entity.setEnabled(stored.enabled());
entity.setSourceJson(objectMapper.writeValueAsString(stored));
repository.save(entity);
return stored;
}
@Override
public Optional<Source> get(String id) {
return repository.findById(id).map(this::toSource);
}
@Override
public List<Source> all() {
return repository.findAll().stream().map(this::toSource).toList();
}
@Override
public List<Source> findByTeam(Long teamId) {
return repository.findByTeam(teamId).stream().map(this::toSource).toList();
}
@Override
public boolean delete(String id) {
if (!repository.existsById(id)) {
return false;
}
repository.deleteById(id);
return true;
}
private Source toSource(SourceEntity entity) {
return objectMapper.readValue(entity.getSourceJson(), Source.class);
}
}
@@ -0,0 +1,34 @@
package stirling.software.proprietary.policy.source;
import java.util.Map;
import stirling.software.proprietary.policy.model.InputSpec;
/**
* A persisted, reusable input connection: the instantiation of a source definition. Policies
* reference sources by {@code id} rather than embedding their config, so one connection is
* configured once and can feed many policies.
*
* <p>{@code type} keys an {@link stirling.software.proprietary.policy.input.InputSource} bean,
* matching {@link InputSpec#type()}; {@code options} is that source's config. {@code owner} and
* {@code teamId} scope the source to a team, mirroring {@link
* stirling.software.proprietary.policy.model.Policy}.
*/
public record Source(
String id,
String name,
String type,
Map<String, Object> options,
boolean enabled,
String owner,
Long teamId) {
public Source {
options = options == null ? Map.of() : options;
}
/** The runtime form the policy engine resolves and runs against. */
public InputSpec toInputSpec() {
return new InputSpec(type, options);
}
}
@@ -0,0 +1,63 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Objects;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Sources are scoped to a team exactly like policies: a user may view, edit, and delete only the
* sources belonging to their own team (the team a source is stamped with at creation). Enforced
* only when login is enabled; single-user deployments (login disabled) pass every check. Mirrors
* {@link stirling.software.proprietary.policy.config.PolicyAccessGuard}.
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceAccessGuard {
private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties;
private final PolicyManagementAuthority policyManagementAuthority;
/** Owner for a new source: the current user, or {@code null} when login is disabled. */
public String ownerForNewSource() {
return enforced() ? userService.getCurrentUsername() : null;
}
/** Team a new source is stamped with: the creator's team. {@code null} when login disabled. */
public Long teamForNewSource() {
return enforced() ? policyManagementAuthority.currentUserTeamId() : null;
}
/** Whether the source belongs to the current user's team (so they may view/edit it). */
public boolean canAccess(Source source) {
if (!enforced()) {
return true;
}
return Objects.equals(source.teamId(), policyManagementAuthority.currentUserTeamId());
}
/**
* The sources visible to the caller: their whole team's, loaded scoped rather than fetched
* globally and filtered, so on SaaS it never pulls another team's sources into memory. Login
* disabled (single-user) returns everything.
*/
public List<Source> visibleFrom(SourceStore store) {
if (!enforced()) {
return store.all();
}
return store.findByTeam(policyManagementAuthority.currentUserTeamId());
}
private boolean enforced() {
return applicationProperties.getSecurity().isEnableLogin();
}
}
@@ -0,0 +1,191 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
/**
* CRUD for persisted, reusable input connections plus the Sources overview for the admin portal. A
* source is configured once here and referenced by id from any number of policies; the overview
* reports how many reference each one. Editing follows the same team-leader rule as policies, and
* everything is scoped to the caller's team.
*/
@RestController
@RequestMapping("/api/v1/sources")
@Hidden
@RequiredArgsConstructor
@Tag(name = "Sources", description = "Reusable policy input connections")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceController {
private final SourceStore sourceStore;
private final SourceAccessGuard sourceAccessGuard;
private final SourceOverviewService overviewService;
private final PolicyStore policyStore;
private final PolicyAccessGuard policyAccessGuard;
private final PolicyManagementAuthority policyManagementAuthority;
private final PolicyTriggerManager policyTriggerManager;
private final ApplicationProperties applicationProperties;
private final List<InputSource> inputSources;
@GetMapping
@Operation(
summary = "Sources overview",
description =
"Returns the KPI strip plus one row per source the caller's team owns, each with"
+ " how many policies reference it and which.")
public SourcesResponse list() {
return overviewService.overview();
}
@GetMapping("/{sourceId}")
@Operation(summary = "Get a source by id")
public ResponseEntity<Source> get(@PathVariable String sourceId) {
return sourceStore
.get(sourceId)
.filter(sourceAccessGuard::canAccess)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Create or update a source",
description =
"Stores an input connection (type + config). A blank id is assigned; owner and"
+ " team are stamped server-side. The config is validated against the"
+ " matching source type.")
public ResponseEntity<Source> save(@RequestBody Source source) {
requireSourceEditingAllowed();
Source owned = resolveOwnership(source);
try {
validateConfig(owned);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
Source saved = sourceStore.save(owned);
// An edited folder source can change which directory needs watching, so re-sync trigger
// registrations now instead of waiting for the next reconcile.
policyTriggerManager.notifyPoliciesChanged();
return ResponseEntity.ok(saved);
}
@DeleteMapping("/{sourceId}")
@Operation(
summary = "Delete a source",
description =
"Removes a source that no policy references. A source still in use returns 409"
+ " so the connection can't be pulled out from under a live policy.")
public ResponseEntity<Void> delete(@PathVariable String sourceId) {
requireSourceEditingAllowed();
Source source = sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).orElse(null);
if (source == null) {
return ResponseEntity.notFound().build();
}
List<String> referencing = referencingPolicyNames(sourceId);
if (!referencing.isEmpty()) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"Source is referenced by "
+ referencing.size()
+ " policy(ies): "
+ String.join(", ", referencing));
}
sourceStore.delete(sourceId);
return ResponseEntity.noContent().build();
}
/**
* Stamp owner + team server-side. Create stamps the current user and their team; update
* preserves the existing owner and team after verifying the source belongs to the caller's
* team, so the client can neither forge ownership on create nor reach across teams on update (a
* source in another team reads as not-found).
*/
private Source resolveOwnership(Source incoming) {
String id = incoming.id();
if (id != null && !id.isBlank()) {
Source existing = sourceStore.get(id).orElse(null);
if (existing != null) {
if (!sourceAccessGuard.canAccess(existing)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No source: " + id);
}
return withOwnerAndTeam(incoming, existing.owner(), existing.teamId());
}
}
return withOwnerAndTeam(
incoming,
sourceAccessGuard.ownerForNewSource(),
sourceAccessGuard.teamForNewSource());
}
private static Source withOwnerAndTeam(Source source, String owner, Long teamId) {
return new Source(
source.id(),
source.name(),
source.type(),
source.options(),
source.enabled(),
owner,
teamId);
}
/** Validate the config against the bean that handles the source's type, as the engine will. */
private void validateConfig(Source source) {
InputSpec spec = source.toInputSpec();
inputSources.stream()
.filter(inputSource -> inputSource.supports(spec))
.findFirst()
.orElseThrow(
() -> new IllegalArgumentException("unknown source type: " + source.type()))
.validate(spec);
}
/**
* Editing sources requires the editor role for the caller's team (a team leader on SaaS), the
* same rule as policies. Single-user deployments (login disabled) trust the local operator.
*/
private void requireSourceEditingAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Sources may only be created or modified by a team leader");
}
}
/** Names of the caller's visible policies that reference the given source. */
private List<String> referencingPolicyNames(String sourceId) {
return policyAccessGuard.visibleFrom(policyStore).stream()
.filter(policy -> policy.sourceIds().contains(sourceId))
.map(Policy::name)
.toList();
}
}
@@ -0,0 +1,50 @@
package stirling.software.proprietary.policy.source;
import java.io.Serializable;
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;
/**
* JPA row for a {@link Source}. The whole source lives as JSON in {@code sourceJson} (authoritative
* on read); the scalar columns are denormalized copies for querying. {@code owner} and {@code
* teamId} are plain values, not foreign keys, to stay decoupled from the security entities -
* matching {@link stirling.software.proprietary.policy.store.PolicyEntity}.
*/
@Entity
@Table(name = "policy_sources")
@NoArgsConstructor
@Getter
@Setter
public class SourceEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "id")
private String id;
@Column(name = "name")
private String name;
@Column(name = "type")
private String type;
@Column(name = "owner")
private String owner;
@Column(name = "team_id")
private Long teamId;
@Column(name = "enabled")
private boolean enabled;
@Column(name = "source_json", columnDefinition = "text")
private String sourceJson;
}
@@ -0,0 +1,4 @@
package stirling.software.proprietary.policy.source;
/** One headline figure in the Sources overview strip. */
public record SourceKpi(long value, String description) {}
@@ -0,0 +1,118 @@
package stirling.software.proprietary.policy.source;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Builds the Sources overview: every persisted source the caller's team owns, shown exactly once,
* each annotated with the policies that reference it. Reference counts are derived by scanning the
* team's policies in memory rather than persisted on the source - fine at admin-dashboard scale and
* always consistent with the live policy set.
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceOverviewService {
private final SourceStore sourceStore;
private final PolicyStore policyStore;
private final SourceAccessGuard sourceAccessGuard;
private final PolicyAccessGuard policyAccessGuard;
public SourcesResponse overview() {
List<Source> sources = sourceAccessGuard.visibleFrom(sourceStore);
List<Policy> policies = policyAccessGuard.visibleFrom(policyStore);
Map<String, List<Policy>> referencesBySource = referencesBySource(policies);
List<SourceView> views =
sources.stream()
.map(
source ->
toView(
source,
referencesBySource.getOrDefault(
source.id(), List.of())))
.sorted(
Comparator.comparingInt(SourceView::referenceCount)
.reversed()
.thenComparing(SourceView::name))
.toList();
return new SourcesResponse(buildKpis(views), views);
}
/** Policies referencing each source id, across the caller's visible policies. */
private static Map<String, List<Policy>> referencesBySource(List<Policy> policies) {
Map<String, List<Policy>> bySource = new HashMap<>();
for (Policy policy : policies) {
for (String sourceId : policy.sourceIds()) {
bySource.computeIfAbsent(sourceId, key -> new ArrayList<>()).add(policy);
}
}
return bySource;
}
private static SourceView toView(Source source, List<Policy> referencingPolicies) {
List<SourceView.PolicyRef> refs =
referencingPolicies.stream()
.map(policy -> new SourceView.PolicyRef(policy.id(), policy.name()))
.toList();
return new SourceView(
source.id(),
source.name(),
source.type(),
deriveStatus(source, refs.size()),
refs.size(),
refs,
configRows(source),
null);
}
/** A disabled (paused) source reads as "disabled"; an unreferenced one reads as "unused". */
private static String deriveStatus(Source source, int referenceCount) {
if (!source.enabled()) {
return "disabled";
}
return referenceCount == 0 ? "unused" : "active";
}
/** Generic key/value view of the source's config - works for any source type. */
private static List<SourceView.DetailRow> configRows(Source source) {
return source.options().entrySet().stream()
.map(
entry ->
new SourceView.DetailRow(
humanize(entry.getKey()), String.valueOf(entry.getValue())))
.toList();
}
private static String humanize(String key) {
if (key == null || key.isBlank()) {
return key;
}
return Character.toUpperCase(key.charAt(0)) + key.substring(1);
}
private static List<SourceKpi> buildKpis(List<SourceView> sources) {
long total = sources.size();
long inUse = sources.stream().filter(source -> source.referenceCount() > 0).count();
long orphaned = total - inUse;
return List.of(
new SourceKpi(total, "connections"),
new SourceKpi(inUse, "referenced by a policy"),
new SourceKpi(orphaned, "unused"));
}
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface SourceRepository extends JpaRepository<SourceEntity, String> {
/**
* Sources belonging to a team, loaded without scanning every team's rows. A {@code null} teamId
* matches the rows with no team (login-disabled / pre-team data), mirroring the in-memory team
* filter rather than the empty result a plain {@code = null} would give.
*/
@Query(
"select s from SourceEntity s where (:teamId is null and s.teamId is null) or"
+ " s.teamId = :teamId")
List<SourceEntity> findByTeam(@Param("teamId") Long teamId);
}
@@ -0,0 +1,21 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Optional;
/** Stores {@link Source} definitions (persisted, reusable input connections). */
public interface SourceStore {
/** Create or update; a blank/absent id is assigned. Returns the stored source. */
Source save(Source source);
Optional<Source> get(String id);
List<Source> all();
/** Sources owned by the given team, loaded scoped rather than fetched globally. */
List<Source> findByTeam(Long teamId);
/** Returns whether the source existed. */
boolean delete(String id);
}
@@ -0,0 +1,25 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
/**
* One row in the Sources overview: a persisted input connection shown exactly once, with how many
* policies reference it (and which). {@code docsTotal} is {@code null} - per-source document volume
* is not tracked yet; the field is reserved so a later doc-accounting pass is additive.
*/
public record SourceView(
String id,
String name,
String type,
String status,
int referenceCount,
List<PolicyRef> referencingPolicies,
List<DetailRow> config,
Long docsTotal) {
/** A policy that references this source. */
public record PolicyRef(String id, String name) {}
/** A key/value line summarising the source's config for display. */
public record DetailRow(String label, String value) {}
}
@@ -0,0 +1,6 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
/** The Sources overview payload: a KPI strip plus one row per source. */
public record SourcesResponse(List<SourceKpi> kpis, List<SourceView> sources) {}
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.store;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@@ -29,7 +30,7 @@ public class InProcessPolicyStore implements PolicyStore {
policy.owner(),
policy.enabled(),
policy.trigger(),
policy.sources(),
policy.sourceIds(),
policy.steps(),
policy.output(),
policy.teamId());
@@ -47,6 +48,13 @@ public class InProcessPolicyStore implements PolicyStore {
return List.copyOf(policies.values());
}
@Override
public List<Policy> findByTeam(Long teamId) {
return policies.values().stream()
.filter(policy -> Objects.equals(policy.teamId(), teamId))
.toList();
}
@Override
public List<Policy> findByTriggerType(String triggerType) {
return policies.values().stream()
@@ -4,7 +4,7 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -19,7 +19,7 @@ import tools.jackson.databind.ObjectMapper;
*/
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaPolicyStore implements PolicyStore {
private final PolicyRepository repository;
@@ -38,7 +38,7 @@ public class JpaPolicyStore implements PolicyStore {
policy.owner(),
policy.enabled(),
policy.trigger(),
policy.sources(),
policy.sourceIds(),
policy.steps(),
policy.output(),
policy.teamId());
@@ -49,6 +49,7 @@ public class JpaPolicyStore implements PolicyStore {
entity.setOwner(stored.owner());
entity.setEnabled(stored.enabled());
entity.setTriggerType(stored.trigger() == null ? null : stored.trigger().type());
entity.setTeamId(stored.teamId());
entity.setPolicyJson(objectMapper.writeValueAsString(stored));
repository.save(entity);
return stored;
@@ -64,6 +65,11 @@ public class JpaPolicyStore implements PolicyStore {
return repository.findAll().stream().map(this::toPolicy).toList();
}
@Override
public List<Policy> findByTeam(Long teamId) {
return repository.findByTeam(teamId).stream().map(this::toPolicy).toList();
}
@Override
public List<Policy> findByTriggerType(String triggerType) {
return repository.findByTriggerTypeAndEnabledTrue(triggerType).stream()
@@ -15,8 +15,9 @@ import lombok.Setter;
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives
* as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies
* for querying, notably {@code triggerType} + {@code enabled} so background triggers can fetch
* their policies. {@code owner} is a plain string, not a foreign key, to stay decoupled from the
* security entities.
* their policies, and {@code teamId} so the caller's team can be loaded without scanning every
* team's rows. {@code owner} and {@code teamId} are plain values, not foreign keys, to stay
* decoupled from the security entities.
*/
@Entity
@Table(name = "policies")
@@ -43,6 +44,9 @@ public class PolicyEntity implements Serializable {
@Column(name = "trigger_type")
private String triggerType;
@Column(name = "team_id")
private Long teamId;
@Column(name = "policy_json", columnDefinition = "text")
private String policyJson;
}
@@ -3,6 +3,8 @@ package stirling.software.proprietary.policy.store;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
@@ -10,4 +12,14 @@ public interface PolicyRepository extends JpaRepository<PolicyEntity, String> {
/** Enabled policies of a given trigger type, for background triggers to activate. */
List<PolicyEntity> findByTriggerTypeAndEnabledTrue(String triggerType);
/**
* Policies belonging to a team, loaded without scanning every team's rows. A {@code null}
* teamId matches the rows with no team (login-disabled / pre-team data), mirroring the
* in-memory team filter rather than the empty result a plain {@code = null} would give.
*/
@Query(
"select p from PolicyEntity p where (:teamId is null and p.teamId is null) or"
+ " p.teamId = :teamId")
List<PolicyEntity> findByTeam(@Param("teamId") Long teamId);
}
@@ -15,6 +15,9 @@ public interface PolicyStore {
List<Policy> all();
/** Policies owned by the given team, loaded scoped rather than fetched globally. */
List<Policy> findByTeam(Long teamId);
/** Enabled policies with the given trigger type, for background triggers. */
List<Policy> findByTriggerType(String triggerType);
@@ -20,7 +20,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -31,6 +31,8 @@ import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
@@ -46,7 +48,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderWatchTrigger implements PolicyTrigger {
private static final String TYPE = "folder-watch";
@@ -54,6 +56,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
private final PolicyStore policyStore;
private final PolicyRunner policyRunner;
private final List<InputSource> inputSources;
private final SourceStore sourceStore;
private final ApplicationProperties applicationProperties;
private final Map<Path, WatchKey> keysByDir = new ConcurrentHashMap<>();
@@ -120,6 +123,14 @@ public class FolderWatchTrigger implements PolicyTrigger {
dirByKey.clear();
}
@Override
public void onPoliciesChanged() {
// A created/updated/deleted policy may add or drop a watched directory: register/cancel now
// instead of waiting up to watchReconcileSeconds for the next reconcile. A no-op until the
// trigger is started (watchService null), where the first reconcile picks everything up.
syncRegistrations();
}
private void watchLoop() {
// Capture once: stop() may null the field; close() still wakes take()/poll() on this local.
WatchService watcher = watchService;
@@ -269,12 +280,17 @@ public class FolderWatchTrigger implements PolicyTrigger {
// the path was configured.
private List<Path> watchDirsOf(Policy policy) {
List<Path> dirs = new ArrayList<>();
for (InputSpec spec : policy.sources()) {
InputSource source = sourceFor(spec);
for (String sourceId : policy.sourceIds()) {
Source source = sourceStore.get(sourceId).orElse(null);
if (source == null) {
continue;
}
for (Path dir : source.watchTargets(spec)) {
InputSpec spec = source.toInputSpec();
InputSource inputSource = sourceFor(spec);
if (inputSource == null) {
continue;
}
for (Path dir : inputSource.watchTargets(spec)) {
dirs.add(dir.toAbsolutePath().normalize());
}
}
@@ -20,4 +20,12 @@ public interface PolicyTrigger {
default void start() {}
default void stop() {}
/**
* React to a policy being created, updated, or deleted. A trigger that caches per-policy state
* (folder-watch tracks which directories to watch) re-syncs it now, so a new policy is acted on
* immediately and a deleted one stops at once, rather than waiting for the next periodic sweep.
* Default no-op for triggers that read the store fresh on every fire.
*/
default void onPoliciesChanged() {}
}
@@ -2,8 +2,8 @@ package stirling.software.proprietary.policy.trigger;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -13,7 +13,7 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyTriggerManager implements SmartLifecycle {
private final List<PolicyTrigger> triggers;
@@ -48,4 +48,26 @@ public class PolicyTriggerManager implements SmartLifecycle {
public boolean isRunning() {
return running;
}
/**
* Tell every trigger that the policy set changed so cached registrations refresh promptly
* instead of waiting for the next periodic reconcile. Best-effort and idempotent: a failing
* trigger is logged and the rest still run; a no-op before the subsystem has started.
*/
public void notifyPoliciesChanged() {
if (!running) {
return;
}
for (PolicyTrigger trigger : triggers) {
try {
trigger.onPoliciesChanged();
} catch (RuntimeException e) {
log.error(
"Failed to refresh trigger '{}' after policy change: {}",
trigger.type(),
e.getMessage(),
e);
}
}
}
}
@@ -10,7 +10,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -32,7 +32,7 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class ScheduleTrigger implements PolicyTrigger {
private static final String TYPE = "schedule";
@@ -32,14 +32,18 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.repository",
"stirling.software.proprietary.storage.repository",
"stirling.software.proprietary.workflow.repository",
"stirling.software.proprietary.policy.store"
"stirling.software.proprietary.policy.store",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.policy.source"
})
@EntityScan({
"stirling.software.proprietary.security.model",
"stirling.software.proprietary.model",
"stirling.software.proprietary.storage.model",
"stirling.software.proprietary.workflow.model",
"stirling.software.proprietary.policy.store"
"stirling.software.proprietary.policy.store",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.policy.source"
})
public class DatabaseConfig {
@@ -0,0 +1,74 @@
package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
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.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.LoginAgreementService;
/**
* Admin editing of the per-language login agreement markdown files
* (customFiles/disclaimer/&lt;locale&gt;.md). The enable/visibility flags are managed through the
* normal admin settings endpoints; only the live-edited text is handled here.
*/
@RestController
@RequestMapping("/api/v1/admin/login-agreement")
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
@Tag(name = "Admin Settings", description = "Login agreement text management")
@Hidden
@Slf4j
public class AdminLoginAgreementController {
private final LoginAgreementService loginAgreementService;
@GetMapping
@Operation(summary = "List locales that currently have login agreement text")
public Set<String> listLocales() {
return loginAgreementService.listLocalesWithContent();
}
@GetMapping("/{locale}")
@Operation(summary = "Read the login agreement markdown for a locale")
public ResponseEntity<Map<String, String>> read(@PathVariable String locale) {
String content = loginAgreementService.readRawForLocale(locale);
if (content == null) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(Map.of("locale", locale, "content", content));
}
@PutMapping("/{locale}")
@Operation(summary = "Write the login agreement markdown for a locale (blank clears it)")
public ResponseEntity<Void> write(
@PathVariable String locale, @RequestBody DisclaimerContentRequest request) {
try {
loginAgreementService.writeForLocale(
locale, request == null ? null : request.content());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
} catch (IOException e) {
log.error("Failed writing login agreement for locale {}", locale, e);
return ResponseEntity.internalServerError().build();
}
}
public record DisclaimerContentRequest(String content) {}
}
@@ -22,12 +22,12 @@ import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
@@ -40,22 +40,22 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
private static final String KEYSTORE_ALIAS = "stirling-pdf-server";
private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert";
@Value("${system.serverCertificate.enabled:false}")
private boolean enabled;
@Value("${system.serverCertificate.organizationName:Stirling-PDF}")
private String organizationName;
@Value("${system.serverCertificate.validity:365}")
private int validityDays;
@Value("${system.serverCertificate.regenerateOnStartup:false}")
private boolean regenerateOnStartup;
private final LicenseKeyChecker licenseKeyChecker;
public ServerCertificateService(LicenseKeyChecker licenseKeyChecker) {
public ServerCertificateService(
LicenseKeyChecker licenseKeyChecker, ApplicationProperties applicationProperties) {
this.licenseKeyChecker = licenseKeyChecker;
ApplicationProperties.System.ServerCertificate config =
applicationProperties.getSystem().getServerCertificate();
this.enabled = config.isEnabled();
this.organizationName = config.getOrganizationName();
this.validityDays = config.getValidity();
this.regenerateOnStartup = config.isRegenerateOnStartup();
}
static {
@@ -81,7 +81,7 @@ public class UserServerCertificateService {
// Certificate details with username
String username = user.getUsername();
X500Name subject = new X500Name("CN=" + username + ", O=Stirling-PDF User, C=US");
X500Name subject = new X500Name("CN=" + username + ", OU=User, O=Stirling PDF Inc, C=US");
BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis());
Date notBefore = new Date();
Date notAfter =
@@ -0,0 +1,192 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.net.ConnectException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import tools.jackson.databind.ObjectMapper;
/**
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register
* relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on
* unreachable) behaviour hold.
*/
class AccountLinkClientTest {
private AccountLinkProperties properties;
private HttpClient httpClient;
private AccountLinkClient client;
@BeforeEach
void setUp() {
properties = new AccountLinkProperties();
properties.setEnabled(true);
properties.setSaasBaseUrl("https://saas.example.com");
httpClient = mock(HttpClient.class);
client = new AccountLinkClient(properties, new ObjectMapper(), httpClient);
}
@SuppressWarnings("unchecked")
private HttpResponse<String> response(int status, String body) {
HttpResponse<String> resp = mock(HttpResponse.class);
when(resp.statusCode()).thenReturn(status);
when(resp.body()).thenReturn(body);
return resp;
}
@Test
@SuppressWarnings("unchecked")
void registerRelaysJwtAndParsesCredential() throws Exception {
// Build the stub response first: nesting response() inside when() trips Mockito's
// unfinished-stubbing check (inner when() runs mid outer when()).
HttpResponse<String> resp =
response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server");
assertEquals("dev-1", result.deviceId());
assertEquals("sec-1", result.deviceSecret());
assertEquals(42L, result.teamId());
HttpRequest sent = captor.getValue();
assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null));
assertEquals(
"https://saas.example.com/api/v1/account-link/register", sent.uri().toString());
}
@Test
@SuppressWarnings("unchecked")
void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception {
HttpResponse<String> resp = response(401, "{\"error\":\"unauthorized\"}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
AccountLinkClient.UpstreamException ex =
assertThrows(
AccountLinkClient.UpstreamException.class,
() -> client.register("jwt", null));
assertEquals(401, ex.status());
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementParsesSnapshotAndSendsDeviceHeaders() throws Exception {
HttpResponse<String> resp =
response(
200,
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":10,\"periodCapUnits\":100,\"state\":\"OK\"}");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
InstanceEntitlement e = client.fetchEntitlement("dev-1", "sec-1");
assertNotNull(e);
assertEquals(true, e.subscribed());
assertEquals(10, e.periodSpendUnits());
assertEquals(100L, e.periodCapUnits());
assertEquals(EntitlementState.OK, e.state());
HttpRequest sent = captor.getValue();
assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null));
assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null));
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementMapsOverLimitState() throws Exception {
// Pins the consume side of the wire contract: InstanceController emits "OVER_LIMIT" (for a
// DEGRADED team) and the client must map it to the gate-blocking state.
HttpResponse<String> resp =
response(
200,
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":1300,\"periodCapUnits\":1250,\"state\":\"OVER_LIMIT\"}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
InstanceEntitlement e = client.fetchEntitlement("dev-1", "sec-1");
assertNotNull(e);
assertEquals(EntitlementState.OVER_LIMIT, e.state());
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementReturnsNullWhenUnreachable() throws Exception {
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
.thenThrow(new ConnectException("refused"));
// Null = unknown → the cache/gate fail open.
assertNull(client.fetchEntitlement("dev-1", "sec-1"));
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementReturnsNullOnServerError() throws Exception {
// 5xx is a transient/server failure, not a credential deny → null, the cache fails open.
HttpResponse<String> resp = response(503, "{}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
assertNull(client.fetchEntitlement("dev-1", "sec-1"));
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementThrowsRevokedOnDeny() throws Exception {
// 401/403 = authoritative deny (revoked/invalid credential) → RevokedException, NOT null:
// the cache must block billable work rather than fail open on a stale snapshot.
for (int status : new int[] {401, 403}) {
HttpResponse<String> resp = response(status, "{}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
AccountLinkClient.RevokedException ex =
assertThrows(
AccountLinkClient.RevokedException.class,
() -> client.fetchEntitlement("dev-1", "sec-1"));
assertEquals(status, ex.status());
}
}
@Test
@SuppressWarnings("unchecked")
void revokeSelfSendsDeviceHeadersAndReturnsTrueOn2xx() throws Exception {
HttpResponse<String> resp = response(204, "");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
assertEquals(true, client.revokeSelf("dev-1", "sec-1"));
HttpRequest sent = captor.getValue();
assertEquals("https://saas.example.com/api/v1/instance/revoke-self", sent.uri().toString());
assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null));
assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null));
assertEquals("POST", sent.method());
}
@Test
@SuppressWarnings("unchecked")
void revokeSelfReturnsFalseOnErrorStatus() throws Exception {
HttpResponse<String> resp = response(403, "{}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
assertEquals(false, client.revokeSelf("dev-1", "sec-1"));
}
@Test
@SuppressWarnings("unchecked")
void revokeSelfReturnsFalseWhenUnreachable() throws Exception {
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
.thenThrow(new ConnectException("refused"));
assertEquals(false, client.revokeSelf("dev-1", "sec-1"));
}
}
@@ -0,0 +1,68 @@
package stirling.software.proprietary.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest;
/**
* The local (self-hosted) account-link controller's error mapping: an upstream auth rejection
* surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport
* faults are a 502.
*/
class AccountLinkControllerTest {
private AccountLinkService service;
private AccountLinkController controller;
@BeforeEach
void setUp() {
service = mock(AccountLinkService.class);
controller = new AccountLinkController(service);
}
@Test
void link_missingJwt_returns400() {
ResponseEntity<?> resp = controller.link(new LinkRequest(" ", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void link_upstreamUnauthorized_maps401() throws Exception {
when(service.link("jwt", null))
.thenThrow(new AccountLinkClient.UpstreamException(401, "bad token"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void link_upstreamForbidden_maps403() throws Exception {
when(service.link("jwt", null))
.thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void link_upstreamServerError_maps502() throws Exception {
when(service.link("jwt", null))
.thenThrow(new AccountLinkClient.UpstreamException(500, "boom"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
@Test
void link_transportFailure_maps502() throws Exception {
when(service.link("jwt", null)).thenThrow(new IOException("connection refused"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
}
@@ -0,0 +1,111 @@
package stirling.software.proprietary.accountlink;
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.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class AccountLinkServiceTest {
private AccountLinkClient client;
private DeviceCredentialStore store;
private EntitlementCache cache;
private AccountLinkService service;
@BeforeEach
void setUp() {
client = mock(AccountLinkClient.class);
store = mock(DeviceCredentialStore.class);
cache = mock(EntitlementCache.class);
service = new AccountLinkService(client, store, cache);
}
@Test
void link_storesCredentialAndInvalidatesCache() throws IOException {
when(client.register("jwt", "name"))
.thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L));
DeviceCredential stored = new DeviceCredential();
stored.setDeviceId("dev-1");
stored.setTeamId(7L);
stored.setLinkedAt(LocalDateTime.now());
when(store.get()).thenReturn(Optional.of(stored));
AccountLinkService.LinkStatus status = service.link("jwt", "name");
verify(store).save("dev-1", "sec-1", 7L);
verify(cache).invalidate();
assertTrue(status.linked());
assertEquals("dev-1", status.deviceId());
assertEquals(7L, status.teamId());
}
@Test
void link_propagatesRegisterFailure() throws IOException {
when(client.register(any(), any())).thenThrow(new IOException("boom"));
org.junit.jupiter.api.Assertions.assertThrows(
IOException.class, () -> service.link("jwt", null));
verify(cache, org.mockito.Mockito.never()).invalidate();
}
@Test
void status_unlinkedWhenNoCredential() {
when(store.get()).thenReturn(Optional.empty());
AccountLinkService.LinkStatus status = service.status();
assertFalse(status.linked());
}
@Test
void unlink_callsSaasRevokeBeforeClearingLocally() {
DeviceCredential cred = new DeviceCredential();
cred.setDeviceId("dev-1");
cred.setDeviceSecret("sec-1");
cred.setTeamId(7L);
cred.setLinkedAt(LocalDateTime.now());
when(store.get()).thenReturn(Optional.of(cred));
when(client.revokeSelf("dev-1", "sec-1")).thenReturn(true);
service.unlink();
verify(client).revokeSelf("dev-1", "sec-1");
verify(store).clear();
verify(cache).invalidate();
}
@Test
void unlink_clearsLocallyEvenWhenSaasRevokeFails() {
DeviceCredential cred = new DeviceCredential();
cred.setDeviceId("dev-1");
cred.setDeviceSecret("sec-1");
cred.setLinkedAt(LocalDateTime.now());
when(store.get()).thenReturn(Optional.of(cred));
// SaaS unreachable / returns non-2xx.
when(client.revokeSelf("dev-1", "sec-1")).thenReturn(false);
service.unlink();
// Local clear MUST still happen — admin's intent wins; orphan row is a follow-up.
verify(store).clear();
verify(cache).invalidate();
}
@Test
void unlink_whenAlreadyUnlinked_skipsSaasRevoke() {
when(store.get()).thenReturn(Optional.empty());
service.unlink();
org.mockito.Mockito.verifyNoInteractions(client);
verify(store).clear();
verify(cache).invalidate();
}
}
@@ -0,0 +1,49 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import stirling.software.common.service.InternalApiClient;
class BillableOperationClassifierTest {
@Test
void aiPathIsBillable() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo");
assertTrue(BillableOperationClassifier.isBillable(req));
}
@Test
void automationHeaderIsBillable() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1");
assertTrue(BillableOperationClassifier.isBillable(req));
}
@Test
void plainManualToolIsFree() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
assertFalse(BillableOperationClassifier.isBillable(req));
}
@Test
void aiSegmentNotAtPathStartIsFree() {
// Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a
// proxy prefix) must NOT classify a manual tool as billable.
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo");
assertFalse(BillableOperationClassifier.isBillable(req));
}
@Test
void aiPathUnderContextPathIsBillable() {
// A real context-path deployment still classifies: /<ctx>/api/v1/ai/** is billable.
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo");
req.setContextPath("/stirling");
assertTrue(BillableOperationClassifier.isBillable(req));
}
}
@@ -0,0 +1,114 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class EntitlementCacheTest {
private DeviceCredentialStore store;
private AccountLinkClient client;
private AccountLinkProperties properties;
private EntitlementCache cache;
@BeforeEach
void setUp() {
store = mock(DeviceCredentialStore.class);
client = mock(AccountLinkClient.class);
properties = new AccountLinkProperties();
properties.setEntitlementCacheSeconds(300);
cache = new EntitlementCache(store, client, properties);
}
private DeviceCredential cred() {
DeviceCredential c = new DeviceCredential();
c.setDeviceId("dev-1");
c.setDeviceSecret("sec-1");
c.setTeamId(1L);
c.setLinkedAt(LocalDateTime.now());
return c;
}
@Test
void unlinked_returnsEmpty() {
when(store.get()).thenReturn(Optional.empty());
assertTrue(cache.current().isEmpty());
}
@Test
void linked_fetchesAndCachesWithinTtl() {
InstanceEntitlement snap = new InstanceEntitlement(false, 10, 0, null, EntitlementState.OK);
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap);
assertEquals(snap, cache.current().orElseThrow());
// Second read within TTL must not re-fetch.
assertEquals(snap, cache.current().orElseThrow());
verify(client, times(1)).fetchEntitlement(any(), any());
}
@Test
void linked_unreachable_keepsLastKnownSnapshot_failOpenFriendly() {
InstanceEntitlement snap = new InstanceEntitlement(true, 0, 1, 100L, EntitlementState.OK);
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap);
assertEquals(snap, cache.current().orElseThrow());
// TTL elapsed → refresh attempted, but the SaaS side is now unreachable (null).
cache.invalidate();
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(null);
assertEquals(snap, cache.current().orElseThrow(), "stale snapshot retained on failure");
}
@Test
void linked_neverFetched_unreachable_backsOffWithinTtl() {
// No prior snapshot + SaaS unreachable: the gate fails open (empty), but a failed
// attempt stamps the TTL so a second read within the window does NOT re-fetch —
// no sustained hammer of blocking round-trips against a dead endpoint.
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(null);
assertTrue(cache.current().isEmpty());
assertTrue(cache.current().isEmpty());
verify(client, times(1)).fetchEntitlement(any(), any());
}
@Test
void linked_revoked_blocksAndDropsStaleEntitlement() {
InstanceEntitlement entitled =
new InstanceEntitlement(true, 0, 1, 100L, EntitlementState.OK);
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(entitled);
assertEquals(entitled, cache.current().orElseThrow());
// Credential revoked: the next refresh is an authoritative deny. The cache must NOT keep
// serving the stale entitled snapshot — it replaces it with a blocked REVOKED one.
cache.invalidate();
when(client.fetchEntitlement(anyString(), anyString()))
.thenThrow(new AccountLinkClient.RevokedException(401));
assertEquals(EntitlementState.REVOKED, cache.current().orElseThrow().state());
}
@Test
void invalidate_forcesRefetch() {
InstanceEntitlement snap = new InstanceEntitlement(false, 10, 0, null, EntitlementState.OK);
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap);
cache.current();
cache.invalidate();
cache.current();
verify(client, times(2)).fetchEntitlement(any(), any());
}
}
@@ -0,0 +1,117 @@
package stirling.software.proprietary.accountlink;
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 java.util.Optional;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.accountlink.GateDecision.Reason;
/**
* Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and
* over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is
* needed.
*/
class InstanceEntitlementGateTest {
private static InstanceEntitlement free() {
return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK);
}
private static InstanceEntitlement exhaustedUnsubscribed() {
return new InstanceEntitlement(false, 0, 0, null, EntitlementState.OVER_LIMIT);
}
private static InstanceEntitlement subscribedWithinCap() {
return new InstanceEntitlement(true, 0, 10, 100L, EntitlementState.OK);
}
private static InstanceEntitlement subscribedOverCap() {
return new InstanceEntitlement(true, 0, 100, 100L, EntitlementState.OK);
}
@Test
void flagOff_allowsEverything_evenBillableUnlinked() {
GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty());
assertTrue(d.allowed());
assertEquals(Reason.FLAG_OFF, d.reason());
}
@Test
void manualTool_alwaysFree_evenUnlinked() {
GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty());
assertTrue(d.allowed());
assertEquals(Reason.MANUAL_FREE, d.reason());
}
@Test
void billable_notLinked_blocksWithLinkSignal() {
GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty());
assertFalse(d.allowed());
assertEquals(Reason.NOT_LINKED, d.reason());
}
@Test
void billable_linked_entitlementUnreachable_failsOpen() {
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty());
assertTrue(d.allowed());
assertEquals(Reason.FAIL_OPEN, d.reason());
}
@Test
void billable_linked_freePoolAvailable_allows() {
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free()));
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@Test
void billable_linked_unsubscribedAndExhausted_blocksOverLimit() {
GateDecision d =
InstanceEntitlementGate.decide(
true, true, true, Optional.of(exhaustedUnsubscribed()));
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
@Test
void billable_linked_subscribedWithinCap_allows() {
GateDecision d =
InstanceEntitlementGate.decide(
true, true, true, Optional.of(subscribedWithinCap()));
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@Test
void billable_linked_subscribedOverCap_blocks() {
GateDecision d =
InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap()));
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
@Test
void billable_linked_revoked_blocksWithRevokedSignal() {
// Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED —
// blocks distinctly from over-limit, even though the snapshot is "present".
InstanceEntitlement revoked =
new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked));
assertFalse(d.allowed());
assertEquals(Reason.REVOKED, d.reason());
}
@Test
void billable_linked_unsubscribedWithFreePool_overLimitStateStillBlocks() {
// Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive.
InstanceEntitlement conflicting =
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT);
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting));
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
}
@@ -0,0 +1,71 @@
package stirling.software.proprietary.accountlink;
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.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/** Verifies {@link InstanceEntitlementGate#evaluate} resolves live state from store + cache. */
class InstanceEntitlementGateWiringTest {
private AccountLinkProperties properties;
private DeviceCredentialStore store;
private EntitlementCache cache;
private InstanceEntitlementGate gate;
@BeforeEach
void setUp() {
properties = new AccountLinkProperties();
properties.setEnabled(true);
store = mock(DeviceCredentialStore.class);
cache = mock(EntitlementCache.class);
gate = new InstanceEntitlementGate(properties, store, cache);
}
@Test
void manualNeverConsultsStoreOrCache() {
GateDecision d = gate.evaluate(false);
assertTrue(d.allowed());
assertEquals(GateDecision.Reason.MANUAL_FREE, d.reason());
verify(store, never()).isLinked();
verify(cache, never()).current();
}
@Test
void billableUnlinkedDoesNotHitCache() {
when(store.isLinked()).thenReturn(false);
GateDecision d = gate.evaluate(true);
assertFalse(d.allowed());
assertEquals(GateDecision.Reason.NOT_LINKED, d.reason());
verify(cache, never()).current();
}
@Test
void billableLinkedConsultsCache() {
when(store.isLinked()).thenReturn(true);
when(cache.current())
.thenReturn(
Optional.of(
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK)));
GateDecision d = gate.evaluate(true);
assertTrue(d.allowed());
assertEquals(GateDecision.Reason.ENTITLED, d.reason());
}
@Test
void flagOffShortCircuits() {
properties.setEnabled(false);
GateDecision d = gate.evaluate(true);
assertTrue(d.allowed());
assertEquals(GateDecision.Reason.FLAG_OFF, d.reason());
verify(store, never()).isLinked();
}
}
@@ -0,0 +1,61 @@
package stirling.software.proprietary.accountlink;
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.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
@ExtendWith(MockitoExtension.class)
class InstanceEntitlementInterceptorTest {
@Mock private InstanceEntitlementGate gate;
private boolean preHandle(MockHttpServletResponse response) throws Exception {
return new InstanceEntitlementInterceptor(gate)
.preHandle(
new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object());
}
@Test
void allowsWhenGateAllows() throws Exception {
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
MockHttpServletResponse response = new MockHttpServletResponse();
assertTrue(preHandle(response));
assertEquals(200, response.getStatus());
}
@Test
void blocksWith402AndLinkSignalWhenGateBlocks() throws Exception {
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.block(GateDecision.Reason.NOT_LINKED));
MockHttpServletResponse response = new MockHttpServletResponse();
assertFalse(preHandle(response));
assertEquals(HttpStatus.PAYMENT_REQUIRED.value(), response.getStatus());
assertEquals("application/json", response.getContentType());
assertTrue(response.getContentAsString().contains("ACCOUNT_LINK_REQUIRED"));
assertTrue(response.getContentAsString().contains("NOT_LINKED"));
}
@Test
void failsOpenWhenGateThrows() throws Exception {
// A DB / SaaS blip while resolving entitlement must never hard-block billable work.
when(gate.evaluate(anyBoolean()))
.thenThrow(new RuntimeException("entitlement source down"));
MockHttpServletResponse response = new MockHttpServletResponse();
assertTrue(preHandle(response));
assertEquals(200, response.getStatus());
}
}
@@ -17,6 +17,9 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
/**
* Tests for {@link FolderAccessGuard}: folder access is fail-closed, confined to the configured
@@ -26,12 +29,14 @@ class FolderAccessGuardTest {
@TempDir Path tempDir;
private final SourceStore sourceStore = new InProcessSourceStore();
private FolderAccessGuard guard(List<String> allowedRoots, String... activeProfiles) {
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowedFolderRoots(allowedRoots);
StandardEnvironment environment = new StandardEnvironment();
environment.setActiveProfiles(activeProfiles);
return new FolderAccessGuard(properties, environment);
return new FolderAccessGuard(properties, environment, sourceStore);
}
@Test
@@ -93,7 +98,23 @@ class FolderAccessGuardTest {
assertFalse(guard.usesFolderAccess(policy(List.of(), OutputSpec.inline())));
}
private static Policy policy(List<InputSpec> sources, OutputSpec output) {
return new Policy("p1", "p", "owner", true, null, sources, List.of(), output);
private Policy policy(List<InputSpec> sources, OutputSpec output) {
List<String> sourceIds =
sources.stream()
.map(
spec ->
sourceStore
.save(
new Source(
null,
"src",
spec.type(),
spec.options(),
true,
"owner",
null))
.id())
.toList();
return new Policy("p1", "p", "owner", true, null, sourceIds, List.of(), output);
}
}
@@ -17,6 +17,8 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* {@link PolicyAccessGuard}: policies are scoped to the caller's team. A user sees/accesses only
@@ -36,18 +38,27 @@ class PolicyAccessGuardTest {
}
@Test
void visibleFiltersToTheCallersTeam() {
void visibleFromLoadsOnlyTheCallersTeam() {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L);
List<Policy> all = List.of(inTeam(1L), inTeam(2L), inTeam(1L), inTeam(null));
List<Policy> visible = guard(true).visible(all);
PolicyStore store = new InProcessPolicyStore();
store.save(inTeam(1L));
store.save(inTeam(2L));
store.save(inTeam(1L));
store.save(inTeam(null));
List<Policy> visible = guard(true).visibleFrom(store);
assertEquals(2, visible.size());
assertTrue(visible.stream().allMatch(p -> Long.valueOf(1L).equals(p.teamId())));
}
@Test
void visibleReturnsEverythingWhenLoginDisabled() {
List<Policy> all = List.of(inTeam(1L), inTeam(2L));
assertEquals(all, guard(false).visible(all));
void visibleFromReturnsEverythingWhenLoginDisabled() {
PolicyStore store = new InProcessPolicyStore();
store.save(inTeam(1L));
store.save(inTeam(2L));
assertEquals(2, guard(false).visibleFrom(store).size());
}
@Test
@@ -79,6 +90,6 @@ class PolicyAccessGuardTest {
private static Policy inTeam(Long teamId) {
return new Policy(
"p1", "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId);
null, "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId);
}
}
@@ -40,6 +40,9 @@ import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunView;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
@ExtendWith(MockitoExtension.class)
@DisplayName("PolicyController")
@@ -48,9 +51,12 @@ class PolicyControllerTest {
@Mock private PolicyRunner policyRunner;
@Mock private PolicyRunRegistry runRegistry;
@Mock private stirling.software.proprietary.policy.store.PolicyStore policyStore;
@Mock private SourceStore sourceStore;
@Mock private SourceAccessGuard sourceAccessGuard;
@Mock private PolicyValidator policyValidator;
@Mock private PolicyAccessGuard policyAccessGuard;
@Mock private PolicyManagementAuthority policyManagementAuthority;
@Mock private PolicyTriggerManager policyTriggerManager;
@Mock private TempFileManager tempFileManager;
@Mock private JobOwnershipService jobOwnershipService;
@@ -65,9 +71,12 @@ class PolicyControllerTest {
policyRunner,
runRegistry,
policyStore,
sourceStore,
sourceAccessGuard,
policyValidator,
policyAccessGuard,
policyManagementAuthority,
policyTriggerManager,
applicationProperties,
tempFileManager,
jobOwnershipService);
@@ -214,6 +223,7 @@ class PolicyControllerTest {
assertThat(response.getBody().owner()).isEqualTo("alice");
assertThat(response.getBody().teamId()).isEqualTo(7L);
verify(policyValidator).validate(any());
verify(policyTriggerManager).notifyPoliciesChanged();
}
@Test
@@ -229,6 +239,7 @@ class PolicyControllerTest {
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN));
verify(policyStore, never()).save(any());
verify(policyTriggerManager, never()).notifyPoliciesChanged();
}
@Test
@@ -295,8 +306,7 @@ class PolicyControllerTest {
@DisplayName("listPolicies returns team-visible policies")
void listVisible() {
List<Policy> all = List.of(policy("a", 1L), policy("b", 1L));
when(policyStore.all()).thenReturn(all);
when(policyAccessGuard.visible(all)).thenReturn(all);
when(policyAccessGuard.visibleFrom(policyStore)).thenReturn(all);
List<Policy> result = controller.listPolicies();
@@ -355,6 +365,7 @@ class PolicyControllerTest {
ResponseEntity<Void> response = controller.deletePolicy("a");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
verify(policyTriggerManager).notifyPoliciesChanged();
}
@Test
@@ -369,6 +380,7 @@ class PolicyControllerTest {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
verify(policyStore, never()).delete(any());
verify(policyTriggerManager, never()).notifyPoliciesChanged();
}
@Test
@@ -33,6 +33,9 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
/**
* Tests for {@link PolicyRunner}: the one place that turns a policy's sources into runs. Verifies
@@ -45,11 +48,12 @@ class PolicyRunnerTest {
@Mock private PolicyEngine policyEngine;
@Mock private InputSource folderSource;
private final SourceStore sourceStore = new InProcessSourceStore();
private PolicyRunner runner;
@BeforeEach
void setUp() {
runner = new PolicyRunner(policyEngine, List.of(folderSource));
runner = new PolicyRunner(policyEngine, List.of(folderSource), sourceStore);
}
@Test
@@ -145,15 +149,22 @@ class PolicyRunnerTest {
verifyNoInteractions(folderSource);
}
private static Policy policy(List<InputSpec> sources) {
/** Persists each spec as a source and returns a policy referencing them by id. */
private Policy policy(List<InputSpec> sources) {
List<String> sourceIds =
sources.stream().map(spec -> sourceStore.save(sourceFrom(spec)).id()).toList();
return new Policy(
"p1",
"p",
"owner",
true,
null,
sources,
sourceIds,
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
private static Source sourceFrom(InputSpec spec) {
return new Source(null, "src", spec.type(), spec.options(), true, "owner", null);
}
}
@@ -23,6 +23,9 @@ import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.output.PolicyOutputSink;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
/** Tests for {@link PolicyValidator}: routes each facet to its handler and surfaces failures. */
@@ -33,12 +36,14 @@ class PolicyValidatorTest {
@Mock private InputSource inputSource;
@Mock private PolicyOutputSink outputSink;
private final SourceStore sourceStore = new InProcessSourceStore();
private PolicyValidator validator;
@BeforeEach
void setUp() {
validator =
new PolicyValidator(List.of(trigger), List.of(inputSource), List.of(outputSink));
new PolicyValidator(
List.of(trigger), List.of(inputSource), List.of(outputSink), sourceStore);
}
@Test
@@ -51,7 +56,7 @@ class PolicyValidatorTest {
validator.validate(policy);
verify(trigger).validate(policy);
verify(inputSource).validate(policy.sources().get(0));
verify(inputSource).validate(InputSpec.folder("/in"));
verify(outputSink).validate(policy.output());
}
@@ -88,27 +93,35 @@ class PolicyValidatorTest {
assertTrue(ex.getMessage().contains("unknown trigger type"));
}
private static Policy policy(String triggerType) {
private Policy policy(String triggerType) {
return new Policy(
"p1",
"p",
"owner",
true,
new TriggerConfig(triggerType, Map.of()),
List.of(InputSpec.folder("/in")),
List.of(folderSourceId()),
List.of(),
OutputSpec.inline());
}
private static Policy manualOnly() {
private Policy manualOnly() {
return new Policy(
"p1",
"p",
"owner",
true,
null,
List.of(InputSpec.folder("/in")),
List.of(folderSourceId()),
List.of(),
OutputSpec.inline());
}
/** Persists a folder source ("/in") and returns its id for a policy to reference. */
private String folderSourceId() {
InputSpec spec = InputSpec.folder("/in");
return sourceStore
.save(new Source(null, "src", spec.type(), spec.options(), true, "owner", null))
.id();
}
}
@@ -25,6 +25,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
/** Tests for {@link FolderInputSource}: consume (claim + route) and snapshot (read-only) modes. */
@ExtendWith(MockitoExtension.class)
@@ -40,7 +41,9 @@ class FolderInputSourceTest {
void setUp() {
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString()));
FolderAccessGuard guard = new FolderAccessGuard(properties, new StandardEnvironment());
FolderAccessGuard guard =
new FolderAccessGuard(
properties, new StandardEnvironment(), new InProcessSourceStore());
source = new FolderInputSource(readinessChecker, guard);
// Lenient: the missing-dir / nonexistent-dir cases return before any readiness check.
lenient().when(readinessChecker.isReady(any())).thenReturn(true);
@@ -22,6 +22,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.ResultFile;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
/** Tests for {@link FolderOutputSink}: outputs are written to the configured directory on disk. */
class FolderOutputSinkTest {
@@ -34,7 +35,10 @@ class FolderOutputSinkTest {
void setUp() {
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString()));
sink = new FolderOutputSink(new FolderAccessGuard(properties, new StandardEnvironment()));
sink =
new FolderOutputSink(
new FolderAccessGuard(
properties, new StandardEnvironment(), new InProcessSourceStore()));
}
@Test
@@ -0,0 +1,114 @@
package stirling.software.proprietary.policy.source;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Tests for {@link JpaSourceStore}'s entity mapping. The repository is mocked; real Hibernate/H2
* persistence is exercised at application boot, mirroring {@link
* stirling.software.proprietary.policy.store.JpaPolicyStore}'s test convention.
*/
@ExtendWith(MockitoExtension.class)
class JpaSourceStoreTest {
@Mock private SourceRepository repository;
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private JpaSourceStore store;
@BeforeEach
void setUp() {
store = new JpaSourceStore(repository, objectMapper);
}
@Test
void saveAssignsAnIdAndPersistsTheSourceAsJson() {
Source saved =
store.save(
new Source(
null,
"Claims intake",
"folder",
Map.of("directory", "/in/claims"),
true,
"alice",
7L));
assertNotNull(saved.id());
ArgumentCaptor<SourceEntity> captor = ArgumentCaptor.forClass(SourceEntity.class);
verify(repository).save(captor.capture());
SourceEntity entity = captor.getValue();
assertEquals(saved.id(), entity.getId());
assertEquals("folder", entity.getType());
assertEquals("alice", entity.getOwner());
assertEquals(Long.valueOf(7L), entity.getTeamId());
assertTrue(entity.isEnabled());
// The stored JSON round-trips back to an equal source.
assertEquals(saved, objectMapper.readValue(entity.getSourceJson(), Source.class));
}
@Test
void getDeserializesTheSourceFromJson() {
Source source =
new Source("s1", "Claims", "folder", Map.of("directory", "/in"), true, "alice", 1L);
when(repository.findById("s1")).thenReturn(Optional.of(entityFor(source)));
assertEquals(source, store.get("s1").orElseThrow());
}
@Test
void allDeserializesEverySource() {
Source a = new Source("a", "A", "folder", Map.of("directory", "/a"), true, "alice", 1L);
when(repository.findAll()).thenReturn(List.of(entityFor(a)));
assertEquals(List.of(a), store.all());
}
@Test
void findByTeamDelegatesToTheScopedQuery() {
Source mine = new Source("a", "A", "folder", Map.of("directory", "/a"), true, "alice", 7L);
when(repository.findByTeam(7L)).thenReturn(List.of(entityFor(mine)));
assertEquals(List.of(mine), store.findByTeam(7L));
}
@Test
void deleteReturnsWhetherTheSourceExisted() {
when(repository.existsById("s1")).thenReturn(true);
assertTrue(store.delete("s1"));
verify(repository).deleteById("s1");
when(repository.existsById("missing")).thenReturn(false);
assertFalse(store.delete("missing"));
}
private SourceEntity entityFor(Source source) {
SourceEntity entity = new SourceEntity();
entity.setId(source.id());
entity.setName(source.name());
entity.setType(source.type());
entity.setOwner(source.owner());
entity.setTeamId(source.teamId());
entity.setEnabled(source.enabled());
entity.setSourceJson(objectMapper.writeValueAsString(source));
return entity;
}
}
@@ -0,0 +1,123 @@
package stirling.software.proprietary.policy.source;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
/**
* Tests for {@link SourceController}'s delete guard: a source still referenced by a policy is
* protected (409), while an unreferenced one is removed. Login is disabled, so editing and team
* scoping pass through and the reference check is exercised on its own.
*/
class SourceControllerTest {
private final SourceStore sourceStore = new InProcessSourceStore();
private final PolicyStore policyStore = new InProcessPolicyStore();
private PolicyTriggerManager triggerManager;
private SourceController controller;
@BeforeEach
void setUp() {
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(false);
UserServiceInterface userService = mock(UserServiceInterface.class);
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
SourceOverviewService overviewService =
new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard);
triggerManager = mock(PolicyTriggerManager.class);
// A permissive input source so config validation passes and save can be exercised.
InputSource folderInput = mock(InputSource.class);
when(folderInput.supports(any())).thenReturn(true);
controller =
new SourceController(
sourceStore,
sourceGuard,
overviewService,
policyStore,
policyGuard,
authority,
triggerManager,
properties,
List.of(folderInput));
}
@Test
void deletingAReferencedSourceConflicts() {
Source source = sourceStore.save(folderSource());
policyStore.save(policyReferencing("Redact incoming", source.id()));
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> controller.delete(source.id()));
assertEquals(409, ex.getStatusCode().value());
assertTrue(sourceStore.get(source.id()).isPresent());
}
@Test
void deletingAnUnreferencedSourceSucceeds() {
Source source = sourceStore.save(folderSource());
ResponseEntity<Void> response = controller.delete(source.id());
assertEquals(204, response.getStatusCode().value());
assertTrue(sourceStore.get(source.id()).isEmpty());
// A deletable source is referenced by no policy, so no watch registration can change.
verify(triggerManager, never()).notifyPoliciesChanged();
}
@Test
void savingASourceReSyncsTriggerRegistrations() {
controller.save(folderSource());
verify(triggerManager).notifyPoliciesChanged();
}
@Test
void deletingAMissingSourceIsNotFound() {
assertEquals(404, controller.delete("nope").getStatusCode().value());
}
private static Source folderSource() {
return new Source(
null, "Claims intake", "folder", Map.of("directory", "/in"), true, "owner", null);
}
private static Policy policyReferencing(String name, String sourceId) {
return new Policy(
null,
name,
"owner",
true,
null,
List.of(sourceId),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
}
@@ -0,0 +1,187 @@
package stirling.software.proprietary.policy.source;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Tests for {@link SourceOverviewService}: each source appears exactly once, annotated with the
* policies that reference it. Login is disabled, so the team guards pass everything through and the
* reference counting is exercised directly.
*/
class SourceOverviewServiceTest {
private final SourceStore sourceStore = new InProcessSourceStore();
private final PolicyStore policyStore = new InProcessPolicyStore();
private SourceOverviewService service;
@BeforeEach
void setUp() {
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(false);
UserServiceInterface userService = mock(UserServiceInterface.class);
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
service = new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard);
}
@Test
void eachSourceAppearsOnceWithItsReferenceCount() {
Source a = source("A", "/a");
Source b = source("B", "/b");
Source c = source("C unused", "/c");
policyReferencing("P1", a.id());
policyReferencing("P2", a.id(), b.id());
SourcesResponse response = service.overview();
assertEquals(3, response.sources().size());
// Sorted most-referenced first, so the shared source A leads.
assertEquals(a.id(), response.sources().get(0).id());
SourceView av = find(response, a.id());
assertEquals(2, av.referenceCount());
assertEquals("active", av.status());
assertTrue(
av.referencingPolicies().stream()
.map(SourceView.PolicyRef::name)
.toList()
.containsAll(List.of("P1", "P2")));
assertTrue(
av.config().stream()
.anyMatch(
row ->
row.label().equals("Directory")
&& row.value().equals("/a")));
assertEquals(1, find(response, b.id()).referenceCount());
SourceView cv = find(response, c.id());
assertEquals(0, cv.referenceCount());
assertEquals("unused", cv.status());
// KPI strip: total, in-use, orphaned.
assertEquals(List.of(3L, 2L, 1L), response.kpis().stream().map(SourceKpi::value).toList());
}
@Test
void aDisabledSourceReadsAsDisabled() {
Source disabled =
sourceStore.save(
new Source(
null,
"Paused",
"folder",
Map.of("directory", "/d"),
false,
"owner",
null));
assertEquals("disabled", find(service.overview(), disabled.id()).status());
}
@Test
void overviewLoadsOnlyTheCallersTeam() {
// Login on, caller is on team 1. Another team's source and policy must be invisible, and a
// cross-team policy referencing our source must not inflate its reference count.
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(true);
UserServiceInterface userService = mock(UserServiceInterface.class);
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
when(authority.currentUserTeamId()).thenReturn(1L);
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
SourceOverviewService scoped =
new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard);
Source ours = teamSource("Ours", "/ours", 1L);
teamSource("Theirs", "/theirs", 2L);
teamPolicy("Our policy", 1L, ours.id());
teamPolicy("Their policy", 2L, ours.id());
SourcesResponse response = scoped.overview();
assertEquals(1, response.sources().size());
SourceView view = response.sources().get(0);
assertEquals(ours.id(), view.id());
assertEquals(1, view.referenceCount());
assertEquals(List.of(1L, 1L, 0L), response.kpis().stream().map(SourceKpi::value).toList());
}
@Test
void documentVolumeIsNotTrackedYet() {
Source a = source("A", "/a");
assertNull(find(service.overview(), a.id()).docsTotal());
}
private Source source(String name, String directory) {
return sourceStore.save(
new Source(
null, name, "folder", Map.of("directory", directory), true, "owner", null));
}
private Source teamSource(String name, String directory, Long teamId) {
return sourceStore.save(
new Source(
null,
name,
"folder",
Map.of("directory", directory),
true,
"owner",
teamId));
}
private void policyReferencing(String name, String... sourceIds) {
policyStore.save(
new Policy(
null,
name,
"owner",
true,
null,
List.of(sourceIds),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
}
private void teamPolicy(String name, Long teamId, String... sourceIds) {
policyStore.save(
new Policy(
null,
name,
"owner",
true,
null,
List.of(sourceIds),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
teamId));
}
private static SourceView find(SourcesResponse response, String id) {
return response.sources().stream()
.filter(view -> view.id().equals(id))
.findFirst()
.orElseThrow();
}
}
@@ -18,7 +18,6 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
@@ -55,7 +54,7 @@ class JpaPolicyStoreTest {
"alice",
true,
new TriggerConfig("schedule", Map.of()),
List.of(InputSpec.folder("/in")),
List.of("src-in"),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
@@ -88,6 +87,46 @@ class JpaPolicyStoreTest {
assertEquals(policy, store.get("p1").orElseThrow());
}
@Test
void saveDenormalizesTeamIdForScopedQueries() {
store.save(
new Policy(
"p1",
"scoped",
"alice",
true,
null,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
9L));
ArgumentCaptor<PolicyEntity> captor = ArgumentCaptor.forClass(PolicyEntity.class);
verify(repository).save(captor.capture());
assertEquals(Long.valueOf(9L), captor.getValue().getTeamId());
}
@Test
void findByTeamDelegatesToTheScopedQuery() {
Policy policy =
new Policy(
"p1",
"ours",
"alice",
true,
null,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
9L);
when(repository.findByTeam(9L)).thenReturn(List.of(entityFor(policy)));
List<Policy> mine = store.findByTeam(9L);
assertEquals(1, mine.size());
assertEquals("p1", mine.get(0).id());
}
@Test
void findByTriggerTypeUsesTheEnabledQuery() {
Policy policy =
@@ -125,6 +164,7 @@ class JpaPolicyStoreTest {
entity.setOwner(policy.owner());
entity.setEnabled(policy.enabled());
entity.setTriggerType(policy.trigger() == null ? null : policy.trigger().type());
entity.setTeamId(policy.teamId());
entity.setPolicyJson(objectMapper.writeValueAsString(policy));
return entity;
}
@@ -32,6 +32,9 @@ import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
@@ -50,6 +53,7 @@ class FolderWatchTriggerTest {
@TempDir Path tempDir;
private final SourceStore sourceStore = new InProcessSourceStore();
private FolderWatchTrigger trigger;
@BeforeEach
@@ -59,6 +63,7 @@ class FolderWatchTriggerTest {
policyStore,
policyRunner,
List.of(folderSource),
sourceStore,
new ApplicationProperties());
lenient().when(folderSource.supports(any())).thenReturn(true);
lenient()
@@ -160,18 +165,59 @@ class FolderWatchTriggerTest {
}
}
@Test
void onPoliciesChangedSyncsRegistrationsImmediately() throws Exception {
Path dir = Files.createDirectories(tempDir.resolve("watched"));
Policy p = folderWatch("p", List.of(InputSpec.folder(dir.toString())));
WatchService service = FileSystems.getDefault().newWatchService();
try {
trigger.watchService = service;
when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(p));
// The mutation hook registers the new policy's directory without waiting for a
// reconcile.
trigger.onPoliciesChanged();
assertEquals(Set.of(normalized(dir.toString())), trigger.watchedDirs());
// Once the policy is gone, the same hook cancels its registration.
when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of());
trigger.onPoliciesChanged();
assertEquals(Set.of(), trigger.watchedDirs());
} finally {
service.close();
}
}
private static Path normalized(String dir) {
return Path.of(dir).toAbsolutePath().normalize();
}
private static Policy folderWatch(String id, List<InputSpec> sources) {
/** Persists each spec as a source and returns a folder-watch policy referencing them by id. */
private Policy folderWatch(String id, List<InputSpec> sources) {
List<String> sourceIds =
sources.stream()
.map(
spec ->
sourceStore
.save(
new Source(
null,
"src",
spec.type(),
spec.options(),
true,
"owner",
null))
.id())
.toList();
return new Policy(
id,
"watcher",
"owner",
true,
new TriggerConfig("folder-watch", Map.of()),
sources,
sourceIds,
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
@@ -0,0 +1,76 @@
package stirling.software.proprietary.security.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.Set;
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 org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.common.service.LoginAgreementService;
import stirling.software.proprietary.security.controller.api.AdminLoginAgreementController.DisclaimerContentRequest;
@ExtendWith(MockitoExtension.class)
class AdminLoginAgreementControllerTest {
@Mock LoginAgreementService loginAgreementService;
@InjectMocks AdminLoginAgreementController controller;
@Test
void listDelegatesToService() {
when(loginAgreementService.listLocalesWithContent()).thenReturn(Set.of("en-GB", "fr-FR"));
assertEquals(Set.of("en-GB", "fr-FR"), controller.listLocales());
}
@Test
void readReturnsContentForValidLocale() {
when(loginAgreementService.readRawForLocale("fr-FR")).thenReturn("# Avis");
ResponseEntity<?> resp = controller.read("fr-FR");
assertEquals(HttpStatus.OK, resp.getStatusCode());
}
@Test
void readReturnsBadRequestForInvalidLocale() {
// Service returns null for an invalid locale.
when(loginAgreementService.readRawForLocale("../escape")).thenReturn(null);
ResponseEntity<?> resp = controller.read("../escape");
assertEquals(HttpStatus.BAD_REQUEST, resp.getStatusCode());
}
@Test
void writeDelegatesAndReturnsNoContent() throws IOException {
ResponseEntity<Void> resp = controller.write("fr-FR", new DisclaimerContentRequest("# Hi"));
assertEquals(HttpStatus.NO_CONTENT, resp.getStatusCode());
verify(loginAgreementService).writeForLocale("fr-FR", "# Hi");
}
@Test
void writeReturnsBadRequestOnInvalidLocale() throws IOException {
doThrow(new IllegalArgumentException("Invalid locale"))
.when(loginAgreementService)
.writeForLocale(eq("../escape"), eq("x"));
ResponseEntity<Void> resp =
controller.write("../escape", new DisclaimerContentRequest("x"));
assertEquals(HttpStatus.BAD_REQUEST, resp.getStatusCode());
}
@Test
void writeReturnsServerErrorOnIoException() throws IOException {
doThrow(new IOException("disk full"))
.when(loginAgreementService)
.writeForLocale(eq("fr-FR"), eq("x"));
ResponseEntity<Void> resp = controller.write("fr-FR", new DisclaimerContentRequest("x"));
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode());
}
}
@@ -25,6 +25,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
@@ -48,7 +49,7 @@ class ServerCertificateServiceTest {
@BeforeEach
void setUp() {
service = new ServerCertificateService(licenseKeyChecker);
service = new ServerCertificateService(licenseKeyChecker, new ApplicationProperties());
// default: feature enabled, validity 365, org Stirling-PDF, no regenerate
ReflectionTestUtils.setField(service, "enabled", true);
ReflectionTestUtils.setField(service, "organizationName", "Stirling-PDF");
@@ -0,0 +1,161 @@
package stirling.software.saas.accountlink;
import java.util.List;
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.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Account-link registration surface (combined-billing "Mode A").
*
* <p>A self-hosted instance's local backend calls {@code POST /register} with the admin's
* short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new
* auth here). We resolve the caller's team, mint a device credential bound to it, and return the
* secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this
* JWT.
*
* <p>Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent →
* 404. Leader-only, and the team is always derived from the caller (never the request body).
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/account-link")
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkController {
private final AccountLinkService service;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
public AccountLinkController(
AccountLinkService service,
TeamMembershipRepository memberRepo,
UserRepository userRepository) {
this.service = service;
this.memberRepo = memberRepo;
this.userRepository = userRepository;
}
/** Optional display name for the instance (hostname / label). */
public record RegisterRequest(String name) {}
/** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */
public record RegisterResponse(
Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {}
public record InstanceRow(
Long instanceId,
String deviceId,
String name,
String createdAt,
String lastSeenAt,
boolean revoked) {}
@PostMapping("/register")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<RegisterResponse> register(
@RequestBody(required = false) RegisterRequest req, Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
String name = req != null ? req.name() : null;
AccountLinkService.RegisteredInstance reg =
service.register(lt.teamId(), lt.userId(), name);
return ResponseEntity.status(HttpStatus.CREATED)
.body(
new RegisterResponse(
reg.instanceId(),
lt.teamId(),
reg.deviceId(),
reg.deviceSecret(),
reg.name()));
}
@GetMapping("/instances")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<List<InstanceRow>> list(Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
List<InstanceRow> rows =
service.list(lt.teamId()).stream()
.map(
i ->
new InstanceRow(
i.getInstanceId(),
i.getDeviceId(),
i.getName(),
i.getCreatedAt() != null
? i.getCreatedAt().toString()
: null,
i.getLastSeenAt() != null
? i.getLastSeenAt().toString()
: null,
i.getRevokedAt() != null))
.toList();
return ResponseEntity.ok(rows);
}
@PostMapping("/instances/{instanceId}/revoke")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> revoke(@PathVariable Long instanceId, Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
boolean ok = service.revoke(lt.teamId(), instanceId);
return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
}
// ---------------------------------------------------------------------------------------
// Helpers — team always derived from the caller; instance linking is a leader (billing) action.
// ---------------------------------------------------------------------------------------
/**
* Resolved caller team, or an {@code error} status to return (teamId/userId null when error).
*/
private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {}
private LeaderTeam resolveLeaderTeam(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty()) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
TeamMembership m = rows.get(0);
if (m.getRole() != TeamRole.LEADER) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
return new LeaderTeam(m.getTeam().getId(), user.getId(), null);
}
}
@@ -0,0 +1,117 @@
package stirling.software.saas.accountlink;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
/**
* Account-link instance registration + lifecycle (combined-billing "Mode A").
*
* <p>Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound
* to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its
* unattended entitlement reads with that credential.
*
* <p>Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so
* {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404.
*/
@Slf4j
@Service
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkService {
/** 32 bytes of entropy → URL-safe secret; high enough that an unsalted SHA-256 hash is fine. */
private static final int SECRET_BYTES = 32;
private final LinkedInstanceRepository repo;
private final SecureRandom random = new SecureRandom();
public AccountLinkService(LinkedInstanceRepository repo) {
this.repo = repo;
}
/** Result of {@link #register}; {@code deviceSecret} is plaintext and returned exactly once. */
public record RegisteredInstance(
Long instanceId, String deviceId, String deviceSecret, String name) {}
/**
* Creates a new linked instance for {@code teamId}, returning the one-time plaintext secret.
*/
@Transactional
public RegisteredInstance register(Long teamId, Long createdByUserId, String name) {
String deviceId = UUID.randomUUID().toString();
String deviceSecret = randomSecret();
LinkedInstance instance = new LinkedInstance();
instance.setTeamId(teamId);
instance.setCreatedByUserId(createdByUserId);
instance.setDeviceId(deviceId);
instance.setDeviceSecretHash(sha256Hex(deviceSecret));
instance.setName(name);
repo.save(instance);
log.info(
"Account-link: registered instance {} (device {}) for team {}",
instance.getInstanceId(),
deviceId,
teamId);
return new RegisteredInstance(instance.getInstanceId(), deviceId, deviceSecret, name);
}
/**
* All instances for a team, newest first (includes revoked, for the "Linked instances" list).
*/
@Transactional(readOnly = true)
public List<LinkedInstance> list(Long teamId) {
return repo.findByTeamIdOrderByCreatedAtDesc(teamId);
}
/**
* Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by
* a different team (so a caller can never revoke another team's instance). Idempotent.
*/
@Transactional
public boolean revoke(Long teamId, Long instanceId) {
Optional<LinkedInstance> found = repo.findById(instanceId);
if (found.isEmpty() || !found.get().getTeamId().equals(teamId)) {
return false;
}
LinkedInstance instance = found.get();
if (instance.getRevokedAt() == null) {
instance.setRevokedAt(LocalDateTime.now());
repo.save(instance);
log.info("Account-link: revoked instance {} for team {}", instanceId, teamId);
}
return true;
}
private String randomSecret() {
byte[] buf = new byte[SECRET_BYTES];
random.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
/** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */
static String sha256Hex(String value) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
}
@@ -0,0 +1,108 @@
package stirling.software.saas.accountlink;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/**
* Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A").
*
* <p>Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link
* LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the
* stored hash. On a match it sets a {@link LinkedInstanceAuthenticationToken} (team-scoped, {@code
* ROLE_LINKED_INSTANCE}); otherwise it does nothing and lets the chain continue (→ 401 on a
* protected endpoint).
*
* <p>Read-only and <b>path-scoped to {@code /api/v1/instance/**}</b>: the device principal is never
* established for user-facing endpoints, so a leaked secret can only reach the instance surface.
* Gated behind {@code stirling.billing.account-link.enabled}; absent when the flag is off, so
* {@code SupabaseSecurityConfig} never wires it in.
*/
@Slf4j
@Component
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class DeviceCredentialAuthenticationFilter extends OncePerRequestFilter {
static final String HEADER_DEVICE_ID = "X-Device-Id";
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
static final String INSTANCE_PATH_PREFIX = "/api/v1/instance/";
private final LinkedInstanceRepository repo;
public DeviceCredentialAuthenticationFilter(LinkedInstanceRepository repo) {
this.repo = repo;
}
/** Only the instance surface uses the device credential; everything else skips this filter. */
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith(INSTANCE_PATH_PREFIX);
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String deviceId = request.getHeader(HEADER_DEVICE_ID);
String secret = request.getHeader(HEADER_DEVICE_SECRET);
if (deviceId != null
&& secret != null
&& SecurityContextHolder.getContext().getAuthentication() == null) {
repo.findByDeviceIdAndRevokedAtIsNull(deviceId)
.ifPresent(
instance -> {
if (constantTimeEquals(
AccountLinkService.sha256Hex(secret),
instance.getDeviceSecretHash())) {
SecurityContextHolder.getContext()
.setAuthentication(
new LinkedInstanceAuthenticationToken(
instance.getInstanceId(),
instance.getTeamId()));
// Stamp liveness, best-effort. Auth is already set above; a
// transient write failure must NOT 500 an otherwise-valid
// request, so swallow it. Targeted single-column UPDATE (not a
// full save) so a concurrent revoke between the read above and
// this write can't be clobbered back to active.
try {
repo.touchLastSeen(
instance.getInstanceId(), LocalDateTime.now());
} catch (RuntimeException e) {
log.debug(
"last_seen_at update failed for device {}: {}",
deviceId,
e.getMessage());
}
} else {
log.debug("Device credential mismatch for device {}", deviceId);
}
});
}
chain.doFilter(request, response);
}
private static boolean constantTimeEquals(String a, String b) {
if (a == null || b == null) {
return false;
}
return MessageDigest.isEqual(
a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,131 @@
package stirling.software.saas.accountlink;
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.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
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;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.entitlement.EntitlementService;
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
import stirling.software.saas.payg.model.EntitlementState;
/**
* Instance-facing surface (combined-billing "Mode A"), authenticated by the <b>device
* credential</b> — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device
* credential is scoped here and nowhere else.
*
* <p>{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid
* device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET
* /entitlement} is the read the local gate consumes — the same team-scoped snapshot the FE wallet
* sees, trimmed to the fields the gate needs (subscription, free pool, period spend/cap, state),
* and built on the same device-credential auth.
*
* <p>Gated behind {@code stirling.billing.account-link.enabled}: off → beans absent → 404.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/instance")
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceController {
private final EntitlementService entitlementService;
private final TeamBillingService billingService;
private final AccountLinkService accountLinkService;
public InstanceController(
EntitlementService entitlementService,
TeamBillingService billingService,
AccountLinkService accountLinkService) {
this.entitlementService = entitlementService;
this.billingService = billingService;
this.accountLinkService = accountLinkService;
}
public record WhoAmIResponse(Long instanceId, Long teamId) {}
/**
* Minimal entitlement view the local gate enforces against. {@code periodCapUnits} null =
* uncapped. {@code state} is the coarse OK / OVER_LIMIT vocabulary the instance gate parses
* (see {@link #coarseState}), not the SaaS feature-state enum.
*/
public record EntitlementResponse(
boolean subscribed,
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
String state) {}
@GetMapping("/whoami")
@PreAuthorize("hasRole('LINKED_INSTANCE')")
public ResponseEntity<WhoAmIResponse> whoami(Authentication auth) {
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
// Belt-and-braces: hasRole already guarantees this, but never leak a non-instance
// principal.
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return ResponseEntity.ok(new WhoAmIResponse(token.getInstanceId(), token.getTeamId()));
}
/**
* Revokes this instance's own credential — a credential can mark itself revoked the same way a
* session logs itself out. Called by the proprietary backend on local unlink so the SaaS row
* gets {@code revoked_at} set; idempotent (already-revoked → still 204).
*/
@PostMapping("/revoke-self")
@PreAuthorize("hasRole('LINKED_INSTANCE')")
public ResponseEntity<Void> revokeSelf(Authentication auth) {
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
accountLinkService.revoke(token.getTeamId(), token.getInstanceId());
return ResponseEntity.noContent().build();
}
@GetMapping("/entitlement")
@PreAuthorize("hasRole('LINKED_INSTANCE')")
@Transactional(readOnly = true)
public ResponseEntity<EntitlementResponse> entitlement(Authentication auth) {
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Long teamId = token.getTeamId();
// Same composition the FE wallet uses: billing facts (subscription, free pool) from
// TeamBillingService, period spend/cap + state from the entitlement snapshot.
TeamBillingContext billing = billingService.forTeam(teamId);
EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
return ResponseEntity.ok(
new EntitlementResponse(
billing.subscribed(),
billing.freeRemainingUnits(),
snap.periodSpendUnits(),
snap.periodCapUnits(),
coarseState(snap.state())));
}
/**
* Collapses the SaaS feature-state machine into the OK / OVER_LIMIT vocabulary the instance
* gate parses. DEGRADED means automation + AI are gated off — which, for a gate that governs
* only billable work (manual tools are free-pathed before it), is exactly OVER_LIMIT; FULL and
* WARNED are OK.
*/
private static String coarseState(EntitlementState state) {
return state == EntitlementState.DEGRADED ? "OVER_LIMIT" : "OK";
}
}
@@ -0,0 +1,77 @@
package stirling.software.saas.accountlink;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code
* linked_instance}, V22).
*
* <p>Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's
* short-lived Supabase JWT. Registration mints a {@code device_id} (public) plus a {@code
* device_secret} (high-entropy, returned once and stored only on the instance — we keep an unsalted
* SHA-256 hash, the same posture as API keys). The instance authenticates its unattended
* entitlement reads with that device credential, so no long-lived user JWT lives on the server
* side.
*
* <p>{@code revoked_at IS NULL} means active; revoking sets it and the credential stops
* authenticating. The whole surface is gated behind {@code stirling.billing.account-link.enabled}.
*/
@Entity
@Table(name = "linked_instance")
@Getter
@Setter
@NoArgsConstructor
public class LinkedInstance {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "instance_id")
private Long instanceId;
@Column(name = "team_id", nullable = false)
private Long teamId;
/**
* Admin who registered the instance; informational (no FK, so a user delete never offlines it).
*/
@Column(name = "created_by_user_id")
private Long createdByUserId;
/** Public, non-secret identifier the instance presents on every request. */
@Column(name = "device_id", nullable = false, unique = true, length = 64)
private String deviceId;
/** SHA-256 hex of the device secret; the secret itself is never stored. */
@Column(name = "device_secret_hash", nullable = false, length = 64)
private String deviceSecretHash;
/** Operator-set display label (hostname etc.) for the "Linked instances" list. */
@Column(name = "name", length = 255)
private String name;
/** Insert time; Hibernate populates this on persist (DB DEFAULT is belt-and-braces). */
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/** Stamped when the device credential last authenticated; powers staleness display. */
@Column(name = "last_seen_at")
private LocalDateTime lastSeenAt;
/** NULL = active. Set on unlink/revoke; a revoked credential fails authentication. */
@Column(name = "revoked_at")
private LocalDateTime revokedAt;
}
@@ -0,0 +1,45 @@
package stirling.software.saas.accountlink;
import java.util.List;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
/**
* Authentication for a linked self-hosted instance (combined-billing "Mode A").
*
* <p>Deliberately <em>not</em> a user: the principal is the instance ({@code instanceId}) bound to
* a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code
* User} and creates no user row — a device credential can never act as a person, only as its team's
* instance, and only on the instance-facing endpoints.
*/
public class LinkedInstanceAuthenticationToken extends AbstractAuthenticationToken {
private final Long instanceId;
private final Long teamId;
public LinkedInstanceAuthenticationToken(Long instanceId, Long teamId) {
super(List.of(new SimpleGrantedAuthority("ROLE_LINKED_INSTANCE")));
this.instanceId = instanceId;
this.teamId = teamId;
setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null; // the secret is never retained on the authentication
}
@Override
public Object getPrincipal() {
return instanceId;
}
public Long getInstanceId() {
return instanceId;
}
public Long getTeamId() {
return teamId;
}
}
@@ -0,0 +1,43 @@
package stirling.software.saas.accountlink;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
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;
/**
* Data access for {@link LinkedInstance}. Plain Spring Data JPA against {@code stirling_pdf} —
* native schema access, no RPC, consistent with the rest of the SaaS backend.
*/
public interface LinkedInstanceRepository extends JpaRepository<LinkedInstance, Long> {
/**
* Active-credential lookup for the device-credential auth filter (revoked rows never match).
*/
Optional<LinkedInstance> findByDeviceIdAndRevokedAtIsNull(String deviceId);
/** Backs the portal "Linked instances" list (includes revoked, newest first). */
List<LinkedInstance> findByTeamIdOrderByCreatedAtDesc(Long teamId);
/** Active (non-revoked) linked instances on a team — the orphan guard's count. */
long countByTeamIdAndRevokedAtIsNull(Long teamId);
/**
* Stamps liveness on a single instance. A targeted single-column UPDATE rather than a
* full-entity {@code save}: the auth filter loads the instance outside a transaction, so a full
* save would write back the stale (in-memory {@code null}) {@code revoked_at} and could
* silently un-revoke a credential that was revoked between the read and the write. The {@code
* revoked_at IS NULL} guard makes this a no-op once revoked.
*/
@Modifying
@Transactional
@Query(
"UPDATE LinkedInstance li SET li.lastSeenAt = :now "
+ "WHERE li.instanceId = :instanceId AND li.revokedAt IS NULL")
int touchLastSeen(@Param("instanceId") Long instanceId, @Param("now") LocalDateTime now);
}
@@ -14,12 +14,14 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@Profile("saas")
@EnableJpaRepositories(
basePackages = {
"stirling.software.saas.accountlink",
"stirling.software.saas.repository",
"stirling.software.saas.billing.repository",
"stirling.software.saas.ai.repository",
"stirling.software.saas.payg.repository"
})
@EntityScan({
"stirling.software.saas.accountlink",
"stirling.software.saas.model",
"stirling.software.saas.billing.model",
"stirling.software.saas.ai.model",
@@ -0,0 +1,150 @@
package stirling.software.saas.payg.api;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
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.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
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.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.stripe.StripeInvoiceDao;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Read-only Stripe-invoices surface for the linked org's billing page.
*
* <p>{@code GET /api/v1/payg/invoices?limit=N} returns the team's most recent Stripe invoices,
* sourced from the {@code stripe.invoices} table the Sync Engine maintains. The caller's team is
* resolved from the authenticated principal (same pattern as {@link PaygWalletController}); we
* never trust a team id from the request.
*
* <p>Defensive: when the team has no {@code stripe_customer_id} (not subscribed, or pre-checkout)
* or the {@code stripe} schema isn't synced (H2 tests, sync engine off), we return {@code 200} with
* an empty list rather than 500 — the UI renders "no invoices yet". This keeps the page working
* through every link/subscription state.
*
* <p>{@code hostedInvoiceUrl} + {@code invoicePdf} are Stripe-hosted links the portal can deep-link
* from. We don't proxy the PDF ourselves; Stripe handles auth + caching.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/payg")
@Profile("saas")
public class PaygInvoicesController {
private static final int DEFAULT_LIMIT = 20;
private static final int MAX_LIMIT = 100;
private final StripeInvoiceDao invoiceDao;
private final PaygTeamExtensionsRepository extRepo;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
public PaygInvoicesController(
StripeInvoiceDao invoiceDao,
PaygTeamExtensionsRepository extRepo,
TeamMembershipRepository memberRepo,
UserRepository userRepository) {
this.invoiceDao = Objects.requireNonNull(invoiceDao, "invoiceDao");
this.extRepo = Objects.requireNonNull(extRepo, "extRepo");
this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
}
/** The shape the portal renders. Trimmed; never echoes raw Stripe object fields verbatim. */
public record InvoiceResponse(
String id,
String number,
String status,
Long totalMinor,
String currency,
String createdAt,
String periodStart,
String periodEnd,
String hostedInvoiceUrl,
String invoicePdf,
String description,
Long pdfsProcessed) {}
@GetMapping("/invoices")
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public ResponseEntity<List<InvoiceResponse>> list(
@RequestParam(name = "limit", required = false) Integer limit, Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// Resolve the caller's team from their primary membership — same pattern as
// PaygWalletController. The team id NEVER comes from the request.
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty()) {
return ResponseEntity.ok(List.of());
}
Long teamId = rows.get(0).getTeam().getId();
// No PAYG extension row OR no Stripe customer id → team has never subscribed → no
// invoices. Empty list, not 404 — the UI distinguishes "no invoices yet" from a
// genuine error and we don't want to error a happy free team.
Optional<PaygTeamExtensions> ext = extRepo.findById(teamId);
if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) {
return ResponseEntity.ok(List.of());
}
int safeLimit = clampLimit(limit);
List<InvoiceResponse> body =
invoiceDao.findRecentByCustomer(ext.get().getStripeCustomerId(), safeLimit).stream()
.map(PaygInvoicesController::toResponse)
.toList();
return ResponseEntity.ok(body);
}
private static int clampLimit(Integer requested) {
if (requested == null) return DEFAULT_LIMIT;
return Math.max(1, Math.min(requested, MAX_LIMIT));
}
private static InvoiceResponse toResponse(StripeInvoiceDao.InvoiceRow r) {
return new InvoiceResponse(
r.id(),
r.number(),
r.status(),
r.totalMinor(),
r.currency(),
iso(r.createdAt()),
iso(r.periodStart()),
iso(r.periodEnd()),
r.hostedInvoiceUrl(),
r.invoicePdf(),
r.description(),
r.pdfsProcessed());
}
private static String iso(LocalDateTime ldt) {
return ldt == null ? null : ldt.toString();
}
}

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