Compare commits

...
Author SHA1 Message Date
LudyandGitHub 4351318f89 Merge branch 'main' into pipeline_controller_20260525 2026-07-09 11:06:03 +02:00
ConnorYohandGitHub b53aaa7d03 Portal procurement: enterprise licence-key mechanism (generate at trial, upgrade on subscription, view/download) (#6902)
Builds on the procurement vertical slice (#6861). Adds the **enterprise
licence-key mechanism**: a Keygen licence is generated at trial,
upgraded in place when the committed subscription is created, and is
viewable/downloadable in the portal. Flag-gated — ships with the mock as
default until Keygen env vars are wired.

### What it does
- **Offline / air-gapped licence** is a new **priced add-on** on the
quote ($12k/yr, flat, alongside indemnification / training / QBR).
- **Licence key visible from the trial step** — the portal shows the key
with **Copy**, and (when the offline add-on is bought) a **Download
offline licence (.lic)** button.
- **Real Keygen client, called directly from Java**
(`KeygenEnterpriseLicenseService`), behind `stirling.keygen.enabled`;
`MockEnterpriseLicenseService` stays the default. All creds are env vars
(`STIRLING_KEYGEN_*`) — nothing committed.
- **Provisioning is driven by the Stripe `customer.subscription.created`
event** (source of truth), not a UI action — so a sales-led deal entered
manually in Stripe provisions a licence too. The webhook calls a new
admin `POST /api/v1/procurement/provision`, which upgrades the trial
licence **in place** to the committed annual term, **valid immediately**
(no wait for payment). The deal stays in the payment step so the
outstanding invoice remains visible.
- Offline `.lic` is checked out `base64+ed25519` (signed, unencrypted)
so the self-hosted `KeygenLicenseVerifier` validates it fully offline.

### Not in scope (deliberate, follow-ups)
- Cloud entitlement flip — a **cloud** customer sees/downloads the key
but the running cloud product doesn't unlock yet (self-hosted/air-gap
**are** unlocked by the key). Immediate next PR.
- `invoice.paid → fully-live` / `payment_failed → suspend` webhook
safety-net.

### Companion change (separate repo)
- The Stripe-webhook wiring that calls `/provision` lives in the
**Stirling-PDF-SaaS** repo (committed on `v3`, not part of this PR):
`stripe-webhook` routes enterprise-committed `subscription.created` →
`provisionProcurement()` → the Java admin endpoint.

### Prod setup required
- Create the committed-enterprise **Keygen policy with
`scheme=ed25519`** under the existing account, set
`STIRLING_KEYGEN_ENABLED=true` + account/token/policy env vars.

### Verified
saas `:saas:test` (procurement) · portal typecheck / eslint / prettier ·
82 portal tests · `deno check` on the webhook handler.

### Review follow-ups (PR review, tracked)
Low-hardening fixes applied in `85369633ed`: keep Keygen response bodies
out of thrown/logged messages; fail-fast at startup when the flag is on
but creds are missing; gate the offline `.lic` on the *accepted* quote
(not the latest draft).

Deliberately deferred, tracked here:
- **Pre-flag verification.** Before `stirling.keygen.enabled=true`,
confirm the id-vs-key addressing against live Keygen. (The shipping
self-hosted edge addresses licences by URL-safe key in the path and
Keygen docs allow it, so the client mirrors that — but confirm
empirically with the real committed-enterprise policy.)
- **No auto-revoke on non-payment.** Provision issues an
immediately-valid annual licence before payment settles; `invoice.paid →
live` and `payment_failed → suspend` are out of scope here. Note the
offline `.lic`, once downloaded, verifies offline for the full term and
**can't be revoked** — so the real mitigation for the offline case is a
shorter bridge term until `invoice.paid`, not just wiring `suspend`.
Enterprise is sales-led/ADMIN-gated, so this is a collections concern,
not mass abuse.
2026-07-08 19:31:26 +00:00
ConnorYohandGitHub 3fa0f30d43 Portal: prep for SaaS launch — hide unfinished sections, fix api client, docs link (#6921)
## What this changes

Getting the portal ready to show the world on SaaS. A few things bundled
in here:

**Developer docs tab** — now opens https://docs.stirlingpdf.com/ in a
new tab instead of taking you to an empty page (we haven't built the
in-app docs page yet).

**Hid the bits that aren't finished yet — SaaS only:**
- Took the Agent Builder button off the Sources page.
- Removed the Components page.
- Infrastructure: the tabs that aren't ready (Deployments, Security,
Models, Storage) are greyed out as "coming soon". API keys and Audit
stay live. Also dropped the "Manage editor deployment" button.
- Removed the floating AI assistant blob.

**Fixed the SaaS api client.** Before this, only the usage/billing page
actually reached the backend — everything else (sources, users,
policies, etc.) was going to the vite dev server with the wrong login,
so it never worked. Now every portal call goes to the one SaaS backend
using the Supabase login.

Self-hosted is left exactly as it was — all the SaaS hides go through
the saas override layer, so self-hosted still shows everything.

## Testing
typecheck (all variants), full test suite, both builds, lint + format —
all green.
2026-07-08 16:39:37 +00:00
Anthony StirlingandGitHub 9ea848570f Wire portal audit tab and documents to real audit data (#6912)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-08 15:31:01 +00:00
James BruntonandGitHub d6061eb0aa Support tool selection in Pipelines page in Portal (#6905)
# Description of Changes

Redesigned the Portal Pipelines page so pipelines are created and edited
on their own dedicated builder page, replacing the previous modal
composer and inline detail card.

## Screenshots

### Pipelines list
The redesigned list with summary KPIs; each row opens that pipeline's
own page.

![Pipelines
list](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/01-pipelines-list.png)

### Pipeline builder
The dedicated create page: pipeline settings (sources, trigger, output)
above, operations and per-tool settings below.

![Pipeline
builder](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/02-builder-new.png)

### Tool picker
Type-to-filter, category-grouped picker for adding an operation to the
pipeline.

![Tool
picker](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/03-tool-picker.png)

### Editing a pipeline
An existing pipeline in the builder: reorderable steps, per-tool
settings, and run/delete actions.

![Editing a
pipeline](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/04-builder-edit.png)
2026-07-08 15:23:04 +00:00
ConnorYohandGitHub 1759e0bdd5 feat(portal): wire procurement "Schedule a call" to Calendly (#6920)
## What

The procurement flow's **Schedule a call** action (deal-status hero →
side modal) was a mock: a fake "SE" avatar and four hardcoded time-slot
buttons that just closed the dialog. This wires it up to the real
Calendly booking widget the admin provided.

## How

- **New `CalendlyInline` component**
(`portal/components/procurement/CalendlyInline.tsx`)
- Lazily loads `assets.calendly.com/assets/external/widget.js` via the
existing `@app/utils/scriptLoader` — only when the modal actually opens,
deduped across reopens.
- Calls `Calendly.initInlineWidget()` explicitly so it rebuilds on
reopen / theme change.
- Colours track the portal's light/dark theme (`useTheme`) via
Calendly's `background_color` / `text_color` / `primary_color` params,
mapped to the portal design tokens (surface / text-1 / primary), plus
`hide_event_type_details=1`.
- Graceful fallback to an "open in a new tab" link if the script fails
to load.
- Base URL overridable via `VITE_CALENDLY_URL` (defaults to the
group-discussion link).
- **`ScheduleCallModal`** now renders `<CalendlyInline />` instead of
the mock; copy moved into i18n (`portal.procurement.schedule.*`).
- `SideModal` gains a `wide` variant so the embed has room; removed the
now-dead `.portal-se*` / `.portal-slots*` CSS and `SLOTS` constant.

## Notes / follow-ups

- No app-level CSP blocks `calendly.com`, so the embed loads without
config changes.
- Verified with the portal typecheck (`tsc -p src/portal/tsconfig.json`)
and ESLint on the changed files; only pre-existing Storybook/msw dev-dep
type errors remain.


<img width="1160" height="642" alt="image"
src="https://github.com/user-attachments/assets/d9b5d92d-ea7e-4862-9f35-a71f65392a2c"
/>
<img width="3744" height="1990" alt="image"
src="https://github.com/user-attachments/assets/0b8002c6-dd3e-41e6-8e8b-6faf6090314c"
/>
<img width="2620" height="1928" alt="image"
src="https://github.com/user-attachments/assets/781b7a60-7065-4a7a-b9f7-1cdb0dece7b3"
/>
2026-07-08 15:06:55 +00:00
ConnorYohandGitHub 514b020f74 Portal: real Free PDF Editors usage card (self-hosted) (#6919)
## What

Replaces the **mocked** "Free PDF Editors" fleet card on the portal
Usage page with live figures. Cost stays a literal `$0`; any figure that
can't be computed renders **N/A** (never a misleading 0).

| Metric | Self-hosted source |
|---|---|
| **Editors deployed** | total users (`UserRepository.count()`) |
| **Active this month** | distinct `source=WEB` principals active in 30d
(excl. `UI_DATA` polling), clamped ≤ deployed |
| **PDFs edited** | cumulative `PDF_PROCESS` + `FILE_OPERATION` audit
events that are **free UI runs** |

## Why the counting approach

"Free operations = UI tool runs." Two dead ends first:
- **Billing/PAYG is the wrong source** — it *deliberately discards* free
ops (classified `BYPASSED`, no DB row); its tables only hold billable
(API/AI/automation).
- **Raw audit is also wrong** — a tool controller emits `PDF_PROCESS`
for UI **and** API/AI/automation calls, and billable traffic exists on
every tier.

So the count is **audit filtered to free UI runs**. Audit events gain a
`source` column, stamped from the always-on signal
`BillingCategoryClassifier.classify(...) == BYPASSED` (not API-key auth,
no `X-Stirling-Automation` header, not `/api/v1/ai/`) — zero
billing-module coupling. Captured on the request thread
(`AuditService.captureCurrentSource`), carried via MDC in
`ControllerAuditAspect` (same propagation as `requestId`), persisted by
`CustomAuditEventRepository`. The count filters `source = 'WEB'`.

## Endpoint

`GET /api/v1/usage/fleet-stats` — admin-gated, EE-only. Returns `null`
per field when EE auditing is off (→ N/A).

## Frontend

- New `portal/api/fleetStats.ts` → `apiClient.local` (this instance's
backend).
- `FreePdfEditorsCard` rewired to `useAsync(fetchFleetStats)`; preview
badge removed, `null`→"N/A", loading→"—".

## Tests

`:proprietary:build` green — `FleetUsageControllerTest` (4) and
`CustomAuditEventRepositoryTest` (+2 for source-from-MDC) pass; spotless
clean.

## Notes / follow-ups

- `deployed` currently counts all users incl. disabled — refine to
enabled-only later.
- **SaaS** (team-scoped endpoint + a `fleetStats.ts` override) is
deferred to a follow-up riding the portal-SaaS layering PR #6900.
- Depends on EE auditing running at `AuditLevel ≥ STANDARD` for the
audit-derived figures; otherwise they show N/A.
2026-07-08 14:42:44 +00:00
18b0b19a67 Block file exit points while a per-file policy run is enforcing (#6904)
## What

While a per-file policy run is in flight, the editor now blocks every
way the file can leave the app, and shows why:

- **Viewer** — a blocking overlay with live progress ("Enforcing
policy…"). Dismissible: collapses to a corner badge (top right, tinted
with the policy's accent) so the file stays readable while the run
finishes.
- **Workbench bar** — Print / Download / Save As / Share are disabled
with an explanatory tooltip and progress bar. The Ctrl+P shortcut and
the form-fill bar's "Download PDF" button are covered too.
- **File lists** — the file sidebar, file-editor thumbnails, and files
page show a spinning shield badge on the affected file, and thumbnail
hover actions (download / upload to server) are blocked with the same
tooltip.

Once a run settles, everything unblocks — including FAILED and CANCELLED
runs. A failed check surfaces through the run's activity feed; it never
locks the user out of their file.

## Why

Upload-triggered policies exist so the enforced output is what leaves
the app. Before this, a file could be printed, downloaded, or shared
while its policy run was still processing.

## Also in here

- **One shared `PolicyBadges` component** — the sidebar, thumbnails, and
files page each had their own copy of the badge markup/CSS and had
drifted (different sizes, tints, missing spinner and glow on the files
page, hardcoded English tooltips). All badge surfaces now render the
same component: accent-tinted shield, spinner while enforcing, one-off
glow when recent, i18n'd tooltips.
- **Cascade fix:** outputs imported from reconciled
(server-rediscovered) runs are now tagged `derivedFromTool`, stopping an
auto-run → import → auto-run loop that produced ever-growing
`_sanitized_sanitized…` filename chains on fresh devices.
- **Core stub for `policyRunStore`** so the core build compiles —
`WorkbenchBar` and `ViewerShareButton` resolve `usePolicyRuns` via
`@app/*`.

## Testing

- `task frontend:check` green: proprietary typecheck, ESLint + dpdm,
Prettier, 915+ editor + 81 portal unit tests.
- `typecheck:core` / `saas` / `desktop` variants all pass.
- Enforcement flow exercised manually against a live backend with an
upload-triggered policy (overlay + progress during the run, dismiss to
corner badge, unblock on completion).

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-07-08 14:09:23 +00:00
ConnorYohandGitHub a8bda9240c feat(portal): replace free-tier carousel with static welcome hero (#6901)
## What this PR does

Redesigns the portal home to match the marketing demo, across all tiers.

- Swapped the old rotating welcome carousel for a static **welcome
hero** on the free tier
- Subscribed (Processor) + enterprise get a **deployed-editor hero** —
shows the live instance (host, version, active users) with an *Open in
browser* button, pulled from the real editor-deployment API (same data
the Editor admin page uses)
- Added a time-of-day **greeting** on the paid tiers
- Rebuilt the **"Finish setting up" checklist** so it's real: the counts
and tick-offs come from the actual policies + sources (a step is done
when there's at least one), not hardcoded numbers
- *Download the PDF Editor* → `https://stirling.com/download`; the other
steps deep-link to Policies / Sources
- **Procurement is now a bolt-on to any tier** — if a deal's in flight
the deal-status hero drops into the hero's footer, otherwise you get the
setup checklist
- All new copy is translated (en-US) and it reuses the shared UI kit,
icons and design tokens

## Tidy-ups / fixes found along the way
- The subscribed hero was shadowing the real `/v1/editor/deployment`
endpoint (broke the Editor admin page) — now reuses it
- Renamed the hero's CSS namespace to `.portal-welcome` so it stops
clashing with the procurement hero's `.portal-hero`
- Refactored the merged procurement component into a shared
`useProcurement` hook + banner + flow, so the deal hero can live inside
the tier hero — `/procurement` route unchanged

## Screenshots

<img width="1258" height="1338" alt="pr-free"
src="https://github.com/user-attachments/assets/9bb7db44-5f8e-4388-857a-7f113c2d7d82"
/>

<img width="1258" height="862" alt="pr-enterprise"
src="https://github.com/user-attachments/assets/f1f36faa-1467-404e-9036-dab12e3d0b54"
/>

<img width="1258" height="944" alt="pr-subscribed"
src="https://github.com/user-attachments/assets/775f1228-f182-47b5-82ff-7ac6d5932bd9"
/>

---

## Checklist

### General
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### UI Changes
- [x] Screenshots demonstrating the UI changes are attached

### Testing
- [x] Portal typecheck, ESLint and Prettier pass on the changed files
- [x] Verified all states in Storybook and ran the app locally via `task
dev:portal`
2026-07-08 13:44:30 +00:00
Anthony StirlingandGitHub 0692058602 Embed admin portal as its own app in the jar behind buildWithPortal (#6911)
## What

Lets the admin portal ("Stirling Processor") ship **inside the JAR**,
gated by a build flag. On `main` the portal already exists as a lazy
`/portal/*` route in the editor but isn't included in production builds
and isn't reachable in a login-enabled server. This PR makes it a
**flag-gated, directly-navigable** part of the editor bundle, and wires
it into the PR preview deployment so it can be tried live.

It keeps the exact architecture `main` uses (portal = a lazy chunk of
the editor, not a separate app), so it inherits all the editor's global
providers/styles and there's no second build to maintain.

## How

**Frontend - gate the existing lazy route**
([`adminRouteExtensions.tsx`](frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx))
```ts
const includePortal = import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
const PortalApp = includePortal ? lazy(() => import("@portal/PortalApp")) : null;
```
Vite bakes the env to a literal, so when off the dynamic import is
**tree-shaken out entirely** (no `PortalApp` chunk emitted). Always on
in dev. `VITE_INCLUDE_PORTAL` is typed in `vite-env.d.ts` and declared
(default `false`) in `editor/.env`.

**Gradle** ([`build.gradle`](app/core/build.gradle)) -
`-PbuildWithPortal=true` forces `buildWithFrontend=true` and sets
`VITE_INCLUDE_PORTAL=true` on the editor build. Process-env takes
priority over `.env`, so the flag wins for JAR builds while plain `vite
build` / Cloudflare Pages default to off.

**Backend - make the shell reachable**
([`RequestUriUtils`](app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java))
- permits `/portal` + `/portal/*` as public SPA routes. The editor keeps
its JWT in localStorage (not a cookie), so a direct nav/refresh to
`/portal` isn't authenticated at the server and would otherwise redirect
to `/login` and never load. Serving the shell pre-auth (like the editor
root already is) lets it load; **access control is unchanged** - the
portal has its own auth gate + `RequirePortalAccess`, and its data APIs
stay protected.

**Docker** - the embedded Dockerfiles take `ARG BUILD_PORTAL=false` →
`-PbuildWithPortal=${BUILD_PORTAL}`. Default off, so official
`push-docker` images do **not** bundle the portal.

**CI - scoped to the PR preview deploy only**
([`PR-Auto-Deploy-V2.yml`](.github/workflows/PR-Auto-Deploy-V2.yml)) -
the one job that builds the JAR and comments owns all portal wiring:
passes `BUILD_PORTAL=true`, enables the portal's backend features
(`POLICIES_ENABLED`, `STIRLING_BILLING_ACCOUNT_LINK_ENABLED`), and adds
an "Admin portal included" line (linking `/portal` via the direct IP) to
the deployment comment. `push-docker`, `build.yml`, `test-build-docker`,
and the shared paths-filter are untouched.

## Validation (real, in the JAR)

Built and booted the JAR with `-PbuildWithPortal=true` and login
enabled:
- `/portal` and `/portal/users` load via direct nav and render **fully
themed** (dark surfaces, gradients, filled buttons).
- Editor-only build (`-PbuildWithFrontend=true`, no portal flag) →
editor ships, **0 portal chunks** (tree-shaken).
- `-PbuildWithPortal=true` → `PortalApp` chunk present.

Green: `frontend:check:all` (typecheck all variants, lint, format,
build, tests incl. the `VITE_*` env guard), backend compile,
`RequestUriUtilsTest`, spotless.

## Notes

- **Official images never bundle the portal** (Dockerfile default off);
only the PR preview does. Flip `BUILD_PORTAL` / `-PbuildWithPortal` to
include it elsewhere.
- The `/portal` shell being public is the one deviation from `main`, and
it's required for the route to be reachable at all in a login-enabled
server; data access is still fully gated.
2026-07-08 12:55:22 +00:00
James BruntonandGitHub 8150d16b6f Support bidirectional mapping for Change Metadata (#6906)
# Description of Changes
The Change Metadata tool was missed from the bidirectional mappings
added in #6867. This PR adds it to the list of supported tools.
2026-07-08 12:47:11 +00:00
Reece BrowneandGitHub c8f238ae60 Portal/editor switcher (#6907)
Adds the portal's top-left app switcher to the editor sidebar, so you
can jump between the two apps from either side.

- Both sidebars render the same shared `AppSwitch` component (sui
dropdown).
- Switching is client-side (no page reload); portal→editor no longer
breaks when `VITE_EDITOR_URL` is unset.
- Editor side is admin-gated (`portalAccess`) and only exists in flavors
that ship the portal — core/desktop stub it out, same seam pattern as
the portal routes.
- Fixes en route: dropdown menu stacking in the editor sidebar, sui
dropdown item button reset, stale `dist-portal` ESLint ignore.
2026-07-08 12:17:00 +00:00
ConnorYohandGitHub 8df49ac053 feat(portal): build portal for SaaS and self-hosted via file-override layer (#6900)
## What

Ground-work so the admin portal can build for the **SaaS flavor**
alongside self-hosted, using the editor's existing **build-time
file-override** mechanism — no runtime flavor flags. This PR
demonstrates SaaS end-to-end (single-login + Usage page loading via the
inherited Supabase session) without changing self-hosted behaviour.

This is intentionally scoped as foundations, not the whole feature.

## How

**Build hook**
- `tsconfig.saas.vite.json`: `@portal/*` now cascades
(`src/saas/portal/*` → `src/portal/*`); added `@portalCore/*` for the
explicit base path.
- New `src/portal-saas/` layer (sibling of `src/portal`) holds SaaS-only
overrides, so `@app/*` resolves only editor layers and `@portal/*` only
portal layers. Self-hosted builds never import it (tree-shaken).

**Seams live in the api-client + composition layers — never in page
components**
- `saasApiBase` — base URL source (self-hosted: `VITE_SAAS_API_URL`;
SaaS reuses the single `VITE_API_BASE_URL` backend).
- `portalSaasSession` — flavor-agnostic token from the shared Supabase
client.
- `PortalAuthBoundary` — self-hosted: Spring `AuthProvider` +
`AuthGate`; SaaS: Supabase `AuthProvider` + session-only gate (inherits
the SaaS session, so no second login).

**Link concept pulled out of the Usage page (one clean cut)**
- `Usage` is now a link-free wallet renderer with generic
`onWalletLoaded` / `onReauth` callbacks; it always loads the wallet and
has zero flavor awareness.
- `PortalBillingGate` is the single flavor seam: self-hosted gates on
link (prompt when unlinked; wires the callbacks onto link/tier +
re-auth), SaaS is a passthrough that renders `Usage` directly.
- Keeps the flavor switch out of the page entirely (no per-flavor code
in `Usage`).

## Testing
Green locally and in CI (CI runs the umbrella `task
frontend:check:all`):
- `task frontend:typecheck:all` — clean across all 7 build variants
- `task frontend:test` — vitest suites pass (portal + saas cover this
change; 146 tests)
- `task frontend:build:saas` and `task frontend:build:proprietary` —
both green
- `task frontend:lint` and `task frontend:format:check` — clean

## Also in this PR (added after the initial foundations)
- **Tier from wallet + full link-layer excision on SaaS.** `TierContext`
no longer reads `LinkContext` (via a `usePlanTier` seam: self-hosted
from link state, SaaS from `wallet.status`), and the SaaS
`PortalProviders` drops `LinkProvider` / `AccountLinkProvider` /
`LinkModalHost` entirely — the link machinery is *absent* from the SaaS
bundle, not mounted-but-inert.

## Deliberately out of scope (follow-ups)
- SaaS-only read-only "connected servers" settings view.
- Shared wallet source so the SaaS tier badge and the Usage page don't
both fetch `/payg/wallet` (harmless double-fetch today).
2026-07-08 12:07:54 +00:00
Anthony StirlingandGitHub 328cd8c664 Claude skills walkthrough, feature-walkthrough, and before/after (#6862)
# Description of Changes

Add review only Claude skills

### Skills (Using
https://github.com/Stirling-Tools/Stirling-PDF/pull/6655 as example for
example files)
- **`/ui-walkthrough`** - captures every state of a feature's UI
(empty/populated/dialogs, light + dark + RTL) via the stubbed Playwright
harness, builds a single-image HTML report with a global light/dark
slider, then runs visual-consistency + UX review passes. `--fix`
auto-applies safe fixes and re-shoots.

[REAL-ui-walkthrough-pr6655.html](https://github.com/user-attachments/files/29594945/REAL-ui-walkthrough-pr6655.html)

- **`/feature-walkthrough`** — explains a branch end-to-end (Mermaid
diagrams, annotated file map, before/after, "try it locally") so a
reviewer with no prior context can follow it.

[REAL-feature-walkthrough-pr6655.html](https://github.com/user-attachments/files/29594950/REAL-feature-walkthrough-pr6655.html)

- **`/ui-before-after`** — generic branch/PR visual diff: derives the
changed UI from the diff, screenshots before (base) vs after (head),
pixel-diffs, auto-crops each pair to the region that actually changed
(full-page only when the change is page-wide), and builds PR-ready
before/after montages.

[REAL-ui-before-after-pr6655.html](https://github.com/user-attachments/files/29594954/REAL-ui-before-after-pr6655.html)




---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-08 09:46:55 +00:00
LudyandGitHub b27c46d72b Merge branch 'main' into pipeline_controller_20260525 2026-07-06 10:08:45 +02:00
LudyandGitHub b0ecc22260 Merge branch 'main' into pipeline_controller_20260525 2026-06-18 09:39:27 +02:00
LudyandGitHub 91279e85fa Merge branch 'main' into pipeline_controller_20260525 2026-06-12 18:25:23 +02:00
LudyandGitHub 55fb5cf6c4 Merge branch 'main' into pipeline_controller_20260525 2026-06-09 19:27:59 +02:00
LudyandGitHub 9c3d1eeca6 Merge branch 'main' into pipeline_controller_20260525 2026-06-07 09:58:59 +02:00
LudyandGitHub a79efd8d39 Merge branch 'main' into pipeline_controller_20260525 2026-06-05 19:14:58 +02:00
LudyandGitHub ade36f7d86 Merge branch 'main' into pipeline_controller_20260525 2026-05-31 09:12:50 +02:00
LudyandGitHub 8b69a5fc05 Merge branch 'main' into pipeline_controller_20260525 2026-05-30 23:39:28 +02:00
Ludy87 ae8c2ec293 Move PipelineConfigController to proprietary
Move PipelineConfigController and its test from the core SPDF package into the proprietary module. Update file paths and package declarations from stirling.software.SPDF.controller.api to stirling.software.proprietary.controller.api for both the main class and its test to reflect the module reorganization.
2026-05-25 18:59:09 +02:00
Ludy87 63b00824bb Add PipelineConfigController for watched configs
Introduce a new controller to persist folder-scanning pipeline configs under the pipeline watched-folders path. Adds POST /watched-folders/config (ADMIN only) which validates the request, enforces path traversal protections, creates target directories, sanitizes filenames, pretty-prints the provided config to a .json file, and returns the saved path or error details. Includes a small DTO (SaveWatchedFolderConfigRequest) and a sanitizeFileName helper; IO errors are logged and returned as 500 responses.
2026-05-25 18:41:28 +02:00
262 changed files with 12933 additions and 4311 deletions
@@ -0,0 +1,97 @@
---
name: feature-walkthrough
description: >-
Explain the full logic and process of the current branch end-to-end so someone
with no prior knowledge of the task can understand, review, and reproduce it.
Scopes the change from the branch diff, traces the flow across every layer it
touches (frontend tool/hook/component, Java controller/service/endpoint, Python
engine, config, i18n, tests), and produces a self-contained walkthrough document
with Mermaid diagrams (sequence/flow/architecture), annotated file map with
clickable references, before/after behavior, screenshots where a UI is involved,
a "try it locally" section, and edge cases/risks. Use when asked for a feature or
branch walkthrough, "explain what this branch does", a design/logic writeup, PR
reviewer onboarding, or a hand-off doc. Pass --html to also emit a rendered HTML
version; --no-screens to skip screenshots.
argument-hint: "[branch-or-area] [--html] [--no-screens]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# Feature / Branch Walkthrough
Turn the current branch into a walkthrough a newcomer can follow. Audience:
**someone who has never seen this task**. Explain the *why*, the *flow*, and *how to
try it* - not just a diff summary.
`$ARGUMENTS` may name a branch or area to focus on; default is the current branch
vs `main`. Flags: `--html` (also emit a rendered HTML twin), `--no-screens`.
## Process
### 1. Scope the change
- `git log --oneline main..HEAD` and `git diff --stat main...HEAD` for the shape.
- Read the PR description / commit messages for stated intent. Do **not** invent
history or motivation that isn't evidenced (state current behavior in present tense).
- Classify touched files by layer:
- **Frontend**: tools (`frontend/editor/src/core/components/tools/*` or `.../core/tools/*`),
hooks (`core/hooks/tools/*`, `useToolOperation`), contexts, routes, i18n
(`public/locales/en-US`).
- **Java backend**: controllers (`.../controller/api/...`), services, models, config.
- **Engine**: `engine/src/stirling/{agents,contracts,api,services}`.
- **Config / build / docker / tests.**
### 2. Trace the flow end-to-end
Follow one real path from user action to result. For a typical PDF tool that's:
UI control → `useToolOperation` hook → `POST /api/v1/...` → Spring controller →
service (PDFBox / LibreOffice / engine call) → response → review panel → download.
Read the actual files so the narrative is true to the code, and collect the exact
file:line anchors you'll cite.
### 3. Draw the diagrams (Mermaid)
Pick what fits; usually 2-3 of:
- **Sequence diagram** - request/response across frontend → backend → engine.
- **Flowchart** - the core decision/branching logic of the feature.
- **Architecture/component** - new pieces and how they wire to existing ones.
- **State** - if the feature has modes/steps.
Keep nodes labeled in plain language. Validate the Mermaid parses before shipping.
### 4. Screenshots (unless --no-screens)
If a UI is involved, capture key states with the stubbed Playwright harness
(see the **ui-walkthrough** skill and `files-page-screenshots.spec.ts` for the
pattern) or, for before/after, capture `main` then the branch. Drop PNGs in
`walkthrough/<feature>/` and reference them from the doc. For backend-only
changes, show request/response examples (curl + JSON) instead.
### 5. Write the walkthrough
Create `walkthrough/<feature>/FEATURE-WALKTHROUGH.md` with:
1. **TL;DR** - what the branch does and who it's for, in 3-4 sentences.
2. **Problem & approach** - what wasn't possible before; the chosen solution.
3. **Architecture diagram** + 1-paragraph orientation.
4. **End-to-end flow** - the sequence diagram + a numbered walk of each step,
each citing the real file (clickable `path:line`).
5. **Key files** - annotated map (path → one line on its role).
6. **Logic deep-dive** - the flowchart + prose for the non-obvious decisions.
7. **Behavior** - before vs after; screenshots or request/response examples.
8. **Try it locally** - exact steps (`task dev` / `task dev:all`, the route to
open or the curl to run, any env like `DOCKER_ENABLE_SECURITY` or a test
license key). Make it copy-pasteable.
9. **Edge cases, risks, follow-ups** - what's untested, known limits, gotchas.
Markdown is the primary deliverable - it renders with diagrams in GitHub PRs and
IDEs, no build step, ideal for review.
### 6. If `--html`
Also emit `walkthrough/<feature>/walkthrough.html`: the same content with Mermaid
rendered via `mermaid.initialize({startOnLoad:true})` (script from CDN; note in
the file that rendering diagrams needs network, the `.md` is the offline copy) and
screenshots inline. Keep it self-contained otherwise.
### 7. Deliver
Give the doc path and a short chat summary. Offer to `SendUserFile` it.
## Principles
- **True to the code.** Every claim traces to a file you read; cite `path:line`.
No fabricated migration/version history.
- **Newcomer-first.** Define repo-specific terms (FileContext, `useToolOperation`,
the `@app/*` layer cascade, stubbed vs live tests) on first use.
- **Show, don't assert.** Prefer a diagram + a real example over adjectives.
- Don't commit the `walkthrough/` output unless asked.
+122
View File
@@ -0,0 +1,122 @@
---
name: ui-before-after
description: >-
Analyse a branch or PR and automatically capture before/after screenshots of
every UI surface its changes touch, then pixel-diff the pairs to surface what
actually changed and assemble PR-ready before/after montage images. Generic and
diff-driven: it derives the capture targets from the diff (changed tools/routes →
URLs) instead of hand-listing screens, captures "before" from the base branch and
"after" from the head, then keeps only the views that visually differ. Each
comparison is auto-cropped to the region that actually changed (the bounding box of
differing pixels), falling back to the full page only when the change spans most of
it. Use for before/after shots, a visual diff of a branch/PR, "screenshots for the
PR description", "show what changed in the UI", or a side-by-side of UI changes.
Takes a PR number/URL (resolved via gh) or a branch; defaults to the current branch
vs its base. Flags: --scope <selector>, --base <ref|merge-base>, --theme
light|dark|both, --all (capture every route, not just changed), --no-autocrop,
--pagewide <n>, --threshold <n>.
argument-hint: "[PR# | PR-url | branch] [--scope <sel>] [--base <ref>] [--theme both] [--all] [--no-autocrop]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# UI Before / After (generic visual diff)
Point it at a branch or PR; it figures out which UI changed, screenshots every
affected surface **before** (base) and **after** (head), pixel-diffs the pairs, and
montages the ones that actually changed into images for the PR description.
`$ARGUMENTS`: a PR number/URL, a branch, or nothing (current branch vs base).
By default it captures the full viewport and auto-crops each comparison to the region
that changed. Flags: `--scope <css>` (narrow the *capture* to a container, e.g.
`[data-sidebar="tool-panel"]`, when you already know where the change is),
`--no-autocrop` (keep full frames), `--pagewide <fraction>` (above this share of the
page, skip cropping; default 0.6), `--base <ref|merge-base>`,
`--theme light|dark|both`, `--all` (walk every route, not just changed),
`--threshold <fraction>` (diff sensitivity, default 0.001).
Shares the capture harness with **ui-walkthrough** - read its SKILL.md for the
stubbed-Playwright setup, worktree node_modules + `generate-icons`, the
stale-`:5173` gotcha, and the dark-mode init-script. Bundled helpers:
[capture-spec.template.ts](capture-spec.template.ts), [diff-shots.mjs](diff-shots.mjs),
[montage-template.html](montage-template.html), [shoot-sections.mjs](shoot-sections.mjs).
## Process
### 1. Resolve target + base
```
gh pr view <pr> --json number,title,headRefName,baseRefName,url,files # PR
# or branch: base = merge-base(main, HEAD); head = HEAD
gh pr diff <pr> --name-only # or: git diff --name-only <base>...HEAD
```
### 2. Derive capture targets from the diff (the "analyse" step - no hand-listing)
Map changed frontend files to URLs generically:
- **Tools**: a changed `components/tools/<toolDir>/…` or `hooks/tools/<tool>/…` →
toolId → URL via the repo's own rule `getToolUrlPath` in
[toolsTaxonomy.ts:200](frontend/editor/src/core/data/toolsTaxonomy.ts): `/` + the
id kebab-cased (`addPageNumbers` → `/add-page-numbers`).
- **Pages/routes**: changed `filesPage/*` → `/files`, etc.
- `--all`: enumerate every tool in the registry instead of just changed ones.
Write `frontend/editor/screenshots/ui-diff/targets.json` =
`[{ "id":"compress", "url":"/compress", "name":"Compress" }]`. This is what makes
it generic - the spec never names a tool.
### 3. Capture AFTER (head) then BEFORE (base)
Copy [capture-spec.template.ts](capture-spec.template.ts) →
`src/core/tests/stubbed/ui-before-after.spec.ts` (it loops `targets.json`, seeds a
sample PDF so file-dependent panels render, navigates to each URL, and screenshots
the full viewport - or the `--scope` container if given). Ensure the harness is ready
(node_modules + icons).
```
# after = current head
cd frontend/editor && PR_SHOT_SIDE=after PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# before = base, in an isolated worktree (copy the spec + targets.json in)
git worktree add ../ba-base origin/<baseRefName> # or the merge-base
# set up its frontend, copy spec + screenshots/ui-diff/targets.json across, then:
cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# copy its screenshots/ui-diff/before/ back next to after/. Repeat with
# PR_SHOT_THEME=dark if --theme includes dark. Remove worktree when done.
```
### 4. Auto-diff (surface what changed)
```
cd frontend/editor && node <skill>/diff-shots.mjs \
screenshots/ui-diff/before screenshots/ui-diff/after screenshots/ui-diff
```
Produces `diff-report.json` classifying each view `unchanged | changed | added |
removed`. For each changed view it computes the bounding box of differing pixels and
writes cropped `__before_crop.png` / `__after_crop.png` / `__diff.png` to that region
(+ padding) - **unless** the change covers more than `--pagewide` of the frame, where
it keeps the full frame (`pageWide:true`). Drop `unchanged` - that's the noise the
user doesn't want.
### 5. Montage the changes
Build the manifest from the non-unchanged entries (group by tab/tool; each becomes a
state row with before/after). For changed views use the cropped `cropBefore` /
`cropAfter` from `diff-report.json` (tight on the affected region; full frame when
`pageWide`); `added`/`removed` render the "not present" placeholder. Fill
[montage-template.html](montage-template.html) (replace the `window.__BA__` data
block; base64-inline the PNGs for portability), then render one PNG per section with
[shoot-sections.mjs](shoot-sections.mjs). Optionally include the `__diff.png` overlay
as a third column.
### 6. Deliver
Output the `montage_<tab>.png` files + a short summary (N changed / added / removed,
M unchanged skipped) and a paste-ready Markdown block. GitHub has no PR-body image
API, so tell the user to drag the PNGs into the description. Do **not** post to the
PR.
## Gotchas
- Two installs (base worktree + head); junction main's node_modules only if its deps
match that ref, else `npm ci` (see ui-walkthrough's stale-dep note).
- A view that errors on one side (refactored/removed) → that side is missing; the
diff marks it added/removed rather than failing the run.
- Pixel diff needs equal dimensions, so capture at a fixed viewport (the template
does); a view whose size changed is reported as "changed (dimensions differ)",
uncropped.
- Auto-crop uses a single bounding box, so two far-apart changes give one large crop
(or trip `--pagewide`); narrow with `--scope` if that happens.
- `getToolUrlPath` is the source of truth for tool URLs - use it, don't guess slugs.
- Don't commit `screenshots/`, the throwaway spec, or the base worktree.
@@ -0,0 +1,67 @@
// Generic before/after capturer. NOT app-specific: it walks a targets.json that
// the ui-before-after skill generates from the branch/PR diff, so nothing here is
// hand-listed. Copy to src/core/tests/stubbed/ui-before-after.spec.ts, then run
// once per (side, theme):
// PR_SHOT_SIDE=after PR_SHOT_THEME=light \
// npx playwright test --project=stubbed ui-before-after.spec.ts
//
// targets.json shape: [{ "id":"compress", "url":"/compress", "name":"Compress",
// "needsFile": true }]
import { test } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
const SIDE = process.env.PR_SHOT_SIDE ?? "after";
const THEME = process.env.PR_SHOT_THEME ?? "light";
// Capture the full viewport by default so the affected region is in frame
// wherever it is; diff-shots.mjs crops each comparison to what actually changed.
// Set PR_SHOT_SCOPE to a selector to narrow the capture to one container.
const SCOPE = process.env.PR_SHOT_SCOPE ?? "";
const ROOT = path.resolve(process.cwd(), "screenshots", "ui-diff");
const OUT = path.join(ROOT, SIDE);
// A tiny sample PDF so file-dependent tool panels render. Point at a real fixture.
const SAMPLE_PDF = process.env.PR_SHOT_SAMPLE ?? "src/core/tests/test-fixtures/sample.pdf";
type Target = { id: string; url: string; name?: string; needsFile?: boolean };
const targets: Target[] = JSON.parse(fs.readFileSync(path.join(ROOT, "targets.json"), "utf-8"));
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 }, seedJwt: true });
async function applyTheme(page: Page): Promise<void> {
if (THEME !== "dark") return;
await page.addInitScript(() => {
localStorage.setItem("mantine-color-scheme", "dark");
localStorage.setItem("mantine-color-scheme-value", "dark");
});
await page.emulateMedia({ colorScheme: "dark" });
}
async function seedFile(page: Page): Promise<void> {
if (!fs.existsSync(SAMPLE_PDF)) return;
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("files-button").click().catch(() => {});
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF).catch(() => {});
await page.locator(".file-sidebar-file-item").first().isVisible({ timeout: 8_000 }).catch(() => {});
}
for (const t of targets) {
// One test per target so a single failure doesn't drop the rest.
test(`${SIDE}/${THEME} ${t.id}`, async ({ page }) => {
fs.mkdirSync(OUT, { recursive: true });
await applyTheme(page);
if (t.needsFile !== false) await seedFile(page);
await page.goto(t.url, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(400); // settle Mantine portals/transitions
const shot = path.join(OUT, `${t.id}__${THEME}.png`);
if (SCOPE) {
const scope = page.locator(SCOPE).first();
if (await scope.isVisible({ timeout: 8_000 }).catch(() => false)) {
await scope.screenshot({ path: shot });
return;
}
}
// Full viewport (fixed size → stable dimensions for pixel diffing).
await page.screenshot({ path: shot });
});
}
@@ -0,0 +1,106 @@
// Auto-diff before/ vs after/ screenshots, classify each as
// unchanged | changed | added | removed, and CROP each changed pair to the
// affected region (bounding box of differing pixels + padding) - unless the
// change spans most of the page, in which case the full frame is kept.
// Run from frontend/editor (so deps resolve):
// node <skill>/diff-shots.mjs <beforeDir> <afterDir> [outDir]
// Env:
// DIFF_THRESHOLD min fraction of differing pixels to count as changed (default 0.001)
// DIFF_PAD padding px around the affected region (default 24)
// DIFF_PAGEWIDE if affected bbox area / image area exceeds this, keep full frame (default 0.6)
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
const require = createRequire(path.join(process.cwd(), "noop.js"));
const pm = require("pixelmatch");
const pixelmatch = pm.default || pm;
const { PNG } = require("pngjs");
const beforeDir = path.resolve(process.argv[2]);
const afterDir = path.resolve(process.argv[3]);
const outDir = path.resolve(process.argv[4] || afterDir);
const THRESHOLD = Number(process.env.DIFF_THRESHOLD ?? "0.001");
const PAD = Number(process.env.DIFF_PAD ?? "24");
const PAGEWIDE = Number(process.env.DIFF_PAGEWIDE ?? "0.6");
const read = (p) => PNG.sync.read(fs.readFileSync(p));
const isShot = (f) => f.endsWith(".png") && !/__(diff|before_crop|after_crop)\.png$/.test(f);
const list = (d) => (fs.existsSync(d) ? fs.readdirSync(d).filter(isShot) : []);
const names = [...new Set([...list(beforeDir), ...list(afterDir)])].sort();
fs.mkdirSync(outDir, { recursive: true });
function cropPNG(src, x, y, w, h) {
const out = new PNG({ width: w, height: h });
PNG.bitblt(src, out, x, y, w, h, 0, 0);
return out;
}
const writePNG = (p, png) => fs.writeFileSync(p, PNG.sync.write(png));
// Bounding box of differing pixels using a diff mask (alpha>0 where changed).
function changedBBox(before, after, w, h) {
const mask = new PNG({ width: w, height: h });
pixelmatch(before.data, after.data, mask.data, w, h, { threshold: 0.1, diffMask: true });
let minX = w, minY = h, maxX = -1, maxY = -1, count = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (mask.data[(y * w + x) * 4 + 3] > 0) {
count++;
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
}
}
}
return maxX < 0 ? null : { minX, minY, maxX, maxY, count };
}
const report = [];
for (const name of names) {
const id = name.replace(/\.png$/, "");
const bp = path.join(beforeDir, name), ap = path.join(afterDir, name);
const hasB = fs.existsSync(bp), hasA = fs.existsSync(ap);
if (hasB && !hasA) { report.push({ id, status: "removed", before: bp }); continue; }
if (!hasB && hasA) { report.push({ id, status: "added", after: ap }); continue; }
const before = read(bp), after = read(ap);
if (before.width !== after.width || before.height !== after.height) {
report.push({ id, status: "changed", note: "dimensions differ", before: bp, after: ap });
continue;
}
const w = after.width, h = after.height;
const overlay = new PNG({ width: w, height: h });
const px = pixelmatch(before.data, after.data, overlay.data, w, h, { threshold: 0.1 });
const ratio = px / (w * h);
if (ratio <= THRESHOLD) { report.push({ id, status: "unchanged", ratio: Number(ratio.toFixed(5)), before: bp, after: ap }); continue; }
const box = changedBBox(before, after, w, h);
// Pad + clamp the affected region.
const x = Math.max(0, box.minX - PAD), y = Math.max(0, box.minY - PAD);
const x2 = Math.min(w, box.maxX + 1 + PAD), y2 = Math.min(h, box.maxY + 1 + PAD);
const bw = x2 - x, bh = y2 - y;
const pageWide = (bw * bh) / (w * h) > PAGEWIDE;
const entry = { id, status: "changed", ratio: Number(ratio.toFixed(5)), before: bp, after: ap, pageWide };
if (pageWide) {
// Change spans most of the page - keep the full frame, full overlay.
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, overlay);
entry.diff = dp;
} else {
entry.bbox = { x, y, w: bw, h: bh };
const cb = path.join(outDir, `${id}__before_crop.png`); writePNG(cb, cropPNG(before, x, y, bw, bh));
const ca = path.join(outDir, `${id}__after_crop.png`); writePNG(ca, cropPNG(after, x, y, bw, bh));
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, cropPNG(overlay, x, y, bw, bh));
entry.cropBefore = cb; entry.cropAfter = ca; entry.diff = dp;
}
report.push(entry);
}
fs.writeFileSync(path.join(outDir, "diff-report.json"), JSON.stringify(report, null, 2));
const changed = report.filter((r) => r.status !== "unchanged");
console.log(`diffed ${report.length} view(s): ${changed.length} changed/added/removed, ${report.length - changed.length} unchanged`);
for (const r of changed) {
const tail = r.status !== "changed" ? ""
: r.pageWide ? " (page-wide → full frame)"
: ` (${(r.ratio * 100).toFixed(2)}%, cropped to ${r.bbox.w}×${r.bbox.h})`;
console.log(` ${r.status.padEnd(9)} ${r.id}${tail}${r.note ? " - " + r.note : ""}`);
}
@@ -0,0 +1,48 @@
"""Build EXAMPLE.html from montage-template.html using REAL files-page shots as
stand-in before/after pairs (layout demo, not an actual PR diff). Inlines PNGs as
data URIs so the HTML is portable. Run: python make_example.py"""
import base64
import json
import pathlib
import re
HERE = pathlib.Path(__file__).parent
SHOTS = pathlib.Path(
r"C:\Users\systo\git\Stirling-PDFNew\.claude\worktrees\kind-faraday-522a30"
r"\frontend\editor\screenshots\files-page"
)
def uri(fname):
p = SHOTS / fname
return "data:image/png;base64," + base64.b64encode(p.read_bytes()).decode() if p.exists() else None
data = {
"pr": "DEMO",
"title": "EXAMPLE — before/after montage (layout demo, real Files-page shots; not a real PR diff)",
"base": "main", "head": "demo-branch",
"cropSelector": "[data-sidebar=\"tool-panel\"] (real runs crop to the side; these demo shots are full-page)",
"tabs": [
{"id": "files", "title": "Files page", "ctx": "Each row = one flow state; left = base branch, right = this PR.",
"states": [
{"name": "Empty folder", "before": uri("01_empty_state_ctas.png"), "after": uri("02_empty_state_storage_off.png")},
{"name": "Files + details panel", "before": uri("03_subtoolbar_with_files.png"), "after": uri("06_details_panel_save_to_server.png")},
{"name": "Delete folder confirm", "before": None, "after": uri("19_delete_folder_dialog.png"), "note": "New in this PR"},
]},
{"id": "move", "title": "Move-to-folder dialog",
"states": [
{"name": "Dialog opened", "before": uri("07_move_dialog_collapsed.png"), "after": uri("08_move_dialog_create_folder_expanded.png")},
{"name": "After folder created", "before": None, "after": uri("08b_move_dialog_after_create_folder.png"), "note": "New flow"},
]},
],
}
tpl = (HERE / "montage-template.html").read_text(encoding="utf-8")
out = re.sub(
r"/\*__DATA__\*/.*?/\*__END__\*/",
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
tpl, count=1, flags=re.S,
)
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
print("wrote", HERE / "EXAMPLE.html", "(", (HERE / "EXAMPLE.html").stat().st_size // 1024, "KB )")
@@ -0,0 +1,106 @@
<!doctype html>
<!--
Before/After montage for a PR description. The ui-before-after skill replaces
the JSON in the window.__BA__ data block below with the captured manifest, then
screenshots each .tab-section (id="section-<tabId>") into a PNG to drag into the
PR description. Self-contained; images may be relative paths or data URIs.
Data shape:
{
"pr":"6552","title":"...","base":"main","head":"feat/x",
"cropSelector":"[data-sidebar=\"tool-panel\"]",
"tabs":[
{ "id":"sign","title":"Sign tool","states":[
{"name":"Initial","before":"before/sign__initial.png","after":"after/sign__initial.png"},
{"name":"Cert selected","before":null,"after":"after/sign__cert.png","note":"New in this PR"}
]}
]
}
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Before / After</title>
<style>
:root { --bg:#ffffff; --ink:#0b0c0e; --muted:#6b7280; --line:#e5e7eb;
--before:#6b7280; --after:#1f883d; --frame:#f3f4f6; --note:#b45309; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink);
font:14px/1.5 -apple-system,"Segoe UI",Roboto,system-ui,sans-serif; }
.wrap { max-width:1100px; margin:0 auto; padding:24px; }
.doc-head { margin-bottom:8px; }
.doc-head h1 { font-size:18px; margin:0 0 2px; }
.doc-head .sub { color:var(--muted); font-size:12.5px; }
.legend { display:flex; gap:14px; align-items:center; margin:10px 0 4px; font-size:12px; color:var(--muted); }
.chip { font-size:10px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
padding:2px 8px; border-radius:999px; color:#fff; }
.chip.before { background:var(--before); } .chip.after { background:var(--after); }
.tab-section { border:1px solid var(--line); border-radius:14px; padding:18px 18px 8px;
margin:18px 0; background:var(--bg); }
.tab-section > h2 { font-size:16px; margin:0 0 2px; }
.tab-section > .ctx { color:var(--muted); font-size:12px; margin-bottom:14px; }
.state { margin-bottom:18px; }
.state .name { font-weight:600; font-size:13.5px; margin-bottom:8px; display:flex; gap:8px; align-items:center; }
.state .name .note { font-weight:500; color:var(--note); font-size:12px; }
.pair { display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:start; }
.cell { border:1px solid var(--line); border-radius:10px; overflow:hidden; background:var(--frame); }
.cell .cap { display:flex; align-items:center; gap:8px; padding:7px 10px; border-bottom:1px solid var(--line);
background:var(--bg); }
.cell .cap .meta { color:var(--muted); font-size:11px; }
.cell img { display:block; width:100%; height:auto; background:#fff; }
.cell.empty .ph { display:flex; align-items:center; justify-content:center; height:160px; color:var(--muted);
font-size:12.5px; text-align:center; padding:0 16px; }
.single .pair { grid-template-columns:1fr; }
.empty-doc { color:var(--muted); padding:40px; text-align:center; }
@media (max-width:760px){ .pair{ grid-template-columns:1fr; } }
</style>
</head>
<body>
<div class="wrap" id="root"></div>
<script id="data">
window.__BA__ = /*__DATA__*/{"pr":"","title":"No data","base":"","head":"","cropSelector":"","tabs":[]}/*__END__*/;
</script>
<script>
(function(){
var D = window.__BA__ || { tabs: [] };
var root = document.getElementById("root");
function el(html){ var t=document.createElement("template"); t.innerHTML=html.trim(); return t.content.firstChild; }
function esc(s){ return (s==null?"":String(s)).replace(/[&<>]/g, function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;"}[c];}); }
function cell(kind, src){
if (src) {
return '<div class="cell"><div class="cap"><span class="chip '+kind+'">'+kind+'</span></div>'+
'<img src="'+esc(src)+'" alt="'+kind+'"/></div>';
}
return '<div class="cell empty"><div class="cap"><span class="chip '+kind+'">'+kind+'</span>'+
'<span class="meta">not present</span></div><div class="ph">No '+kind+' screenshot for this state</div></div>';
}
var head = '<div class="doc-head"><h1>'+esc(D.title || ("PR #"+D.pr))+'</h1>'+
'<div class="sub">Before / after &nbsp;·&nbsp; base <code>'+esc(D.base)+'</code> → head <code>'+esc(D.head)+'</code>'+
(D.cropSelector ? ' &nbsp;·&nbsp; cropped to <code>'+esc(D.cropSelector)+'</code>' : '')+'</div></div>'+
'<div class="legend"><span class="chip before">Before</span> base branch'+
'<span class="chip after">After</span> this PR</div>';
root.appendChild(el('<div>'+head+'</div>'));
if (!D.tabs || !D.tabs.length){ root.appendChild(el('<div class="empty-doc">No tabs captured yet.</div>')); return; }
D.tabs.forEach(function(tab){
var states = (tab.states||[]).map(function(s){
var onlyOne = (!s.before || !s.after);
return '<div class="state'+(onlyOne?' ':'')+'">'+
'<div class="name">'+esc(s.name)+(s.note?'<span class="note">'+esc(s.note)+'</span>':'')+'</div>'+
'<div class="pair">'+cell("before", s.before)+cell("after", s.after)+'</div></div>';
}).join("");
var sec = '<section class="tab-section" id="section-'+esc(tab.id)+'">'+
'<h2>'+esc(tab.title)+'</h2>'+
(tab.ctx?'<div class="ctx">'+esc(tab.ctx)+'</div>':'')+
states+'</section>';
root.appendChild(el(sec));
});
})();
</script>
</body>
</html>
@@ -0,0 +1,25 @@
// Render each .tab-section of a montage HTML into its own PNG (the PR-ready image).
// Run from frontend/editor (so @playwright/test resolves):
// node <skill>/shoot-sections.mjs <montage.html> <outDir>
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createRequire } from "node:module";
const require = createRequire(path.join(process.cwd(), "noop.js"));
const { chromium } = require("@playwright/test");
const htmlPath = path.resolve(process.argv[2]);
const outDir = path.resolve(process.argv[3] || path.dirname(htmlPath));
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 1200 }, deviceScaleFactor: 2 });
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
await page.waitForTimeout(250); // let images/fonts paint
const ids = await page.$$eval(".tab-section", (els) => els.map((e) => e.id));
if (!ids.length) { console.error("no .tab-section found"); process.exit(1); }
for (const id of ids) {
const name = id.replace(/^section-/, "");
await page.locator("#" + id).screenshot({ path: path.join(outDir, `montage_${name}.png`) });
console.log("wrote montage_" + name + ".png");
}
await browser.close();
+120
View File
@@ -0,0 +1,120 @@
---
name: ui-walkthrough
description: >-
Full UI investigation of the current branch's feature. Enumerates every view
and state (empty, populated, loading, error, each dialog/menu/panel, responsive
breakpoints, light + dark + RTL), captures them with the stubbed Playwright
harness, assembles a single-image HTML walkthrough with a global light/dark
toggle slider, then runs two review passes: visual/consistency (alignment,
spacing, professionalism, dark/light parity, contrast, truncation) and
UX/ease-of-use (flow, discoverability, affordances, empty/error states,
expectations). Use when asked for a UI walkthrough, screenshot review, design
or QA pass, "find anywhere to make it easier/better for users", or before
merging frontend work. Pass --fix to auto-apply safe frontend fixes and
re-capture; --theme to limit themes; --no-rtl to skip RTL.
argument-hint: "[feature/area] [--fix] [--theme light|dark|both] [--no-rtl] [--breakpoints]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# UI Walkthrough
Produce a reviewable HTML walkthrough of a feature's UI in every state and theme,
then critique it. Optionally auto-fix and re-capture.
`$ARGUMENTS` may name the feature/area to focus on. If empty, scope from the
current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
`--no-rtl`, `--breakpoints` (also capture phone/narrow widths).
## What this repo gives you (use it, don't reinvent)
- **Stubbed Playwright project** = backend-free screenshots via `page.route()` mocks.
Reference implementation: `frontend/editor/src/core/tests/stubbed/files-page-screenshots.spec.ts`.
It already shows the light / **dark** / **RTL** passes, JWT seeding, IndexedDB
seeding, and dumping PNGs to a `screenshots/<area>/` folder. Copy its shape.
- Helpers: `frontend/editor/src/core/tests/helpers/ui-helpers.ts`
(`uploadFiles`, `openSettings`, `waitForModalOpen`, `dismissTourTooltip`, …)
and the `stub-test-base` fixtures (`autoGoto`, `seedJwt`, `viewport`).
- Config: `frontend/editor/playwright.config.ts` (run from `frontend/editor/`).
- Report template: [report-template.html](report-template.html) - self-contained,
one big image at a time, a global light/dark slider that flips every shot,
thumbnail rail, prev/next + arrow keys, and a Findings tab.
## Process
### 1. Scope the feature
- If `$ARGUMENTS` is empty: `git diff --name-only main...HEAD` and read the PR/commits.
Identify changed pages, tools (`core/components/tools/<tool>` or `core/tools/<tool>`),
dialogs, panels, and routes.
- Enumerate **every view and state** to capture, e.g.:
empty / populated / loading / error / disabled; each dialog, menu, popover, tooltip;
each tab or step; selection + multi-select; success/result panel; and (if relevant)
permission/role variants. Write the list down before capturing - it's the report's spine.
### 2. Prepare the harness (worktree-safe)
Worktrees have no `node_modules` and no generated icons. From repo root:
```
cd frontend && npm ci # or junction main's node_modules (see memory)
cd frontend/editor && node scripts/generate-icons.js
```
Kill any stale dev server first (it serves old modules):
`Get-NetTCPConnection -LocalPort 5173 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`
### 3. Write the capture spec
Create `frontend/editor/src/core/tests/stubbed/<feature>-walkthrough.spec.ts`,
modeled on `files-page-screenshots.spec.ts`. For each enumerated view:
- stub the APIs it needs, drive the UI to that state, wait on a real locator
(not a fixed sleep), `await settle(page)` for Mantine portals, then
`page.screenshot({ path: shotPath("NN_name_<theme>") })`.
- Capture each view in **light and dark** (and RTL unless `--no-rtl`). Reuse the
`enableDarkMode` / `enableRtl` init-script pattern from the reference spec
(`localStorage["mantine-color-scheme"]="dark"` + `emulateMedia({colorScheme:"dark"})`).
- Name shots `NN_<view>_<theme>.png` so light/dark pair up by suffix.
- Prefer **stable test-ids** over translated accessible names (RTL/i18n breaks text locators).
Run it: `cd frontend/editor && npx playwright test --project=stubbed <feature>-walkthrough.spec.ts`.
Add `--project=stubbed-firefox`/`-webkit` only if cross-browser layout matters.
### 4. Build the report
- Copy `report-template.html` to `screenshots/<feature>/walkthrough.html` (so the
relative `screenshots/...` image paths resolve, or rewrite paths to sit beside it).
- Build the manifest and inject it: replace the JSON between the
`/*__DATA__*/` … `/*__END__*/` markers with one `views[]` entry per view
(`{id,title,light,dark,viewport,notes}`) and an empty `findings` object you'll
fill in step 5. Keep `light`/`dark` as relative paths.
- The toggle slider answers the "one big image + flip light/dark for all" request:
it shows a single large screenshot, and switching the slider re-themes every view.
### 5. Review pass 1 - visual & consistency
Open each screenshot (Read the PNG) and judge against the others:
alignment & spacing rhythm, control placement, button hierarchy, typography,
**light/dark parity** (contrast, invisible borders, washed-out text, wrong tokens),
truncation/overflow, RTL mirroring, focus states, icon consistency, professional polish.
Record each issue as a finding `{severity:high|med|low, view, title, detail, fix}`.
### 6. Review pass 2 - UX & ease of use
Walk the flow as a first-time user: discoverability, number of steps, affordance
clarity, empty-state guidance, error recovery, destructive-action confirmation,
defaults, loading feedback, mobile reachability, accessible names, and whether the
UI matches user expectations for this kind of tool. Record findings the same way.
Write both finding lists into the report's `findings.visual` / `findings.ux`,
and add short per-view `notes`. Re-inject the manifest.
### 7. If `--fix`
Only safe, self-contained frontend fixes (spacing, alignment, tokens, missing
dark-mode colors, labels, aria, obvious copy). For each: edit the component/CSS,
mark the finding `fixed:true` with what changed, then **re-run the spec** to
re-capture the affected shots and regenerate the report. Run `task frontend:check`.
Leave anything risky or ambiguous as a finding, not a change.
### 8. Deliver
Tell the user the report path and give a tight chat summary: N views ×
themes captured, top findings by severity, and (if `--fix`) what changed.
Optionally `SendUserFile` the `walkthrough.html`.
## Gotchas
- Stale `:5173` server serves old bundles - kill it before capturing (see step 2).
- Missing `material-symbols-icons.json` → blank app → every shot times out. Run
`generate-icons.js` first.
- `await settle(page)` before shots or portals/transitions tear mid-capture.
- Don't commit the generated `screenshots/` or the throwaway spec unless asked.
@@ -0,0 +1,116 @@
"""Build a self-contained EXAMPLE.html from report-template.html with mock
light/dark screenshots, so the viewer + global theme slider can be demoed
without a real capture run. Run: python make_example.py"""
import base64
import json
import pathlib
import re
HERE = pathlib.Path(__file__).parent
def svg(bg, fg, panel, accent, muted, label, kind):
"""A simple fake 'screen' SVG: title bar, sidebar, content varies by kind."""
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">',
f'<rect width="1600" height="900" fill="{bg}"/>',
# top bar
f'<rect width="1600" height="64" fill="{panel}"/>',
f'<circle cx="40" cy="32" r="12" fill="{accent}"/>',
f'<rect x="64" y="24" width="160" height="16" rx="6" fill="{muted}"/>',
f'<rect x="1430" y="20" width="130" height="24" rx="12" fill="{accent}"/>',
# left sidebar
f'<rect x="0" y="64" width="220" height="836" fill="{panel}"/>',
]
for i in range(6):
y = 100 + i * 56
parts.append(f'<rect x="24" y="{y}" width="172" height="32" rx="8" fill="{bg}"/>')
if kind == "empty":
parts += [
f'<rect x="700" y="360" width="200" height="120" rx="16" fill="none" stroke="{muted}" stroke-width="3" stroke-dasharray="10 8"/>',
f'<rect x="690" y="510" width="220" height="44" rx="10" fill="{accent}"/>',
f'<text x="800" y="600" fill="{muted}" font-family="sans-serif" font-size="26" text-anchor="middle">{label}</text>',
]
elif kind == "form":
for i in range(4):
y = 140 + i * 90
parts.append(f'<rect x="280" y="{y}" width="160" height="16" rx="6" fill="{muted}"/>')
parts.append(f'<rect x="280" y="{y+26}" width="900" height="44" rx="8" fill="{panel}" stroke="{muted}" stroke-width="1"/>')
parts.append(f'<rect x="280" y="560" width="200" height="50" rx="10" fill="{accent}"/>')
parts.append(f'<text x="800" y="850" fill="{muted}" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>')
else: # dialog
parts += [
f'<rect width="1600" height="900" fill="{fg}" opacity="0.45"/>',
f'<rect x="520" y="280" width="560" height="360" rx="18" fill="{panel}"/>',
f'<rect x="556" y="320" width="280" height="22" rx="8" fill="{fg}"/>',
f'<rect x="556" y="372" width="488" height="14" rx="6" fill="{muted}"/>',
f'<rect x="556" y="398" width="420" height="14" rx="6" fill="{muted}"/>',
f'<rect x="820" y="560" width="110" height="44" rx="9" fill="{bg}" stroke="{muted}"/>',
f'<rect x="946" y="560" width="98" height="44" rx="9" fill="{accent}"/>',
f'<text x="800" y="700" fill="#fff" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>',
]
parts.append("</svg>")
return "".join(parts)
def data_uri(s):
return "data:image/svg+xml;base64," + base64.b64encode(s.encode()).decode()
LIGHT = dict(bg="#ffffff", fg="#111418", panel="#f1f3f6", accent="#2f6fed", muted="#c2c8d0")
DARK = dict(bg="#16181c", fg="#000000", panel="#1f232a", accent="#5b8cff", muted="#3a414b")
def pair(kind, label):
return (
data_uri(svg(LIGHT["bg"], LIGHT["fg"], LIGHT["panel"], LIGHT["accent"], LIGHT["muted"], label, kind)),
data_uri(svg(DARK["bg"], DARK["fg"], DARK["panel"], DARK["accent"], DARK["muted"], label, kind)),
)
views = []
for idx, (kind, title, label) in enumerate([
("empty", "Empty state", "Drop a PDF to start"),
("form", "Tool options panel", "Compress options"),
("dialog", "Confirm dialog", "Replace original file?"),
], start=1):
light, dark = pair(kind, label)
views.append({
"id": f"{idx:02d}_{kind}",
"title": title,
"light": light,
"dark": dark,
"viewport": "1600x900",
"notes": ["This is mock data to demo the viewer."],
})
data = {
"feature": "EXAMPLE - Compress PDF (mock data)",
"branch": "demo",
"generated": "example",
"views": views,
"findings": {
"visual": [
{"severity": "high", "view": "03_dialog", "title": "Dialog buttons too close",
"detail": "Cancel/Confirm have only 8px gap; easy to misclick.",
"fix": "Increase gap to var(--mantine-spacing-md)."},
{"severity": "low", "view": "02_form", "title": "Field labels low contrast in dark mode",
"detail": "Muted token fails WCAG AA on the dark panel.",
"fix": "Use --mantine-color-dimmed instead of a hard-coded grey."},
],
"ux": [
{"severity": "med", "view": "01_empty", "title": "Primary CTA below the dropzone",
"detail": "Users expect the action button adjacent to the dropzone.",
"fix": "Move the button directly under the dashed zone."},
],
},
}
tpl = (HERE / "report-template.html").read_text(encoding="utf-8")
out = re.sub(
r"/\*__DATA__\*/.*?/\*__END__\*/",
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
tpl, count=1, flags=re.S,
)
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
print("wrote", (HERE / "EXAMPLE.html"))
@@ -0,0 +1,298 @@
<!doctype html>
<!--
UI Walkthrough report template (self-contained, works from file://).
The ui-walkthrough skill replaces the JSON in the window.__WALKTHROUGH__ data
block below with the captured manifest. Do not add external CDN deps - it must open offline.
Data shape:
{
"feature": "Compress PDF tool",
"branch": "claude/...",
"generated": "2026-06-21",
"views": [
{ "id": "01_empty", "title": "Empty state",
"light": "screenshots/compress/01_empty_light.png",
"dark": "screenshots/compress/01_empty_dark.png",
"viewport": "1600x900",
"notes": ["Heading is centered", "Primary CTA below the fold on mobile"] }
],
"findings": {
"visual": [ { "severity":"high", "view":"01_empty", "title":"...", "detail":"...", "fix":"..." } ],
"ux": [ { "severity":"med", "view":"03_dialog", "title":"...", "detail":"...", "fix":"..." } ]
}
}
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UI Walkthrough</title>
<style>
:root {
--bg: #f6f7f9; --panel: #ffffff; --panel-2: #f0f2f5; --text: #1a1b1e;
--muted: #6b7280; --border: #e2e5ea; --accent: #2f6fed; --accent-weak: #e8f0fe;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);
--hi: #d92d20; --med: #d98e00; --low: #2f6fed; --stage: #0b0c0e;
}
html[data-theme="dark"] {
--bg: #0d0e10; --panel: #16181c; --panel-2: #1d2024; --text: #e6e8eb;
--muted: #9aa3ad; --border: #2a2e35; --accent: #5b8cff; --accent-weak: #1a2336;
--shadow: 0 1px 3px rgba(0,0,0,.5), 0 8px 24px rgba(0,0,0,.4); --stage: #000;
}
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.5 -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
background: var(--bg); color: var(--text); }
header { display: flex; align-items: center; gap: 16px; padding: 12px 20px;
background: var(--panel); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 5; }
header h1 { font-size: 15px; margin: 0; font-weight: 650; }
header .sub { color: var(--muted); font-size: 12px; }
.spacer { flex: 1; }
.counter { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
.tabs { display: flex; gap: 4px; }
.tab { border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; }
.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Light/Dark slider */
.theme-toggle { display: flex; align-items: center; gap: 9px; user-select: none; }
.theme-toggle .lbl { font-size: 12px; color: var(--muted); }
.theme-toggle .lbl.on { color: var(--text); font-weight: 600; }
.switch { position: relative; width: 52px; height: 28px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; inset: 0; cursor: pointer; background: var(--panel-2);
border: 1px solid var(--border); border-radius: 999px; transition: .2s; }
.slider:before { content: ""; position: absolute; height: 20px; width: 20px; left: 3px; top: 3px;
background: #fbbf24; border-radius: 50%; transition: .2s; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
.switch input:checked + .slider { background: var(--accent); }
.switch input:checked + .slider:before { transform: translateX(24px); background: #c7d2fe; }
main { display: grid; grid-template-columns: 240px 1fr; height: calc(100vh - 53px); }
.rail { border-right: 1px solid var(--border); overflow-y: auto; background: var(--panel); padding: 8px; }
.rail .group-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em;
color: var(--muted); padding: 10px 8px 4px; }
.thumb { display: flex; gap: 9px; align-items: center; padding: 7px; border-radius: 8px;
cursor: pointer; border: 1px solid transparent; }
.thumb:hover { background: var(--panel-2); }
.thumb.active { background: var(--accent-weak); border-color: var(--accent); }
.thumb img { width: 64px; height: 40px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); background: var(--stage); }
.thumb .t { font-size: 12.5px; line-height: 1.3; }
.thumb .badge { font-size: 10px; color: var(--muted); }
.thumb .dot { width: 7px; height: 7px; border-radius: 50%; margin-left: auto; flex: none; }
.stagewrap { display: flex; flex-direction: column; min-width: 0; }
.stage { flex: 1; display: flex; align-items: center; justify-content: center; padding: 22px;
background: var(--stage); position: relative; min-height: 0; }
.stage img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px;
box-shadow: 0 4px 30px rgba(0,0,0,.4); background: #fff; }
html[data-theme="dark"] .stage img { background: #16181c; }
.nav-btn { position: absolute; top: 50%; transform: translateY(-50%); width: 42px; height: 42px;
border-radius: 50%; border: 1px solid var(--border); background: var(--panel);
color: var(--text); cursor: pointer; font-size: 18px; opacity: .85; }
.nav-btn:hover { opacity: 1; } .nav-btn.prev { left: 16px; } .nav-btn.next { right: 16px; }
.nav-btn:disabled { opacity: .25; cursor: default; }
.missing { color: var(--muted); font-size: 13px; text-align: center; }
.detail { border-top: 1px solid var(--border); background: var(--panel); padding: 14px 20px;
max-height: 38vh; overflow-y: auto; }
.detail h2 { margin: 0 0 4px; font-size: 15px; }
.detail .meta { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
.notes { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
.notes li { display: flex; gap: 8px; align-items: flex-start; }
.sev { font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 2px 7px; border-radius: 999px;
color: #fff; flex: none; margin-top: 1px; }
.sev.high { background: var(--hi); } .sev.med { background: var(--med); } .sev.low { background: var(--low); }
.finding .fix { color: var(--muted); font-size: 12.5px; }
.finding .fix b { color: var(--text); font-weight: 600; }
/* Summary tab */
.summary { padding: 20px 28px; overflow-y: auto; }
.summary h2 { font-size: 16px; margin: 22px 0 8px; }
.summary .empty { color: var(--muted); }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 14px; margin-bottom: 8px; box-shadow: var(--shadow); }
.card .head { display: flex; gap: 8px; align-items: center; }
.card a { color: var(--accent); text-decoration: none; cursor: pointer; }
.hide { display: none !important; }
kbd { font: 11px ui-monospace, monospace; background: var(--panel-2); border: 1px solid var(--border);
border-radius: 4px; padding: 1px 5px; }
</style>
</head>
<body>
<header>
<div>
<h1 id="feature-title">UI Walkthrough</h1>
<div class="sub" id="feature-sub"></div>
</div>
<div class="spacer"></div>
<div class="tabs">
<button class="tab active" data-tab="viewer">Walkthrough</button>
<button class="tab" data-tab="summary">Findings</button>
</div>
<div class="counter" id="counter"></div>
<label class="theme-toggle" title="Toggle light / dark for every screenshot">
<span class="lbl" id="lbl-light">Light</span>
<span class="switch"><input type="checkbox" id="theme-switch" /><span class="slider"></span></span>
<span class="lbl" id="lbl-dark">Dark</span>
</label>
</header>
<main id="viewer-pane">
<aside class="rail" id="rail"></aside>
<section class="stagewrap">
<div class="stage">
<button class="nav-btn prev" id="prev" aria-label="Previous">&#8249;</button>
<img id="stage-img" alt="" />
<div class="missing hide" id="missing"></div>
<button class="nav-btn next" id="next" aria-label="Next">&#8250;</button>
</div>
<div class="detail">
<h2 id="view-title"></h2>
<div class="meta" id="view-meta"></div>
<ul class="notes" id="view-notes"></ul>
</div>
</section>
</main>
<section class="summary hide" id="summary-pane"></section>
<script id="data">
window.__WALKTHROUGH__ = /*__DATA__*/{"feature":"No data","branch":"","generated":"","views":[],"findings":{"visual":[],"ux":[]}}/*__END__*/;
</script>
<script>
(function () {
var D = window.__WALKTHROUGH__ || { views: [], findings: { visual: [], ux: [] } };
var views = D.views || [];
var state = { i: 0, theme: localStorage.getItem("ui-wt-theme") || "light", tab: "viewer" };
var $ = function (id) { return document.getElementById(id); };
function sevClass(s) { return s === "high" ? "high" : s === "med" || s === "medium" ? "med" : "low"; }
function applyChrome() {
document.documentElement.setAttribute("data-theme", state.theme);
$("theme-switch").checked = state.theme === "dark";
$("lbl-light").classList.toggle("on", state.theme === "light");
$("lbl-dark").classList.toggle("on", state.theme === "dark");
}
function srcFor(v) { return state.theme === "dark" ? (v.dark || v.light) : (v.light || v.dark); }
function findingsForView(id) {
var all = (D.findings && D.findings.visual || []).concat(D.findings && D.findings.ux || []);
return all.filter(function (f) { return f.view === id; });
}
function renderRail() {
var rail = $("rail");
rail.innerHTML = "";
if (!views.length) { rail.innerHTML = '<div class="group-label">No views captured</div>'; return; }
views.forEach(function (v, idx) {
var fs = findingsForView(v.id);
var worst = fs.some(function (f){return sevClass(f.severity)==="high";}) ? "var(--hi)"
: fs.some(function (f){return sevClass(f.severity)==="med";}) ? "var(--med)"
: fs.length ? "var(--low)" : "transparent";
var el = document.createElement("div");
el.className = "thumb" + (idx === state.i ? " active" : "");
el.innerHTML = '<img src="' + srcFor(v) + '" alt="" />' +
'<div><div class="t">' + (v.title || v.id) + '</div>' +
'<div class="badge">' + (v.viewport || "") + '</div></div>' +
'<span class="dot" style="background:' + worst + '"></span>';
el.onclick = function () { state.i = idx; render(); };
rail.appendChild(el);
});
}
function render() {
applyChrome();
if (!views.length) {
$("missing").classList.remove("hide"); $("stage-img").classList.add("hide");
$("missing").textContent = "No screenshots in this report yet.";
$("counter").textContent = ""; return;
}
var v = views[state.i];
var src = srcFor(v);
var img = $("stage-img");
if (src) {
img.classList.remove("hide"); $("missing").classList.add("hide");
img.src = src; img.alt = v.title || v.id;
} else {
img.classList.add("hide"); $("missing").classList.remove("hide");
$("missing").textContent = "No " + state.theme + " screenshot for this view.";
}
$("counter").textContent = (state.i + 1) + " / " + views.length;
$("view-title").textContent = v.title || v.id;
$("view-meta").textContent = [v.viewport, state.theme + " mode"].filter(Boolean).join(" · ");
var notes = $("view-notes"); notes.innerHTML = "";
var fs = findingsForView(v.id);
(v.notes || []).forEach(function (n) {
var li = document.createElement("li"); li.textContent = "· " + n; notes.appendChild(li);
});
fs.forEach(function (f) {
var li = document.createElement("li"); li.className = "finding";
li.innerHTML = '<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
'<span><b>' + (f.title || "") + '</b> — ' + (f.detail || "") +
(f.fix ? ' <span class="fix"><b>Fix:</b> ' + f.fix + '</span>' : '') + '</span>';
notes.appendChild(li);
});
$("prev").disabled = state.i === 0;
$("next").disabled = state.i === views.length - 1;
renderRail();
}
function renderSummary() {
var pane = $("summary-pane");
function block(title, arr) {
var h = '<h2>' + title + ' (' + arr.length + ')</h2>';
if (!arr.length) return h + '<div class="empty">None found.</div>';
return h + arr.map(function (f) {
return '<div class="card"><div class="head">' +
'<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
'<b>' + (f.title || "") + '</b>' +
(f.view ? ' <a data-jump="' + f.view + '">' + f.view + '</a>' : '') + '</div>' +
'<div style="margin-top:6px">' + (f.detail || "") + '</div>' +
(f.fix ? '<div class="finding" style="margin-top:6px"><span class="fix"><b>Fix:</b> ' + f.fix + '</span></div>' : '') +
'</div>';
}).join("");
}
pane.innerHTML = block("Visual & consistency", (D.findings && D.findings.visual) || []) +
block("UX & ease of use", (D.findings && D.findings.ux) || []);
pane.querySelectorAll("[data-jump]").forEach(function (a) {
a.onclick = function () {
var id = a.getAttribute("data-jump");
var idx = views.findIndex(function (v) { return v.id === id; });
if (idx >= 0) { state.i = idx; setTab("viewer"); }
};
});
}
function setTab(t) {
state.tab = t;
document.querySelectorAll(".tab").forEach(function (b) { b.classList.toggle("active", b.dataset.tab === t); });
$("viewer-pane").classList.toggle("hide", t !== "viewer");
$("summary-pane").classList.toggle("hide", t !== "summary");
if (t === "viewer") $("viewer-pane").style.display = "grid";
if (t === "summary") renderSummary();
}
// wiring
$("feature-title").textContent = D.feature || "UI Walkthrough";
$("feature-sub").textContent = [D.branch, D.generated].filter(Boolean).join(" · ");
$("theme-switch").onchange = function () {
state.theme = this.checked ? "dark" : "light";
localStorage.setItem("ui-wt-theme", state.theme);
render();
};
$("prev").onclick = function () { if (state.i > 0) { state.i--; render(); } };
$("next").onclick = function () { if (state.i < views.length - 1) { state.i++; render(); } };
document.addEventListener("keydown", function (e) {
if (state.tab !== "viewer") return;
if (e.key === "ArrowLeft") $("prev").click();
if (e.key === "ArrowRight") $("next").click();
if (e.key.toLowerCase() === "t") $("theme-switch").click();
});
document.querySelectorAll(".tab").forEach(function (b) { b.onclick = function () { setTab(b.dataset.tab); }; });
render();
})();
</script>
</body>
</html>
+20 -4
View File
@@ -116,6 +116,9 @@ jobs:
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
# Single source of truth for whether this preview embeds the admin portal:
# drives the image build-arg and the deployment comment.
BUILD_PORTAL: "true"
steps:
- name: Harden Runner
@@ -246,7 +249,9 @@ jobs:
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
build-args: VERSION_TAG=v2-alpha
build-args: |
VERSION_TAG=v2-alpha
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
platforms: linux/amd64
- name: Build and push V2 image (Docker fork fallback)
@@ -259,7 +264,9 @@ jobs:
cache-from: type=gha,scope=stirling-pdf-latest
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
build-args: VERSION_TAG=v2-alpha
build-args: |
VERSION_TAG=v2-alpha
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
platforms: linux/amd64
- name: Set up SSH
@@ -290,6 +297,8 @@ jobs:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
POLICIES_ENABLED: "true"
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
@@ -359,12 +368,19 @@ jobs:
}
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
// Only mention the portal when this image actually embeds it.
// Use the direct IP URL - the SSL hostname isn't supported yet.
const withPortal = "${{ env.BUILD_PORTAL }}" === "true";
const portalNote = withPortal
? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n`
: ``;
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
`Your V2 PR with embedded architecture has been deployed!\n\n` +
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
`🔐 **Secure HTTPS URL**: unsupported currently\n\n` +
portalNote +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** for approved V2 contributors.`;
@@ -57,6 +57,15 @@ public class RequestUriUtils {
return true;
}
// Admin portal SPA shell. Served publicly like the editor root so a direct
// nav / refresh to /portal loads the app (the JWT lives in localStorage, not
// a cookie, so the server can't authenticate the navigation itself). The
// portal gates access via its own auth gate + RequirePortalAccess, and its
// data APIs stay protected, so serving the shell pre-auth is safe.
if (normalizedUri.equals("/portal") || normalizedUri.startsWith("/portal/")) {
return true;
}
// Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png")
@@ -73,6 +73,14 @@ class RequestUriUtilsTest {
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
}
@Test
void testIsStaticResource_portalShell() {
// The admin portal SPA shell is served pre-auth so it's directly navigable.
assertTrue(RequestUriUtils.isStaticResource("/portal"));
assertTrue(RequestUriUtils.isStaticResource("/portal/users"));
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/portal"));
}
// --- isFrontendRoute tests ---
@Test
+11 -1
View File
@@ -175,6 +175,14 @@ springBoot {
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
def buildPrototypes = project.hasProperty('prototypesMode') && project.property('prototypesMode') == 'true'
// The admin portal ships as a lazy route inside the editor bundle (see
// proprietary/routes/adminRouteExtensions). -PbuildWithPortal=true includes that
// chunk via VITE_INCLUDE_PORTAL on the editor build; the deploy GHA sets it when
// the portal or AI layers change. Building the portal implies building the editor.
def buildWithPortal = project.hasProperty('buildWithPortal') && project.property('buildWithPortal') == 'true'
if (buildWithPortal) {
buildWithFrontend = true
}
// Workspace root holds package.json and node_modules (shared across editor /
// future portal). Editor-specific paths (src, public, dist, tauri) live one
// level deeper under frontend/editor/.
@@ -297,9 +305,11 @@ tasks.register('npmBuild', Exec) {
// Override VITE_API_BASE_URL to use relative paths for production builds
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
environment 'VITE_API_BASE_URL', '/'
// Include the admin portal's lazy route/chunk in the editor build when requested.
environment 'VITE_INCLUDE_PORTAL', (buildWithPortal ? 'true' : 'false')
doFirst {
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)"
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})"
}
}
@@ -130,6 +130,7 @@ public class ControllerAuditAspect {
String previousPrincipal = MDC.get("auditPrincipal");
String previousOrigin = MDC.get("auditOrigin");
String previousSource = MDC.get("auditSource");
String previousIp = MDC.get("auditIp");
// EARLY CAPTURE: Capture from SecurityContext on request thread, store in MDC for async
@@ -161,6 +162,14 @@ public class ControllerAuditAspect {
return joinPoint.proceed();
}
// Stamp the free-UI source only for non-@Audited controller traffic — an actual
// tool / UI action. @Audited events (login, settings) return above without a source,
// so they never count as an "active editor" or a free UI run. The finally block
// restores auditSource, so a pooled thread can't leak a stale "WEB" into them.
if (previousSource == null) {
MDC.put("auditSource", auditService.captureCurrentSource());
}
long start = System.currentTimeMillis();
// Use auditService to create the base audit data
@@ -247,6 +256,7 @@ public class ControllerAuditAspect {
} finally {
restoreMdcValue("auditPrincipal", previousPrincipal);
restoreMdcValue("auditOrigin", previousOrigin);
restoreMdcValue("auditSource", previousSource);
restoreMdcValue("auditIp", previousIp);
}
}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.audit;
import org.springframework.stereotype.Component;
/** Self-hosted default: admins see the whole-server audit log, everyone else is denied. */
@Component
public class DefaultPortalAuditScopeResolver implements PortalAuditScopeResolver {
@Override
public PortalAuditScope resolve() {
return PortalAuditScopeResolver.hasAdminAuthority()
? PortalAuditScope.server()
: PortalAuditScope.denied();
}
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.audit;
import java.time.Instant;
/** Immutable, cacheable projection of an {@code audit_events} row, shared across portal views. */
public record PortalAuditEventRow(
long id, String principal, String type, String data, Instant timestamp) {}
@@ -0,0 +1,21 @@
package stirling.software.proprietary.audit;
import java.util.List;
/** Resolved audit visibility: fullServer (admin), principals-scoped (team lead), or !allowed. */
public record PortalAuditScope(
boolean allowed, boolean fullServer, List<String> principals, String cacheKey) {
public static PortalAuditScope denied() {
return new PortalAuditScope(false, false, List.of(), "denied");
}
// Named server()/team() to avoid colliding with the record's fullServer() accessor.
public static PortalAuditScope server() {
return new PortalAuditScope(true, true, List.of(), "server");
}
public static PortalAuditScope team(String cacheKey, List<String> principals) {
return new PortalAuditScope(true, false, List.copyOf(principals), cacheKey);
}
}
@@ -0,0 +1,18 @@
package stirling.software.proprietary.audit;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
/** Resolves which slice of the audit log the caller may see. */
public interface PortalAuditScopeResolver {
PortalAuditScope resolve();
/** True when the current authentication carries {@code ROLE_ADMIN}. */
static boolean hasAdminAuthority() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null
&& auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
}
}
@@ -60,6 +60,8 @@ public class CustomAuditEventRepository implements AuditEventRepository {
clean.put("requestId", rid);
}
String source = MDC.get("auditSource");
String auditEventData = mapper.writeValueAsString(clean);
log.debug("AuditEvent data (JSON): {}", auditEventData);
@@ -67,6 +69,7 @@ public class CustomAuditEventRepository implements AuditEventRepository {
PersistentAuditEvent.builder()
.principal(safePrincipal(ev.getPrincipal()))
.type(ev.getType())
.source(source)
.data(auditEventData)
.timestamp(ev.getTimestamp())
.build();
@@ -0,0 +1,71 @@
package stirling.software.proprietary.controller.api;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.model.api.usage.FleetUsageStats;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
import stirling.software.proprietary.security.database.repository.UserRepository;
/**
* Admin endpoint exposing free-editor fleet usage for the portal Usage card. Audit-derived figures
* (active editors, PDFs processed) are null (rendered as "N/A") rather than a misleading 0 whenever
* the data can't exist: the events they count (PDF_PROCESS, FILE_OPERATION, HTTP_REQUEST) are all
* STANDARD level, so a gate on {@code isEnabled()} alone would still return 0 at level=OFF/BASIC —
* we gate on {@code isLevelEnabled(STANDARD)} instead.
*
* <p>Known limitation: on a login-disabled self-hosted instance every request is anonymous, so its
* audit origin is SYSTEM (not WEB) and it is excluded from these WEB-only counts — active/PDFs then
* read 0 despite real usage. Historical audit rows written before the {@code source} column existed
* carry {@code source=null}, so the cumulative "PDFs edited" figure effectively starts at deploy.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/usage")
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class FleetUsageController {
private final PersistentAuditEventRepository auditRepository;
private final UserRepository userRepository;
private final AuditConfigurationProperties auditConfig;
@GetMapping("/fleet-stats")
public FleetUsageStats fleetStats() {
// Exclude the reserved INTERNAL_API_USER row that InitialSecuritySetup creates on every
// install, so a fresh single-admin instance reads 1 editor, not 2.
Long deployed = userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId());
// STANDARD is the level at which the counted events are recorded; below it the data
// can't exist, so report N/A instead of a 0 that would misrepresent an empty table.
boolean auditOn = auditConfig.isLevelEnabled(AuditLevel.STANDARD);
Instant since = Instant.now().minus(30, ChronoUnit.DAYS);
Long active =
auditOn
? auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter(
"WEB", "UI_DATA", since)
: null;
Long pdfs =
auditOn
? auditRepository.countByTypeInAndSourceAndTimestampAfter(
List.of("PDF_PROCESS", "FILE_OPERATION"), "WEB", Instant.EPOCH)
: null;
if (active != null && deployed != null && active > deployed) {
active = deployed; // active editors are a subset of those deployed
}
return new FleetUsageStats(deployed, active, pdfs);
}
}
@@ -0,0 +1,121 @@
package stirling.software.proprietary.controller.api;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import io.swagger.v3.oas.annotations.Operation;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.PipelineApi;
import stirling.software.common.configuration.RuntimePathConfig;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@PipelineApi
public class PipelineConfigController {
private final RuntimePathConfig runtimePathConfig;
private final ObjectMapper objectMapper;
public PipelineConfigController(
RuntimePathConfig runtimePathConfig, ObjectMapper objectMapper) {
this.runtimePathConfig = runtimePathConfig;
this.objectMapper = objectMapper;
}
@PostMapping("/watched-folders/config")
@PreAuthorize("hasRole('ADMIN')")
@Operation(
summary =
"Save a folder-scanning pipeline config into pipeline/watchedFolders (optionally in a subfolder)")
public ResponseEntity<?> saveConfigToWatchedFolder(
@RequestBody SaveWatchedFolderConfigRequest request) {
try {
if (request == null || request.getConfig() == null) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Missing request body or config payload"));
}
Path watchedRoot =
Paths.get(runtimePathConfig.getPipelineWatchedFoldersPath())
.toAbsolutePath()
.normalize();
String subfolder = request.getSubfolder() == null ? "" : request.getSubfolder().trim();
Path targetDir =
subfolder.isEmpty()
? watchedRoot
: watchedRoot.resolve(subfolder).toAbsolutePath().normalize();
if (!targetDir.startsWith(watchedRoot)) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid subfolder path"));
}
Files.createDirectories(targetDir);
String safeFileName = sanitizeFileName(request.getFileName());
Path targetFile = targetDir.resolve(safeFileName).toAbsolutePath().normalize();
if (!targetFile.startsWith(watchedRoot)) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Invalid target file path"));
}
String json =
objectMapper
.writerWithDefaultPrettyPrinter()
.writeValueAsString(request.getConfig());
Files.writeString(targetFile, json, StandardCharsets.UTF_8);
return ResponseEntity.ok(
Map.of(
"success",
true,
"savedPath",
targetFile.toString(),
"fileName",
safeFileName));
} catch (IOException e) {
log.error("Failed to write pipeline config to watched folder", e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Failed to write config file"));
}
}
private String sanitizeFileName(String fileName) {
String base =
fileName == null || fileName.isBlank()
? "automation.folder-scan.json"
: fileName.trim();
String sanitized = base.replaceAll("[\\\\/:*?\"<>|]", "_");
if (!sanitized.toLowerCase().endsWith(".json")) {
sanitized = sanitized + ".json";
}
if (sanitized.length() > 200) {
sanitized = sanitized.substring(0, 200);
if (!sanitized.toLowerCase().endsWith(".json")) {
sanitized = sanitized + ".json";
}
}
return sanitized;
}
@Data
public static class SaveWatchedFolderConfigRequest {
private String subfolder;
private String fileName;
private Object config;
}
}
@@ -0,0 +1,46 @@
package stirling.software.proprietary.controller.api;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
import stirling.software.proprietary.audit.PortalAuditScope;
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
import stirling.software.proprietary.service.PortalDocumentsService;
/** Serves the portal Documents review queue, derived from real audit data and scoped per caller. */
@ProprietaryUiDataApi
@RequiredArgsConstructor
@EnterpriseEndpoint
public class PortalDocumentsController {
private final PortalDocumentsService portalDocumentsService;
private final PortalAuditScopeResolver auditScopeResolver;
// tier accepted for mock-seam symmetry; ignored (queue isn't tier-scoped).
@GetMapping("/documents")
@Operation(
summary = "Documents review queue",
description = "Files processed through the org, derived from the audit trail.")
public ResponseEntity<PortalDocumentsResponseDto> getDocuments(
@RequestParam(value = "tier", required = false) String tier) {
PortalAuditScope scope = auditScopeResolver.resolve();
if (!scope.allowed()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
PortalDocumentsResponseDto body =
scope.fullServer()
? portalDocumentsService.serverDocuments()
: portalDocumentsService.scopedDocuments(
scope.cacheKey(), scope.principals());
return ResponseEntity.ok(body);
}
}
@@ -0,0 +1,47 @@
package stirling.software.proprietary.controller.api;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
import stirling.software.proprietary.audit.PortalAuditScope;
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
import stirling.software.proprietary.model.api.audit.InfraAuditLogResponse;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
import stirling.software.proprietary.service.PortalInfraAuditService;
/** Serves the Infrastructure → Audit tab from real audit data, scoped and cached per caller. */
@ProprietaryUiDataApi
@RequiredArgsConstructor
@EnterpriseEndpoint
public class PortalInfraAuditController {
private final PortalInfraAuditService portalInfraAuditService;
private final PortalAuditScopeResolver auditScopeResolver;
// tier accepted for endpoint symmetry; ignored (audit log isn't tier-scoped).
@GetMapping("/infrastructure/audit-log")
@Operation(
summary = "Infrastructure audit log",
description = "Recent audit events shaped for the portal Infrastructure → Audit tab.")
public ResponseEntity<InfraAuditLogResponse> getInfrastructureAuditLog(
@RequestParam(value = "tier", required = false) String tier) {
PortalAuditScope scope = auditScopeResolver.resolve();
if (!scope.allowed()) {
// Return 403 (not throw) so the tab shows its access message, not a generic 500.
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
InfraAuditLogResponse body =
scope.fullServer()
? portalInfraAuditService.serverAuditLog()
: portalInfraAuditService.scopedAuditLog(
scope.cacheKey(), scope.principals());
return ResponseEntity.ok(body);
}
}
@@ -0,0 +1,44 @@
package stirling.software.proprietary.model.api.audit;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* A single infrastructure audit-log row, shaped for the portal Infrastructure → Audit tab. Derived
* from a {@code audit_events} row: the real {@link
* stirling.software.proprietary.audit.AuditEventType} is mapped to a display category/action.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InfraAuditEventDto {
@Schema(description = "Audit event id", example = "8841")
private String id;
@Schema(description = "Display timestamp (UTC)", example = "2026-07-07 18:59:31")
private String timestamp;
@Schema(description = "Category: auth | config | elevation | processing | security")
private String category;
@Schema(description = "Human-readable action", example = "Compress PDF")
private String action;
@Schema(description = "Actor principal", example = "alice.chen@acme.com")
private String actor;
@Schema(description = "Affected target (file, endpoint, or session)")
private String target;
@Schema(description = "Status: success | warning | danger | info")
private String status;
@Schema(description = "Operation latency in milliseconds", example = "412")
private long latencyMs;
}
@@ -0,0 +1,30 @@
package stirling.software.proprietary.model.api.audit;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Response for the portal Infrastructure → Audit tab: summary strip + recent event rows. */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InfraAuditLogResponse {
@Schema(description = "Headline counts")
private InfraAuditSummary summary;
@Schema(description = "Most-recent audit events, newest first")
private List<InfraAuditEventDto> events;
@Schema(
description =
"True when this is the whole-server (admin) view. Team-scoped views are false; "
+ "drives whether the admin-only, whole-server CSV export is offered.")
private boolean fullServer;
}
@@ -0,0 +1,28 @@
package stirling.software.proprietary.model.api.audit;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Headline counts for the infrastructure audit-log tab, derived from the returned events. */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InfraAuditSummary {
@Schema(description = "Total events in the returned window", example = "40")
private int totalEvents;
@Schema(description = "Processing-category events", example = "24")
private int processing;
@Schema(description = "Elevation-category events", example = "0")
private int elevation;
@Schema(description = "Config-category events", example = "6")
private int config;
}
@@ -0,0 +1,24 @@
package stirling.software.proprietary.model.api.documents;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** One event in a document's lifecycle timeline. */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PortalDocAuditEventDto {
private String id;
/** ingested | extracted | flagged | reviewed | approved | archived | elevation */
private String kind;
/** Relative-time string, e.g. "2m ago". */
private String time;
private String actor;
private String detail;
}
@@ -0,0 +1,18 @@
package stirling.software.proprietary.model.api.documents;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Response for the portal Documents review queue. */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PortalDocumentsResponseDto {
private PortalDocumentsSummaryDto summary;
private List<PortalReviewDocumentDto> documents;
}
@@ -0,0 +1,18 @@
package stirling.software.proprietary.model.api.documents;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** KPI strip for the documents queue. */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PortalDocumentsSummaryDto {
private int totalInQueue;
private int processed;
private int errors;
private int processedToday;
}
@@ -0,0 +1,17 @@
package stirling.software.proprietary.model.api.documents;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** A single extracted field. Empty for audit-derived documents (no extraction data yet). */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PortalExtractionDto {
private String field;
private String value;
private double confidence;
}
@@ -0,0 +1,48 @@
package stirling.software.proprietary.model.api.documents;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* A document in the review queue, derived from the audit trail of a processed file. Extraction
* fields ({@code confidence}, {@code extractions}) are absent/empty - that data doesn't exist yet.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PortalReviewDocumentDto {
private String id;
private String name;
private String type;
/** Where it was processed: "API" or "Editor". */
private String product;
/** The operation / pipeline, e.g. "Compress PDF" (or "Editor"). */
private String action;
/** The user who ran it. */
private String user;
/** processed | error */
private String status;
private String source;
/** Overall confidence 0..1, or null when there's no extraction data. */
private Double confidence;
private int fieldsExtracted;
/** Relative-time string, e.g. "4m ago". */
private String time;
private boolean sensitive;
private List<PortalExtractionDto> extractions;
private List<PortalDocAuditEventDto> audit;
}
@@ -0,0 +1,6 @@
package stirling.software.proprietary.model.api.usage;
/**
* Free-editor fleet usage for the portal Usage card. Null fields render as "N/A" (uncomputable).
*/
public record FleetUsageStats(Long editorsDeployed, Long activeThisMonth, Long pdfsProcessed) {}
@@ -18,7 +18,15 @@ import lombok.*;
columnList = "principal,type"),
@jakarta.persistence.Index(
name = "idx_audit_type_timestamp",
columnList = "type,timestamp")
columnList = "type,timestamp"),
@jakarta.persistence.Index(
name = "idx_audit_type_source_timestamp",
columnList = "type,source,timestamp"),
// Leads with source (equality) for the active-editors query, which filters on
// source then a timestamp range and counts distinct principal.
@jakarta.persistence.Index(
name = "idx_audit_source_timestamp_principal",
columnList = "source,timestamp,principal")
})
@Data
@Builder
@@ -32,6 +40,7 @@ public class PersistentAuditEvent {
private String principal;
private String type;
private String source;
@Column(columnDefinition = "text")
private String data; // JSON blob
@@ -259,4 +259,21 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
"SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType AND e.timestamp > :startDate")
List<PersistentAuditEvent> findAllExceptTypeAndTimestampAfterForExport(
@Param("excludeType") String excludeType, @Param("startDate") Instant startDate);
// Free-editor fleet usage: count genuine free-UI operations (source = "WEB") by type.
@Query(
"SELECT COUNT(e) FROM PersistentAuditEvent e "
+ "WHERE e.type IN :types AND e.source = :source AND e.timestamp > :since")
long countByTypeInAndSourceAndTimestampAfter(
@Param("types") List<String> types,
@Param("source") String source,
@Param("since") Instant since);
@Query(
"SELECT COUNT(DISTINCT e.principal) FROM PersistentAuditEvent e "
+ "WHERE e.source = :source AND e.type <> :excludeType AND e.timestamp > :since")
long countDistinctPrincipalsBySourceExcludingTypeAfter(
@Param("source") String source,
@Param("excludeType") String excludeType,
@Param("since") Instant since);
}
@@ -24,6 +24,9 @@ public class CacheConfig {
this.applicationProperties = applicationProperties;
}
/** Short-TTL cache of recent audit rows, shared by every audit-derived portal view. */
private static final String PORTAL_AUDIT_EVENTS_CACHE = "portalAuditEvents";
@Bean
public CacheManager cacheManager() {
int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays();
@@ -33,6 +36,14 @@ public class CacheConfig {
.maximumSize(1000) // Make configurable?
.expireAfterWrite(Duration.ofDays(keyRetentionDays))
.recordStats());
// 30s TTL keeps audit views near-live without re-scanning the DB; one entry per scope.
cacheManager.registerCustomCache(
PORTAL_AUDIT_EVENTS_CACHE,
Caffeine.newBuilder()
.maximumSize(256)
.expireAfterWrite(Duration.ofSeconds(30))
.recordStats()
.build());
return cacheManager;
}
}
@@ -49,6 +49,9 @@ public interface UserRepository extends JpaRepository<User, Long> {
long countByTeam(Team team);
/** Count real users, excluding a reserved username such as the internal API user. */
long countByUsernameNot(String username);
List<User> findAllByTeam(Team team);
// OAuth grandfathering queries
@@ -39,6 +39,7 @@ import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.accountlink.BillableOperationClassifier;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
@@ -847,6 +848,39 @@ public class AuditService {
return origin;
}
/**
* Refines {@link #determineOrigin()} into an audit {@code source} that isolates genuine
* free-editor UI runs from automation/AI traffic that also arrives over the web channel.
*
* <p>API and SYSTEM origins pass through unchanged. A WEB origin is demoted to "AUTOMATION" or
* "AI" when the request carries the automation marker or targets an AI surface; only a manual
* interactive tool call ({@code BYPASSED}) stays "WEB". A "WEB" source therefore counts as a
* free/BYPASSED UI run. The automation/AI resolution is delegated to {@link
* BillableOperationClassifier} so it can't drift from the billing gate's own signal.
*
* <p>IMPORTANT: like {@link #captureCurrentOrigin()} this must be called on the request thread
* before async execution, because it reads the current {@link HttpServletRequest}.
*
* @return "API", "SYSTEM", "AUTOMATION", "AI", or "WEB"
*/
public String captureCurrentSource() {
String origin = determineOrigin();
if (!"WEB".equals(origin)) {
return origin;
}
HttpServletRequest req = getCurrentRequest();
if (req == null) {
return "WEB";
}
// apiKey=false: a WEB origin already means the request is not API-key authenticated.
return switch (BillableOperationClassifier.categorize(req, false)) {
case AUTOMATION -> "AUTOMATION";
case AI -> "AI";
default -> "WEB";
};
}
/**
* Determines the origin of the request: API (X-API-KEY), WEB (JWT), or SYSTEM (no auth).
* IMPORTANT: This must be called in the request thread before async execution.
@@ -0,0 +1,60 @@
package stirling.software.proprietary.service;
import java.util.List;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.audit.PortalAuditEventRow;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
/** One cached read of recent {@code audit_events} per scope, shared by all audit-derived views. */
@Service
@RequiredArgsConstructor
public class PortalAuditReadService {
/** Cache name - registered with a short TTL in CacheConfig. */
public static final String CACHE_NAME = "portalAuditEvents";
/** Newest rows to scan; each surface filters this down to what it shows. */
private static final int SCAN_LIMIT = 400;
private final PersistentAuditEventRepository auditRepository;
/** Recent whole-server events (admins). */
@Cacheable(value = CACHE_NAME, key = "'server'")
public List<PortalAuditEventRow> serverEvents() {
return toRows(auditRepository.findAll(recentPage()).getContent());
}
/** Recent events by the given principals (team scope). Empty principals yield an empty list. */
@Cacheable(value = CACHE_NAME, key = "#cacheKey")
public List<PortalAuditEventRow> scopedEvents(String cacheKey, List<String> principals) {
if (principals.isEmpty()) {
return List.of();
}
return toRows(auditRepository.findByPrincipalIn(principals, recentPage()).getContent());
}
private static PageRequest recentPage() {
return PageRequest.of(0, SCAN_LIMIT, Sort.by(Sort.Direction.DESC, "timestamp"));
}
private static List<PortalAuditEventRow> toRows(List<PersistentAuditEvent> events) {
return events.stream()
.map(
e ->
new PortalAuditEventRow(
e.getId() == null ? 0L : e.getId(),
e.getPrincipal(),
e.getType(),
e.getData(),
e.getTimestamp()))
.toList();
}
}
@@ -0,0 +1,268 @@
package stirling.software.proprietary.service;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.PortalAuditEventRow;
import stirling.software.proprietary.model.api.documents.PortalDocAuditEventDto;
import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto;
import stirling.software.proprietary.model.api.documents.PortalDocumentsSummaryDto;
import stirling.software.proprietary.model.api.documents.PortalReviewDocumentDto;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/** Builds the Documents feed from audit_events: one row per file; extraction fields stay null. */
@Slf4j
@Service
@RequiredArgsConstructor
public class PortalDocumentsService {
private static final int RETURN_LIMIT = 40;
private final PortalAuditReadService auditReadService;
private final ObjectMapper objectMapper;
/** Whole-server view (admins). */
public PortalDocumentsResponseDto serverDocuments() {
return build(auditReadService.serverEvents());
}
/** Team-scoped view: only files touched by {@code principals}. */
public PortalDocumentsResponseDto scopedDocuments(String cacheKey, List<String> principals) {
return build(auditReadService.scopedEvents(cacheKey, principals));
}
private PortalDocumentsResponseDto build(List<PortalAuditEventRow> events) {
// Events arrive newest-first; each file in a processing event is one activity row.
Instant dayAgo = Instant.now().minus(Duration.ofDays(1));
List<PortalReviewDocumentDto> documents = new ArrayList<>();
int processedToday = 0;
for (PortalAuditEventRow event : events) {
if (documents.size() >= RETURN_LIMIT) {
break;
}
if (!isFileBearing(event.type())) {
continue;
}
Map<String, Object> data = parseData(event);
Object files = data.get("files");
if (!(files instanceof List<?> fileList)) {
continue;
}
String path = asString(data.get("path"));
String source = sourceLabel(asString(data.get("__origin")));
String product = "API integration".equals(source) ? "API" : "Editor";
String action = prettyTool(path);
boolean failed = isFailure(data);
Instant ts = event.timestamp();
long eventId = event.id();
int idx = 0;
for (Object f : fileList) {
if (documents.size() >= RETURN_LIMIT) {
break;
}
if (!(f instanceof Map<?, ?> fileMap)) {
continue;
}
String name = asString(fileMap.get("name"));
if (name == null || name.isBlank()) {
continue;
}
documents.add(
toDocument(
eventId + "-" + idx++,
name,
asString(fileMap.get("type")),
product,
action,
event.principal(),
failed,
source,
ts));
if (!failed && ts != null && ts.isAfter(dayAgo)) {
processedToday++;
}
}
}
int processed =
(int) documents.stream().filter(d -> "processed".equals(d.getStatus())).count();
int errors = documents.size() - processed;
PortalDocumentsSummaryDto summary =
PortalDocumentsSummaryDto.builder()
.totalInQueue(documents.size())
.processed(processed)
.errors(errors)
.processedToday(processedToday)
.build();
return PortalDocumentsResponseDto.builder().summary(summary).documents(documents).build();
}
/** Build one activity row from a single file inside one processing event. */
private PortalReviewDocumentDto toDocument(
String rowId,
String name,
String contentType,
String product,
String action,
String user,
boolean failed,
String source,
Instant timestamp) {
PortalDocAuditEventDto op =
PortalDocAuditEventDto.builder()
.id(rowId + "-op")
.kind(failed ? "flagged" : "extracted")
.time(relativeTime(timestamp))
.actor(user)
.detail(failed ? action + " failed" : action + " via " + source)
.build();
return PortalReviewDocumentDto.builder()
.id("doc-" + rowId)
.name(name)
.type(docType(contentType, name))
.product(product)
.action(action)
.user(user)
.status(failed ? "error" : "processed")
.source(source)
.confidence(null)
.fieldsExtracted(0)
.time(relativeTime(timestamp))
// Audit events don't reveal content sensitivity, so never guess it.
.sensitive(false)
.extractions(List.of())
.audit(List.of(op))
.build();
}
private static boolean isFileBearing(String type) {
return AuditEventType.PDF_PROCESS.name().equals(type)
|| AuditEventType.FILE_OPERATION.name().equals(type);
}
private static boolean isFailure(Map<String, Object> data) {
Object status = data.get("status");
if (status instanceof String s && "failure".equalsIgnoreCase(s)) {
return true;
}
Object code = data.get("statusCode");
return code instanceof Number n && n.intValue() >= 400;
}
private static String sourceLabel(String origin) {
if ("API".equals(origin)) {
return "API integration";
}
if ("SYSTEM".equals(origin)) {
return "System";
}
return "Web upload";
}
private static String docType(String contentType, String name) {
String ct = contentType == null ? "" : contentType.toLowerCase(Locale.ROOT);
if (ct.contains("pdf") || name.toLowerCase(Locale.ROOT).endsWith(".pdf")) {
return "PDF";
}
if (ct.startsWith("image/") || name.matches("(?i).*\\.(png|jpe?g|gif|webp|tiff?)$")) {
return "Image";
}
if (ct.contains("word") || name.matches("(?i).*\\.docx?$")) {
return "Word";
}
return "Document";
}
private static String prettyTool(String path) {
if (path == null || path.isBlank()) {
return "Processed";
}
String[] parts = path.split("/");
// Convert endpoints are /convert/{from}/{to}; label them as a conversion.
for (int i = 0; i + 2 < parts.length; i++) {
if ("convert".equals(parts[i]) && !parts[i + 1].isEmpty() && !parts[i + 2].isEmpty()) {
return "Convert " + prettyWords(parts[i + 1]) + " to " + prettyWords(parts[i + 2]);
}
}
String last = parts.length > 0 ? parts[parts.length - 1] : path;
String pretty = prettyWords(last);
return pretty.isEmpty() ? "Processed" : pretty;
}
/** Title-case a hyphenated path segment, upper-casing known acronyms. */
private static String prettyWords(String segment) {
StringBuilder sb = new StringBuilder();
for (String word : segment.split("-")) {
if (word.isEmpty()) {
continue;
}
if (sb.length() > 0) {
sb.append(' ');
}
String lower = word.toLowerCase(Locale.ROOT);
sb.append(
switch (lower) {
case "pdf" -> "PDF";
case "pdfs" -> "PDFs";
case "ocr" -> "OCR";
case "img" -> "Image";
case "csv" -> "CSV";
default -> Character.toUpperCase(word.charAt(0)) + word.substring(1);
});
}
return sb.toString();
}
private static String relativeTime(Instant ts) {
if (ts == null) {
return "";
}
long seconds = Duration.between(ts, Instant.now()).getSeconds();
if (seconds < 60) {
return "just now";
}
long minutes = seconds / 60;
if (minutes < 60) {
return minutes + "m ago";
}
long hours = minutes / 60;
if (hours < 24) {
return hours + "h ago";
}
return (hours / 24) + "d ago";
}
private Map<String, Object> parseData(PortalAuditEventRow event) {
if (event.data() == null || event.data().isEmpty()) {
return Map.of();
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> parsed = objectMapper.readValue(event.data(), Map.class);
// A literal "null" payload parses to null; treat it as empty, not an NPE.
return parsed == null ? Map.of() : parsed;
} catch (JacksonException e) {
log.warn("Failed to parse audit event {} data as JSON", event.id());
return Map.of();
}
}
private static String asString(Object o) {
return o == null ? null : String.valueOf(o);
}
}
@@ -0,0 +1,251 @@
package stirling.software.proprietary.service;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.PortalAuditEventRow;
import stirling.software.proprietary.model.api.audit.InfraAuditEventDto;
import stirling.software.proprietary.model.api.audit.InfraAuditLogResponse;
import stirling.software.proprietary.model.api.audit.InfraAuditSummary;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/** Maps cached audit_events to the Infrastructure → Audit tab, dropping read-noise types. */
@Slf4j
@Service
@RequiredArgsConstructor
public class PortalInfraAuditService {
/** Rows returned to the tab after filtering. */
private static final int RETURN_LIMIT = 40;
private static final DateTimeFormatter TS_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneOffset.UTC);
private final PortalAuditReadService auditReadService;
private final ObjectMapper objectMapper;
/** Whole-server view (admins). */
public InfraAuditLogResponse serverAuditLog() {
return buildFromEvents(auditReadService.serverEvents(), true);
}
/** Team-scoped view: only events by the given principals; empty yields an empty log. */
public InfraAuditLogResponse scopedAuditLog(String cacheKey, List<String> principals) {
return buildFromEvents(auditReadService.scopedEvents(cacheKey, principals), false);
}
private InfraAuditLogResponse buildFromEvents(
List<PortalAuditEventRow> recent, boolean fullServer) {
List<InfraAuditEventDto> events =
recent.stream()
.filter(e -> isInfraRelevant(e.type()))
.map(this::toDto)
.limit(RETURN_LIMIT)
.toList();
int processing =
(int) events.stream().filter(e -> "processing".equals(e.getCategory())).count();
int elevation =
(int) events.stream().filter(e -> "elevation".equals(e.getCategory())).count();
int config = (int) events.stream().filter(e -> "config".equals(e.getCategory())).count();
InfraAuditSummary summary =
InfraAuditSummary.builder()
.totalEvents(events.size())
.processing(processing)
.elevation(elevation)
.config(config)
.build();
return InfraAuditLogResponse.builder()
.summary(summary)
.events(events)
.fullServer(fullServer)
.build();
}
/** UI_DATA and HTTP_REQUEST are read/polling noise - excluded from the infrastructure view. */
private static boolean isInfraRelevant(String type) {
return !AuditEventType.UI_DATA.name().equals(type)
&& !AuditEventType.HTTP_REQUEST.name().equals(type);
}
private InfraAuditEventDto toDto(PortalAuditEventRow event) {
Map<String, Object> data = parseData(event);
String path = asString(data.get("path"));
String category = categoryFor(event.type(), path);
return InfraAuditEventDto.builder()
.id(String.valueOf(event.id()))
.timestamp(event.timestamp() == null ? "" : TS_FORMAT.format(event.timestamp()))
.category(category)
.action(actionFor(event.type(), path))
.actor(event.principal())
.target(targetFor(category, path, data))
.status(statusFor(event.type(), category, data))
.latencyMs(asLong(data.get("latencyMs")))
.build();
}
private Map<String, Object> parseData(PortalAuditEventRow event) {
if (event.data() == null || event.data().isEmpty()) {
return Map.of();
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> parsed = objectMapper.readValue(event.data(), Map.class);
// A literal "null" payload parses to null; treat it as empty, not an NPE.
return parsed == null ? Map.of() : parsed;
} catch (JacksonException e) {
log.warn("Failed to parse audit event {} data as JSON", event.id());
return Map.of();
}
}
private static String categoryFor(String type, String path) {
AuditEventType t = AuditEventType.fromString(type);
if (t == null) {
return "processing";
}
return switch (t) {
case USER_LOGIN, USER_LOGOUT, USER_FAILED_LOGIN -> "auth";
case SETTINGS_CHANGED, USER_PROFILE_UPDATE -> "config";
case PDF_PROCESS, FILE_OPERATION -> isSecurityPath(path) ? "security" : "processing";
// UI_DATA / HTTP_REQUEST are filtered out before mapping.
default -> "processing";
};
}
private static boolean isSecurityPath(String path) {
if (path == null) {
return false;
}
String p = path.toLowerCase(Locale.ROOT);
return p.contains("/security/")
|| p.contains("password")
|| p.contains("watermark")
|| p.contains("sign")
|| p.contains("cert")
|| p.contains("redact");
}
private static String actionFor(String type, String path) {
AuditEventType t = AuditEventType.fromString(type);
if (t == null) {
return prettyTool(path);
}
return switch (t) {
case USER_LOGIN -> "User signed in";
case USER_LOGOUT -> "User signed out";
case USER_FAILED_LOGIN -> "Failed sign-in attempt";
case USER_PROFILE_UPDATE -> "Profile settings updated";
case SETTINGS_CHANGED -> "Admin settings changed";
case PDF_PROCESS, FILE_OPERATION -> prettyTool(path);
default -> prettyTool(path);
};
}
/** Acronyms/tokens that get special casing when title-casing a tool path. */
private static final Map<String, String> WORD_FIXUPS =
Map.of(
"pdf", "PDF",
"pdfs", "PDFs",
"ocr", "OCR",
"img", "Image",
"csv", "CSV",
"html", "HTML",
"url", "URL",
"xml", "XML");
/** "/api/v1/misc/compress-pdf" → "Compress PDF"; "merge-pdfs" → "Merge PDFs". */
private static String prettyTool(String path) {
if (path == null || path.isBlank()) {
return "PDF operation";
}
String[] parts = path.split("/");
String last = parts.length > 0 ? parts[parts.length - 1] : path;
StringBuilder sb = new StringBuilder();
for (String word : last.split("-")) {
if (word.isEmpty()) {
continue;
}
if (sb.length() > 0) {
sb.append(' ');
}
String lower = word.toLowerCase(Locale.ROOT);
sb.append(
WORD_FIXUPS.getOrDefault(
lower, Character.toUpperCase(word.charAt(0)) + word.substring(1)));
}
return sb.isEmpty() ? "PDF operation" : sb.toString();
}
private static String targetFor(String category, String path, Map<String, Object> data) {
if ("auth".equals(category)) {
// Auth events don't act on a resource; the session is the closest thing.
return "Web session";
}
if ("config".equals(category)) {
return path != null && !path.isBlank() ? path : "System settings";
}
// processing / security: prefer the first affected file name.
String file = firstFileName(data);
if (file != null) {
return file;
}
return path != null && !path.isBlank() ? prettyTool(path) : "Document";
}
private static String statusFor(String type, String category, Map<String, Object> data) {
if (AuditEventType.USER_FAILED_LOGIN.name().equals(type)) {
return "danger";
}
String status = asString(data.get("status"));
Integer code = asInteger(data.get("statusCode"));
if ("failure".equalsIgnoreCase(status) || (code != null && code >= 500)) {
return "danger";
}
if (code != null && code >= 400) {
return "warning";
}
if ("config".equals(category)) {
return "info";
}
return "success";
}
@SuppressWarnings("unchecked")
private static String firstFileName(Map<String, Object> data) {
Object files = data.get("files");
if (files instanceof List<?> list
&& !list.isEmpty()
&& list.get(0) instanceof Map<?, ?> f) {
Object name = ((Map<String, Object>) f).get("name");
return name != null ? String.valueOf(name) : null;
}
return null;
}
private static String asString(Object o) {
return o == null ? null : String.valueOf(o);
}
private static long asLong(Object o) {
return o instanceof Number n ? n.longValue() : 0L;
}
private static Integer asInteger(Object o) {
return o instanceof Number n ? n.intValue() : null;
}
}
@@ -3,12 +3,61 @@ package stirling.software.proprietary.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
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.verify;
import java.time.Instant;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.slf4j.MDC;
import org.springframework.boot.actuate.audit.AuditEvent;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import tools.jackson.databind.json.JsonMapper;
class CustomAuditEventRepositoryTest {
@AfterEach
void clearMdc() {
MDC.clear();
}
@Test
void sourceIsPopulatedFromMdcAuditSource() {
PersistentAuditEventRepository repo = mock(PersistentAuditEventRepository.class);
CustomAuditEventRepository writer =
new CustomAuditEventRepository(repo, JsonMapper.builder().build());
MDC.put("auditSource", "WEB");
writer.add(new AuditEvent(Instant.now(), "admin", "PDF_PROCESS", Map.of("k", "v")));
ArgumentCaptor<PersistentAuditEvent> captor =
ArgumentCaptor.forClass(PersistentAuditEvent.class);
verify(repo).save(captor.capture());
assertEquals("WEB", captor.getValue().getSource());
}
@Test
void sourceIsNullWhenMdcAbsent() {
PersistentAuditEventRepository repo = mock(PersistentAuditEventRepository.class);
CustomAuditEventRepository writer =
new CustomAuditEventRepository(repo, JsonMapper.builder().build());
writer.add(new AuditEvent(Instant.now(), "admin", "PDF_PROCESS", Map.of("k", "v")));
ArgumentCaptor<PersistentAuditEvent> captor =
ArgumentCaptor.forClass(PersistentAuditEvent.class);
verify(repo).save(captor.capture());
assertNull(captor.getValue().getSource());
}
@Test
void shortPrincipalPassesThroughUnchanged() {
assertEquals(
@@ -0,0 +1,108 @@
package stirling.software.proprietary.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.model.api.usage.FleetUsageStats;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
@ExtendWith(MockitoExtension.class)
class FleetUsageControllerTest {
@Mock private PersistentAuditEventRepository auditRepository;
@Mock private UserRepository userRepository;
@Mock private AuditConfigurationProperties auditConfig;
private FleetUsageController controller;
@BeforeEach
void setUp() {
controller = new FleetUsageController(auditRepository, userRepository, auditConfig);
}
@Test
@DisplayName("deployed reflects the user count, excluding the internal API user")
void deployedFromUserCount() {
when(userRepository.countByUsernameNot(anyString())).thenReturn(7L);
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(false);
FleetUsageStats stats = controller.fleetStats();
assertThat(stats.editorsDeployed()).isEqualTo(7L);
verify(userRepository).countByUsernameNot(Role.INTERNAL_API_USER.getRoleId());
}
@Test
@DisplayName("audit-derived figures are null when auditing is below STANDARD")
void auditOffYieldsNulls() {
when(userRepository.countByUsernameNot(anyString())).thenReturn(3L);
// Covers both disabled and the enabled-but-level=OFF/BASIC misconfig: isLevelEnabled
// is false, so no events can exist and we must report N/A, not a 0 from an empty table.
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(false);
FleetUsageStats stats = controller.fleetStats();
assertThat(stats.activeThisMonth()).isNull();
assertThat(stats.pdfsProcessed()).isNull();
verify(auditRepository, never())
.countDistinctPrincipalsBySourceExcludingTypeAfter(
any(), any(), any(Instant.class));
verify(auditRepository, never())
.countByTypeInAndSourceAndTimestampAfter(anyList(), any(), any(Instant.class));
}
@Test
@DisplayName("audit-derived figures come from the repository when auditing is enabled")
void auditOnReadsRepository() {
when(userRepository.countByUsernameNot(anyString())).thenReturn(10L);
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true);
when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter(
eq("WEB"), eq("UI_DATA"), any(Instant.class)))
.thenReturn(4L);
when(auditRepository.countByTypeInAndSourceAndTimestampAfter(
anyList(), eq("WEB"), any(Instant.class)))
.thenReturn(1234L);
FleetUsageStats stats = controller.fleetStats();
assertThat(stats.editorsDeployed()).isEqualTo(10L);
assertThat(stats.activeThisMonth()).isEqualTo(4L);
assertThat(stats.pdfsProcessed()).isEqualTo(1234L);
}
@Test
@DisplayName("active editors are clamped to deployed (active is a subset)")
void activeClampedToDeployed() {
when(userRepository.countByUsernameNot(anyString())).thenReturn(2L);
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true);
when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter(
eq("WEB"), eq("UI_DATA"), any(Instant.class)))
.thenReturn(9L);
when(auditRepository.countByTypeInAndSourceAndTimestampAfter(
anyList(), eq("WEB"), any(Instant.class)))
.thenReturn(50L);
FleetUsageStats stats = controller.fleetStats();
assertThat(stats.activeThisMonth()).isEqualTo(2L);
}
}
@@ -0,0 +1,131 @@
package stirling.software.proprietary.controller.api;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.common.configuration.RuntimePathConfig;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectWriter;
@ExtendWith(MockitoExtension.class)
class PipelineConfigControllerTest {
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private ObjectMapper objectMapper;
@Mock private ObjectWriter objectWriter;
@TempDir Path tempDir;
@Test
void saveConfigToWatchedFolder_returnsBadRequest_whenRequestIsNull() {
PipelineConfigController controller =
new PipelineConfigController(runtimePathConfig, objectMapper);
ResponseEntity<?> response = controller.saveConfigToWatchedFolder(null);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
Map<?, ?> body = (Map<?, ?>) response.getBody();
assertNotNull(body);
assertEquals("Missing request body or config payload", body.get("error"));
}
@Test
void saveConfigToWatchedFolder_returnsBadRequest_whenConfigIsNull() {
PipelineConfigController controller =
new PipelineConfigController(runtimePathConfig, objectMapper);
PipelineConfigController.SaveWatchedFolderConfigRequest request =
new PipelineConfigController.SaveWatchedFolderConfigRequest();
request.setConfig(null);
ResponseEntity<?> response = controller.saveConfigToWatchedFolder(request);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
Map<?, ?> body = (Map<?, ?>) response.getBody();
assertNotNull(body);
assertEquals("Missing request body or config payload", body.get("error"));
}
@Test
void saveConfigToWatchedFolder_returnsBadRequest_whenSubfolderEscapesWatchedRoot() {
when(runtimePathConfig.getPipelineWatchedFoldersPath()).thenReturn(tempDir.toString());
PipelineConfigController controller =
new PipelineConfigController(runtimePathConfig, objectMapper);
PipelineConfigController.SaveWatchedFolderConfigRequest request =
new PipelineConfigController.SaveWatchedFolderConfigRequest();
request.setSubfolder("../outside");
request.setFileName("config");
request.setConfig(Map.of("enabled", true));
ResponseEntity<?> response = controller.saveConfigToWatchedFolder(request);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
Map<?, ?> body = (Map<?, ?>) response.getBody();
assertNotNull(body);
assertEquals("Invalid subfolder path", body.get("error"));
}
@Test
void saveConfigToWatchedFolder_savesFileAndReturnsSuccess() throws IOException {
when(runtimePathConfig.getPipelineWatchedFoldersPath()).thenReturn(tempDir.toString());
when(objectMapper.writerWithDefaultPrettyPrinter()).thenReturn(objectWriter);
when(objectWriter.writeValueAsString(any())).thenReturn("{\"k\":\"v\"}");
PipelineConfigController controller =
new PipelineConfigController(runtimePathConfig, objectMapper);
PipelineConfigController.SaveWatchedFolderConfigRequest request =
new PipelineConfigController.SaveWatchedFolderConfigRequest();
request.setSubfolder("incoming");
request.setFileName("my:config");
request.setConfig(Map.of("k", "v"));
ResponseEntity<?> response = controller.saveConfigToWatchedFolder(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
Map<?, ?> body = (Map<?, ?>) response.getBody();
assertNotNull(body);
assertEquals(true, body.get("success"));
assertEquals("my_config.json", body.get("fileName"));
Path expectedFile = tempDir.resolve("incoming").resolve("my_config.json");
assertTrue(Files.exists(expectedFile));
assertEquals("{\"k\":\"v\"}", Files.readString(expectedFile));
}
@Test
void saveConfigToWatchedFolder_returnsInternalServerError_whenWriteFails() throws IOException {
Path watchedRootAsFile = tempDir.resolve("watched-root-file");
Files.writeString(watchedRootAsFile, "not-a-directory");
when(runtimePathConfig.getPipelineWatchedFoldersPath())
.thenReturn(watchedRootAsFile.toString());
PipelineConfigController controller =
new PipelineConfigController(runtimePathConfig, objectMapper);
PipelineConfigController.SaveWatchedFolderConfigRequest request =
new PipelineConfigController.SaveWatchedFolderConfigRequest();
request.setSubfolder("incoming");
request.setFileName("config");
request.setConfig(Map.of("k", "v"));
ResponseEntity<?> response = controller.saveConfigToWatchedFolder(request);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
Map<?, ?> body = (Map<?, ?>) response.getBody();
assertNotNull(body);
assertEquals("Failed to write config file", body.get("error"));
}
}
@@ -35,6 +35,7 @@ import jakarta.servlet.http.HttpServletResponse;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.InternalApiClient;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
@@ -83,6 +84,61 @@ class AuditServiceTest {
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
}
@Nested
@DisplayName("captureCurrentSource()")
class CaptureSource {
@Test
@DisplayName("a plain authenticated web tool call is WEB")
void plainWebIsWeb() {
authenticateAs("alice");
bindRequest(new MockHttpServletRequest("POST", "/api/v1/general/merge-pdfs"));
assertThat(service.captureCurrentSource()).isEqualTo("WEB");
}
@Test
@DisplayName("the automation marker header demotes WEB to AUTOMATION")
void automationHeaderIsAutomation() {
authenticateAs("alice");
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/api/v1/general/merge");
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true");
bindRequest(req);
assertThat(service.captureCurrentSource()).isEqualTo("AUTOMATION");
}
@Test
@DisplayName("the AI surface demotes WEB to AI")
void aiSurfaceIsAi() {
authenticateAs("alice");
bindRequest(new MockHttpServletRequest("POST", "/api/v1/ai/tools/ask"));
assertThat(service.captureCurrentSource()).isEqualTo("AI");
}
@Test
@DisplayName("the AI surface is matched after stripping the deployment context path")
void aiSurfaceWithContextPathIsAi() {
authenticateAs("alice");
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/ask");
req.setContextPath("/stirling");
req.setRequestURI("/stirling/api/v1/ai/tools/ask");
bindRequest(req);
assertThat(service.captureCurrentSource()).isEqualTo("AI");
}
@Test
@DisplayName("an unauthenticated request is SYSTEM, not WEB")
void anonymousIsSystem() {
bindRequest(new MockHttpServletRequest("POST", "/api/v1/general/merge-pdfs"));
assertThat(service.captureCurrentSource()).isEqualTo("SYSTEM");
}
}
@Nested
@DisplayName("audit() gating")
class AuditGating {
@@ -2,9 +2,12 @@ package stirling.software.saas.procurement.api;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
@@ -12,6 +15,7 @@ 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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -72,24 +76,28 @@ public class ProcurementController {
public record QuoteRequest(
long volume,
int users,
int intensity, // policy posture (runs/PDF): 2 / 4 / 7; 0 → default Governed
String deployment,
int termYears,
String serviceLevel,
boolean indemnification,
boolean training,
boolean qbr,
boolean offlineLicense,
String currency,
String businessName) {
QuoteConfig toConfig() {
return new QuoteConfig(
volume,
users,
intensity,
deployment,
termYears,
serviceLevel,
indemnification,
training,
qbr,
offlineLicense,
currency);
}
}
@@ -115,12 +123,14 @@ public class ProcurementController {
public record QuoteConfigEcho(
long volume,
int users,
int intensity,
String deployment,
int termYears,
String serviceLevel,
boolean indemnification,
boolean training,
boolean qbr,
boolean offlineLicense,
String currency,
String businessName) {}
@@ -131,6 +141,7 @@ public class ProcurementController {
String trialEndsAt,
int trialExtensionsUsed,
boolean licensed,
String licenseKey,
QuoteResponse latestQuote) {}
// ---- endpoints ----------------------------------------------------------
@@ -143,21 +154,49 @@ public class ProcurementController {
@GetMapping
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> snapshot(Authentication auth) {
Long teamId = resolveTeam(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
Optional<TeamMembership> membership = primaryMembership(auth);
if (membership.isEmpty()) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
Long teamId = membership.get().getTeam().getId();
// The licence key is the team's secret entitlement — leader-only. Members still see the
// journey (stage, trial, quote) but the key is withheld; the .lic file is likewise gated.
boolean leader = membership.get().getRole() == TeamRole.LEADER;
return ResponseEntity.ok(
procurement.getDeal(teamId).map(this::toSnapshot).orElse(EMPTY_SNAPSHOT));
procurement.getDeal(teamId).map(d -> toSnapshot(d, leader)).orElse(EMPTY_SNAPSHOT));
}
private static final SnapshotResponse EMPTY_SNAPSHOT =
new SnapshotResponse(null, null, null, null, 0, false, null);
new SnapshotResponse(null, null, null, null, 0, false, null, null);
/**
* Download the offline / air-gapped licence file (.lic) for the team, when the paid offline
* add-on was purchased. 404 when there's no licence or the add-on wasn't taken — we don't leak
* that a licence exists to a team without the add-on.
*/
@GetMapping("/license/file")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<String> licenseFile(Authentication auth) {
// Leader-only: the offline .lic is the team's portable entitlement, not a member artefact.
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return procurement
.offlineLicenseFile(teamId)
.<ResponseEntity<String>>map(
cert ->
ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"stirling-enterprise.lic\"")
.contentType(MediaType.TEXT_PLAIN)
.body(cert))
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping("/trial/start")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<SnapshotResponse> startTrial(Authentication auth) {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId)));
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId), true));
}
@PostMapping("/trial/extend")
@@ -166,7 +205,7 @@ public class ProcurementController {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
try {
return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId)));
return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId), true));
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
@@ -197,12 +236,30 @@ public class ProcurementController {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
try {
return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId)));
return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId), true));
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
/**
* Provision on accept: upgrade the team's licence to the committed annual term, valid
* immediately. Called server-side by the accept edge function (ROLE_ADMIN via X-API-Key) once
* the subscription + invoice exist, so the buyer is licensed the moment they accept — the deal
* stays in the payment step until the invoice settles. Idempotent.
*/
@PostMapping("/provision")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> provision(@RequestParam("teamId") long teamId) {
try {
procurement.provisionLicense(teamId);
return ResponseEntity.ok().build();
} catch (IllegalStateException e) {
log.warn("[procurement] provision rejected team={}: {}", teamId, e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
/**
* Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the
* annual licence, advance to active). The real go-live is webhook-driven once payment settles.
@@ -214,7 +271,7 @@ public class ProcurementController {
Long teamId = requireLeader(auth);
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
try {
return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId)));
return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId), true));
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
@@ -233,34 +290,31 @@ public class ProcurementController {
// ---- helpers ------------------------------------------------------------
/**
* Resolve the caller's team from their primary membership; null when unauthenticated/teamless.
*/
private Long resolveTeam(Authentication auth) {
/** The caller's primary team membership; empty when unauthenticated/teamless. */
private Optional<TeamMembership> primaryMembership(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return null;
return Optional.empty();
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
return rows.isEmpty() ? null : rows.get(0).getTeam().getId();
return memberRepo.findPrimaryMembership(user.getId()).stream().findFirst();
}
/** Team id only when the caller is the team leader; null otherwise (commercial actions). */
private Long requireLeader(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return null;
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty() || rows.get(0).getRole() != TeamRole.LEADER) return null;
return rows.get(0).getTeam().getId();
return primaryMembership(auth)
.filter(m -> m.getRole() == TeamRole.LEADER)
.map(m -> m.getTeam().getId())
.orElse(null);
}
private SnapshotResponse toSnapshot(ProcurementDeal deal) {
/**
* Build the snapshot for a deal. {@code includeLicenseKey} is true only for the team leader; a
* member sees {@code licensed} but not the key itself (see {@link #snapshot}). Mutation
* endpoints are leader-gated, so they always pass true.
*/
private SnapshotResponse toSnapshot(ProcurementDeal deal, boolean includeLicenseKey) {
QuoteResponse latest =
procurement.quotesForDeal(deal.getDealId()).stream()
.findFirst()
@@ -273,6 +327,7 @@ public class ProcurementController {
str(deal.getTrialEndsAt()),
deal.getTrialExtensionsUsed(),
deal.getLicenseRef() != null,
includeLicenseKey ? deal.getLicenseRef() : null,
latest);
}
@@ -291,12 +346,14 @@ public class ProcurementController {
new QuoteConfigEcho(
q.getVolume(),
0,
q.getIntensity(),
q.getDeployment(),
q.getTermYears(),
q.getServiceLevel(),
q.isIndemnification(),
q.isTraining(),
q.isQbr(),
q.isOfflineLicense(),
q.getCurrency(),
q.getBusinessName()));
}
@@ -0,0 +1,55 @@
package stirling.software.saas.procurement.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.Getter;
import lombok.Setter;
/**
* Keygen credentials + policy for issuing enterprise procurement licences directly from Java.
* Prefix {@code stirling.keygen}. All secrets come from the environment (relaxed binding: {@code
* STIRLING_KEYGEN_ACCOUNT_ID}, {@code STIRLING_KEYGEN_API_TOKEN}, …) — never committed.
*
* <p>{@code enabled} is the switch between {@code MockEnterpriseLicenseService} (default) and the
* real {@code KeygenEnterpriseLicenseService}; the mock stays in place until the env vars are
* wired.
*/
@Getter
@Setter
@Component
@Profile("saas")
@ConfigurationProperties(prefix = "stirling.keygen")
public class KeygenConfigurationProperties {
/** Master switch: when true, the real Keygen client replaces the mock licence service. */
private boolean enabled = false;
/** Keygen account id (UUID or slug). From {@code STIRLING_KEYGEN_ACCOUNT_ID}. */
private String accountId;
/** Keygen admin API token. From {@code STIRLING_KEYGEN_API_TOKEN}. Never log this. */
private String apiToken;
/** Policy the committed-enterprise licences are created under. From {@code ..._POLICY_ID}. */
private String policyId;
/** API base; overridable for self-hosted Keygen, defaults to the hosted service. */
private String apiBase = "https://api.keygen.sh/v1";
/**
* License-file check-out algorithm. Must stay {@code base64+ed25519} — the self-hosted {@code
* KeygenLicenseVerifier} only verifies that scheme (signed, unencrypted) offline.
*/
private String licenseFileAlgorithm = "base64+ed25519";
/** True when the credentials needed to talk to Keygen are all present. */
public boolean isConfigured() {
return notBlank(accountId) && notBlank(apiToken) && notBlank(policyId);
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
}
@@ -11,15 +11,37 @@ import java.time.LocalDateTime;
*/
public interface EnterpriseLicenseService {
/** Issue a time-boxed trial licence for the team; returns the licence reference. */
String issueTrialLicense(Long teamId, LocalDateTime expiresAt);
/**
* Issue a time-boxed trial licence for the team, owned by {@code ownerEmail} (the team leader);
* returns the licence reference (the Keygen key, stored on the deal).
*/
String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt);
/** Move a licence's expiry out (trial extension). */
void extendLicense(String licenseRef, LocalDateTime newExpiry);
/** Issue/upgrade to a committed annual licence with the quote's entitlements. */
String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt);
/**
* Issue/upgrade to a committed annual licence carrying the deal's {@link LicenseEntitlements}
* ({@code seats} = 0 means unlimited). When {@code existingRef} is non-null (the team already
* has a trial licence), that licence is upgraded in place so the key the buyer already holds
* keeps working; otherwise a new licence is created. Owned by {@code ownerEmail}; returns the
* licence reference.
*/
String issueAnnualLicense(
Long teamId,
String ownerEmail,
LocalDateTime expiresAt,
String existingRef,
LicenseEntitlements entitlements);
/** Suspend a licence (e.g. payment failed, deal lost). */
void suspendLicense(String licenseRef);
/**
* Check out a signed, offline-verifiable licence file (a {@code -----BEGIN LICENSE FILE-----}
* certificate) for the given licence, for an air-gapped self-hosted instance. The paid offline
* add-on gates whether this is offered; the certificate itself is generated on demand and never
* stored.
*/
String checkOutLicenseFile(String licenseRef);
}
@@ -0,0 +1,304 @@
package stirling.software.saas.procurement.license;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.procurement.config.KeygenConfigurationProperties;
/**
* Real {@link EnterpriseLicenseService}: manages the team's enterprise licence directly against the
* Keygen API (the "call Keygen from Java" direction), rather than via the Supabase edge functions
* the self-hosted checkout uses. Active only when {@code stirling.keygen.enabled=true}; otherwise
* {@link MockEnterpriseLicenseService} is the bean.
*
* <p>Licences are owned by the team leader (a Keygen user, found or created by email) and created
* under the committed-enterprise policy. Metadata carries {@code isEnterprise} + {@code users} so a
* checked-out offline licence file satisfies the self-hosted {@code KeygenLicenseVerifier}. The
* licence key returned is stored on the deal as its {@code license_ref}.
*/
@Slf4j
@Service
@Profile("saas")
@ConditionalOnProperty(name = "stirling.keygen.enabled", havingValue = "true")
public class KeygenEnterpriseLicenseService implements EnterpriseLicenseService {
private static final String VND_JSON = "application/vnd.api+json";
private final ObjectMapper mapper = new ObjectMapper();
private final HttpClient http =
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
private final KeygenConfigurationProperties config;
public KeygenEnterpriseLicenseService(KeygenConfigurationProperties config) {
this.config = config;
// Fail fast: if the flag is on but creds are missing, a misconfigured prod deploy should be
// caught at startup, not at the first trial/provision. (Flag off → Mock bean, never here.)
if (!config.isConfigured()) {
throw new IllegalStateException(
"stirling.keygen.enabled=true but Keygen is not fully configured — set "
+ "STIRLING_KEYGEN_ACCOUNT_ID / _API_TOKEN / _POLICY_ID, or turn the flag off");
}
}
@Override
public String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt) {
String ownerId = findOrCreateUser(ownerEmail);
// Trial: enterprise entitlement, unlimited users, expiring at the trial end. No committed
// volume/add-ons yet — those are stamped on the annual licence at provision.
return createLicense(ownerId, expiresAt, trialMetadata(teamId));
}
@Override
public void extendLicense(String licenseRef, LocalDateTime newExpiry) {
Map<String, Object> attrs = new LinkedHashMap<>();
attrs.put("expiry", iso(newExpiry));
patchLicense(licenseRef, attrs);
}
@Override
public String issueAnnualLicense(
Long teamId,
String ownerEmail,
LocalDateTime expiresAt,
String existingRef,
LicenseEntitlements ent) {
Map<String, Object> metadata = annualMetadata(teamId, ent);
// Upgrade the trial licence in place so the key the buyer already holds keeps working.
if (existingRef != null && !existingRef.isBlank()) {
Map<String, Object> attrs = new LinkedHashMap<>();
attrs.put("expiry", iso(expiresAt));
attrs.put("suspended", false);
attrs.put("metadata", metadata);
patchLicense(existingRef, attrs);
return existingRef;
}
String ownerId = findOrCreateUser(ownerEmail);
return createLicense(ownerId, expiresAt, metadata);
}
@Override
public void suspendLicense(String licenseRef) {
HttpResponse<String> res =
send(
authed(licenseUrl(licenseRef) + "/actions/suspend")
.POST(HttpRequest.BodyPublishers.noBody())
.build());
expect(res, 200, "suspend licence");
}
@Override
public String checkOutLicenseFile(String licenseRef) {
// Signed, unencrypted (base64+ed25519) so the self-hosted verifier can validate it offline;
// include entitlements in the snapshot. Never encrypt — the verifier only reads
// base64+ed25519.
String url =
licenseUrl(licenseRef)
+ "/actions/check-out?include=entitlements&algorithm="
+ enc(config.getLicenseFileAlgorithm());
HttpResponse<String> res =
send(authed(url).POST(HttpRequest.BodyPublishers.noBody()).build());
expect(res, 200, "check-out licence file");
JsonNode cert = readJson(res).at("/data/attributes/certificate");
if (cert.isMissingNode() || cert.asText().isBlank()) {
throw new IllegalStateException("Keygen check-out returned no certificate");
}
return cert.asText();
}
// ---- Keygen primitives --------------------------------------------------
/** Find the Keygen user by email, creating it if absent; returns the user id. */
private String findOrCreateUser(String email) {
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("Cannot own a licence without an owner email");
}
HttpResponse<String> find =
send(authed(accountUrl() + "/users/" + enc(email)).GET().build());
if (find.statusCode() == 200) {
return readJson(find).at("/data/id").asText();
}
if (find.statusCode() != 404) {
expect(find, 200, "find Keygen user"); // throws with the real status
}
Map<String, Object> body =
jsonApi("users", Map.of("email", email), null); // no relationships
HttpResponse<String> create =
send(
authed(accountUrl() + "/users")
.POST(HttpRequest.BodyPublishers.ofString(write(body)))
.build());
expect(create, 201, "create Keygen user");
return readJson(create).at("/data/id").asText();
}
private String createLicense(
String ownerId, LocalDateTime expiresAt, Map<String, Object> metadata) {
Map<String, Object> attributes = new LinkedHashMap<>();
attributes.put("expiry", iso(expiresAt));
attributes.put("metadata", metadata);
Map<String, Object> relationships =
Map.of(
"policy",
Map.of("data", Map.of("type", "policies", "id", config.getPolicyId())),
"owner",
Map.of("data", Map.of("type", "users", "id", ownerId)));
Map<String, Object> body = jsonApi("licenses", attributes, relationships);
HttpResponse<String> res =
send(
authed(accountUrl() + "/licenses")
.POST(HttpRequest.BodyPublishers.ofString(write(body)))
.build());
expect(res, 201, "create licence");
return readJson(res).at("/data/attributes/key").asText();
}
private void patchLicense(String licenseRef, Map<String, Object> attributes) {
Map<String, Object> body = jsonApi("licenses", attributes, null);
HttpResponse<String> res =
send(
authed(licenseUrl(licenseRef))
.method("PATCH", HttpRequest.BodyPublishers.ofString(write(body)))
.build());
expect(res, 200, "update licence");
}
// ---- helpers ------------------------------------------------------------
// The self-hosted verifier only reads isEnterprise + users; everything else is informational
// (dashboard / reconciliation) but kept so the licence is a self-describing record of the deal.
/** Trial licence: enterprise, unlimited users, no committed volume/add-ons yet. */
private Map<String, Object> trialMetadata(Long teamId) {
Map<String, Object> m = baseMetadata(teamId, true);
m.put("users", 0); // 0 = unlimited during the trial
m.put("seat_count", 0);
return m;
}
/** Committed annual licence: the full entitlement snapshot from the accepted quote + deal. */
private Map<String, Object> annualMetadata(Long teamId, LicenseEntitlements ent) {
Map<String, Object> m = baseMetadata(teamId, false);
int seats = Math.max(0, ent.seats());
m.put("users", seats); // 0 = unlimited
m.put("seat_count", seats); // parity with the self-hosted edge's metadata
m.put("volume", ent.volume()); // committed PDFs / year
m.put("term_years", ent.termYears());
if (ent.serviceLevel() != null && !ent.serviceLevel().isBlank()) {
m.put("service_level", ent.serviceLevel());
}
m.put("indemnification", ent.indemnification());
m.put("training", ent.training());
m.put("qbr", ent.qbr());
m.put("offline_license", ent.offlineLicense());
if (ent.deployment() != null && !ent.deployment().isBlank()) {
m.put("deployment", ent.deployment());
}
if (ent.dealId() != null) m.put("deal_id", ent.dealId());
if (ent.subscriptionId() != null && !ent.subscriptionId().isBlank()) {
m.put("subscription_id", ent.subscriptionId());
}
return m;
}
private Map<String, Object> baseMetadata(Long teamId, boolean trial) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("team_id", teamId);
m.put("plan_type", "enterprise");
m.put("isEnterprise", true);
m.put("trial", trial);
return m;
}
private Map<String, Object> jsonApi(
String type, Map<String, Object> attributes, Map<String, Object> relationships) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("type", type);
data.put("attributes", attributes);
if (relationships != null) data.put("relationships", relationships);
return Map.of("data", data);
}
private HttpRequest.Builder authed(String url) {
return HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + config.getApiToken())
.header("Content-Type", VND_JSON)
.header("Accept", VND_JSON)
.timeout(Duration.ofSeconds(30));
}
private HttpResponse<String> send(HttpRequest request) {
try {
return http.send(request, HttpResponse.BodyHandlers.ofString());
} catch (java.io.IOException e) {
throw new IllegalStateException("Keygen request failed: " + e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Keygen request interrupted", e);
}
}
private void expect(HttpResponse<String> res, int status, String action) {
if (res.statusCode() != status) {
// Keep the response body out of the thrown message: it never carries the token, but can
// echo owner emails / metadata, and the message reaches warn-level logs. Body at debug.
log.debug(
"[procurement][keygen] {} failed: HTTP {} body={}",
action,
res.statusCode(),
res.body());
throw new IllegalStateException(
"Keygen " + action + " failed: HTTP " + res.statusCode());
}
}
private JsonNode readJson(HttpResponse<String> res) {
try {
return mapper.readTree(res.body());
} catch (Exception e) {
throw new IllegalStateException("Keygen returned unparseable JSON", e);
}
}
private String write(Map<String, Object> body) {
try {
return mapper.writeValueAsString(body);
} catch (Exception e) {
throw new IllegalStateException("Failed to serialise Keygen request", e);
}
}
private String accountUrl() {
return config.getApiBase() + "/accounts/" + config.getAccountId();
}
private String licenseUrl(String licenseRef) {
return accountUrl() + "/licenses/" + enc(licenseRef);
}
private static String enc(String s) {
return URLEncoder.encode(s, StandardCharsets.UTF_8);
}
private static String iso(LocalDateTime dt) {
return dt.toInstant(ZoneOffset.UTC).toString();
}
}
@@ -0,0 +1,21 @@
package stirling.software.saas.procurement.license;
/**
* The committed deal's entitlements, stamped onto the annual Keygen licence's metadata so the
* licence is a self-describing record of what was bought. Only {@code seats} (as {@code users}) and
* the enterprise flag are read by the self-hosted verifier; the rest is informational — kept "for
* good measure" so the Keygen dashboard and any downstream reconciliation can see the full picture.
* Built by {@code ProcurementService} from the accepted quote + deal.
*/
public record LicenseEntitlements(
long volume, // committed PDFs / year
int seats, // 0 = unlimited
String deployment, // cloud | selfhost | airgap
int termYears,
String serviceLevel, // standard | priority | dedicated
boolean indemnification,
boolean training,
boolean qbr,
boolean offlineLicense,
Long dealId,
String subscriptionId) {}
@@ -3,6 +3,7 @@ package stirling.software.saas.procurement.license;
import java.time.LocalDateTime;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
@@ -13,15 +14,23 @@ import lombok.extern.slf4j.Slf4j;
* returns a synthetic reference, without calling Keygen. Lets the whole procurement journey run
* end-to-end while the real Keygen management client is a later drop-in — the seam and the stored
* {@code license_ref} on the deal stay identical.
*
* <p>This is the default; it steps aside for {@code KeygenEnterpriseLicenseService} when {@code
* stirling.keygen.enabled=true} (real Keygen secrets are wired).
*/
@Slf4j
@Service
@Profile("saas")
@ConditionalOnProperty(
name = "stirling.keygen.enabled",
havingValue = "false",
matchIfMissing = true)
public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
@Override
public String issueTrialLicense(Long teamId, LocalDateTime expiresAt) {
public String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt) {
String ref = "mock-trial-" + UUID.randomUUID();
// Owner email is deliberately not logged — it's PII and adds nothing to the mock trace.
log.info(
"[procurement][mock-license] issue trial team={} expires={} ref={}",
teamId,
@@ -36,14 +45,24 @@ public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
}
@Override
public String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt) {
String ref = "mock-annual-" + UUID.randomUUID();
public String issueAnnualLicense(
Long teamId,
String ownerEmail,
LocalDateTime expiresAt,
String existingRef,
LicenseEntitlements ent) {
// Upgrade in place when a trial licence already exists, so the key stays stable.
String ref = existingRef != null ? existingRef : "mock-annual-" + UUID.randomUUID();
// Owner email is deliberately not logged — it's PII and adds nothing to the mock trace.
log.info(
"[procurement][mock-license] issue annual team={} deployment={} expires={} ref={}",
"[procurement][mock-license] issue annual team={} seats={} volume={} deployment={} expires={} ref={} upgrade={}",
teamId,
deployment,
ent.seats(),
ent.volume(),
ent.deployment(),
expiresAt,
ref);
ref,
existingRef != null);
return ref;
}
@@ -51,4 +70,14 @@ public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
public void suspendLicense(String licenseRef) {
log.info("[procurement][mock-license] suspend ref={}", licenseRef);
}
@Override
public String checkOutLicenseFile(String licenseRef) {
log.info("[procurement][mock-license] check-out licence file ref={}", licenseRef);
// A syntactically shaped stand-in so the portal download path is exercisable without
// Keygen; not a valid certificate.
return "-----BEGIN LICENSE FILE-----\nmock-offline-license-for-"
+ licenseRef
+ "\n-----END LICENSE FILE-----\n";
}
}
@@ -62,6 +62,10 @@ public class ProcurementQuote implements Serializable {
@Column(name = "seats")
private Integer seats;
/** Policy posture as runs-per-PDF: Essentials 2, Governed 4, Regulated 7. Defaults Governed. */
@Column(name = "intensity", nullable = false)
private int intensity = 4;
@Column(name = "deployment", length = 24)
private String deployment;
@@ -80,6 +84,9 @@ public class ProcurementQuote implements Serializable {
@Column(name = "qbr", nullable = false)
private boolean qbr;
@Column(name = "offline_license", nullable = false)
private boolean offlineLicense;
@Column(name = "annual_net_minor", nullable = false)
private long annualNetMinor;
@@ -1,53 +1,44 @@
package stirling.software.saas.procurement.pricing;
/**
* The enterprise rate card: the inputs pricing multiplies against. In production these are read
* from the Stripe price mirror (see {@code StripeMirrorPriceCatalog}); {@link #defaults()} is the
* fallback used when the {@code stripe} schema isn't synced (dev / tests) and is the single source
* of the numbers the marketing prototype encodes.
* The enterprise rate card (D71): the inputs pricing derives a quote from. In production these are
* read from the Stripe price mirror; {@link #defaults()} is the fallback used when the {@code
* stripe} schema isn't synced (dev / tests) and is the single source of the numbers the marketing
* prototype ({@code quotePricing}) encodes.
*
* <p>Per-PDF rates are in minor units (cents) per document. Multipliers are fractions (e.g. 0.15 =
* +15%). Flat/one-time fees are in minor units.
* <p>The meter is denominated in <b>runs</b> (a PDF running N policies is N runs). The per-run rate
* is a fraction of a dollar, so it lives here as a {@code double} in dollars; flat and one-time
* fees are whole-dollar amounts held in minor units (cents). See {@link ProcurementPricingService}
* for how they combine.
*/
public record PricingRates(
long perPdfMinorUnder1M,
long perPdfMinorUnder5M,
long perPdfMinor5MPlus,
double priorityUplift,
double dedicatedUplift,
double indemnificationUplift,
double[] termDiscountByYear, // index 0 = 1yr … index 4 = 5yr
long qbrAnnualMinor,
long trainingOneTimeMinor) {
double listRatePerRun, // $0.01 list per run
double floorRatePerRun, // $0.005 asymptotic floor (cost + margin)
double discountPerDoubling, // 0.06 = 6% off the per-run rate per doubling past 1M runs/yr
double[] termDiscountByYear, // meter-only discount; index 0 = 1yr … index 4 = 5yr
double indemnificationRate, // fraction of the net meter (legal exposure scales with usage)
long dedicatedSupportMinor, // flat: dedicated SE/CSM (standard + priority are included)
long selfHostDeployMinor, // flat: self-hosted deployment
long airgapDeployMinor, // flat: air-gapped deployment
long qbrAnnualMinor, // flat: quarterly business reviews
long trainingOneTimeMinor) { // one-time: onboarding & training
public static PricingRates defaults() {
return new PricingRates(
5, // $0.05 / PDF under 1M/yr
4, // $0.04 / PDF at 1M–5M/yr
3, // $0.03 / PDF at 5M+/yr
0.15, // priority +15%
0.30, // dedicated +30%
0.05, // IP indemnification +5%
new double[] {0.0, 0.05, 0.10, 0.12, 0.15},
0.01, // $0.01 / run list
0.005, // $0.005 / run floor
0.06, // 6% off per doubling of committed runs past 1M/yr
new double[] {0.0, 0.03, 0.05, 0.06, 0.07}, // 1–5 year term, meter only
0.05, // IP indemnification = 5% of the net meter
3_000_000, // dedicated SE/CSM $30,000 / yr
1_200_000, // self-hosted $12,000 / yr
3_600_000, // air-gapped $36,000 / yr
800_000, // QBRs $8,000 / yr
750_000); // onboarding & training $7,500 one-time
}
/** Volume-banded per-PDF rate for an annual volume, in minor units. */
public long perPdfMinor(long annualVolume) {
if (annualVolume >= 5_000_000) return perPdfMinor5MPlus;
if (annualVolume >= 1_000_000) return perPdfMinorUnder5M;
return perPdfMinorUnder1M;
}
public double termDiscount(int termYears) {
int idx = Math.max(1, Math.min(termYears, 5)) - 1;
return termDiscountByYear[idx];
}
public double serviceLevelUplift(String serviceLevel) {
if ("priority".equalsIgnoreCase(serviceLevel)) return priorityUplift;
if ("dedicated".equalsIgnoreCase(serviceLevel)) return dedicatedUplift;
return 0.0;
}
}
@@ -2,34 +2,39 @@ package stirling.software.saas.procurement.pricing;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.stereotype.Service;
/**
* The canonical enterprise pricing engine — the single server-side definition the quote builder,
* the order form, and the Stripe checkout all derive from. A faithful port of the marketing
* The canonical enterprise pricing engine (D71) — the single server-side definition the quote
* builder, the order form, and Stripe checkout all derive from. A faithful port of the marketing
* prototype's {@code quotePricing}:
*
* <pre>
* annual = volume x perPdfRate x serviceLevelMult x (indemnification ? 1.05 : 1)
* annualNet = round(annual x (1 - termDiscount)) + qbr
* tcv = annualNet x termYears + training
* runVol = volume x intensity // posture: Essentials x2, Governed x4, Regulated x7
* volDisc = min(0.5, 0.06 x log2(runVol / 1M)) // 6% off the per-run rate per doubling past 1M
* rate = max($0.005, $0.01 x (1 - volDisc)) // continuous curve, floors at half a cent
* meterNet = round(runVol x rate x (1 - termDisc)) // whole dollars; term discounts the meter only
* annualNet = meterNet + dedicated + deployment + indemnification + qbr // needs priced flat
* tcv = annualNet x termYears + training
* </pre>
*
* The multi-year discount applies to usage + service level + indemnification, but NOT to the flat
* QBR fee (added after), and one-time training sits outside the recurring total. All money is in
* minor units (cents). Rates come from {@link PricingRates} (Stripe-backed in prod).
* <p>No volume tiers, no service-level multipliers, no ACV floor — those were the retired model.
* Deployment (self-hosted / air-gapped) and dedicated support are flat line items at cost basis;
* SSO / SCIM / RBAC / audit are always included. Money is USD only; amounts are in minor units
* (cents). Rates come from {@link PricingRates} (Stripe-backed in prod).
*/
@Service
public class ProcurementPricingService {
/** Bespoke/committed deals don't price below this ACV; the builder floors against it. */
public static final long MIN_ACV_MINOR = 5_000_000L; // $50,000
private static final double LOG2 = Math.log(2.0);
private static final long RUN_CURVE_KNEE = 1_000_000L; // discount starts past 1M committed runs
/**
* Estimated annual PDF volume from seat count (~2,012.5 PDFs/user/yr = 5 docs/day x 230 working
* days x 1.75), used to prefill the builder's volume step. Rounded, matching the prototype's
* {@code users x 5 x 230 x 1.75}.
* days x 1.75), used to prefill the builder's volume step. Matches the prototype's {@code users
* x 5 x 230 x 1.75}.
*/
public long estimateAnnualVolume(int users) {
return Math.round(Math.max(0, users) * 5.0 * 230.0 * 1.75);
@@ -40,55 +45,95 @@ public class ProcurementPricingService {
}
public QuoteBreakdown price(QuoteConfig cfg, PricingRates rates) {
// Never trust the client's volume: clamp to non-negative so a tampered request can't drive
// a
// negative amount. The rate card and formula are server-side, so the browser can't lower
// the
// price — only pick a smaller, legitimate config. (See MIN_ACV_MINOR for the committed
// floor,
// a policy decision that is intentionally not force-applied here — see the review notes.)
// Never trust the client's volume/intensity: clamp so a tampered request can't drive a
// negative amount. The curve and flat fees are server-side, so the browser can only pick a
// smaller legitimate config, never a cheaper rate.
long volume = Math.max(0, cfg.volume());
long perPdf = rates.perPdfMinor(volume);
long usage = Math.round(volume * (double) perPdf); // base, pre-service-level
double slaUplift = rates.serviceLevelUplift(cfg.serviceLevel());
long withSla = Math.round(usage * (1.0 + slaUplift));
long withIndemnity =
cfg.indemnification()
? Math.round(withSla * (1.0 + rates.indemnificationUplift()))
: withSla;
int intensity = Math.max(1, cfg.intensity());
long runVol = volume * (long) intensity;
double termDiscount = rates.termDiscount(cfg.termYears());
long discount = Math.round(withIndemnity * termDiscount);
// Committed-volume curve: continuous, no cliffs. Floors at the per-run cost + margin.
double volDisc =
runVol > RUN_CURVE_KNEE
? Math.min(
0.5,
rates.discountPerDoubling()
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
: 0.0;
double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc));
double termDisc = rates.termDiscount(cfg.termYears());
// The meter is a whole-dollar figure (the quote reads in dollars), then minor units.
long annualBaseMinor = Math.round((double) runVol * rate) * 100L;
long meterNetMinor = Math.round((double) runVol * rate * (1.0 - termDisc)) * 100L;
long termDiscountMinor = meterNetMinor - annualBaseMinor; // <= 0
long support =
"dedicated".equalsIgnoreCase(cfg.serviceLevel())
? rates.dedicatedSupportMinor()
: 0L;
long deploy = deployFeeMinor(cfg.deployment(), rates);
long indemnity =
cfg.indemnification()
? Math.round(meterNetMinor * rates.indemnificationRate())
: 0L;
long qbr = cfg.qbr() ? rates.qbrAnnualMinor() : 0L;
long training = cfg.training() ? rates.trainingOneTimeMinor() : 0L;
long annualNet = (withIndemnity - discount) + qbr;
long annualNet = meterNetMinor + support + deploy + indemnity + qbr;
long tcv = annualNet * cfg.termYears() + training;
double effectivePerPdf = rate * intensity; // quotes speak per-PDF-at-posture, never per-run
List<QuoteLineItem> lines = new ArrayList<>();
lines.add(
new QuoteLineItem("usage", "PDF processing", QuoteLineItem.Kind.RECURRING, usage));
new QuoteLineItem(
"usage",
String.format(
Locale.ROOT,
"PDF processing — %,d PDFs/yr at $%.4f/PDF (%s posture)",
volume,
effectivePerPdf,
postureLabel(intensity)),
QuoteLineItem.Kind.RECURRING,
annualBaseMinor));
lines.add(
new QuoteLineItem(
"seats",
"Unlimited users + SSO / SCIM / RBAC",
"Unlimited users + SSO / SCIM / RBAC / audit",
QuoteLineItem.Kind.INCLUDED,
0L));
if (withSla != usage) {
if (termDiscountMinor < 0) {
lines.add(
new QuoteLineItem(
"service-level",
serviceLevelLabel(cfg.serviceLevel()),
QuoteLineItem.Kind.RECURRING,
withSla - usage));
"multi-year",
cfg.termYears() + "-year commitment",
QuoteLineItem.Kind.DISCOUNT,
termDiscountMinor));
}
if (withIndemnity != withSla) {
if (support > 0) {
lines.add(
new QuoteLineItem(
"support",
"Dedicated SE / CSM",
QuoteLineItem.Kind.RECURRING,
support));
}
if (deploy > 0) {
lines.add(
new QuoteLineItem(
"deployment",
deploymentLabel(cfg.deployment()) + " deployment",
QuoteLineItem.Kind.RECURRING,
deploy));
}
if (indemnity > 0) {
lines.add(
new QuoteLineItem(
"indemnification",
"IP indemnification",
QuoteLineItem.Kind.RECURRING,
withIndemnity - withSla));
indemnity));
}
if (qbr > 0) {
lines.add(
@@ -98,14 +143,6 @@ public class ProcurementPricingService {
QuoteLineItem.Kind.RECURRING,
qbr));
}
if (discount > 0) {
lines.add(
new QuoteLineItem(
"multi-year",
cfg.termYears() + "-year commitment",
QuoteLineItem.Kind.DISCOUNT,
-discount));
}
if (training > 0) {
lines.add(
new QuoteLineItem(
@@ -117,9 +154,25 @@ public class ProcurementPricingService {
return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency());
}
private static String serviceLevelLabel(String serviceLevel) {
if ("priority".equalsIgnoreCase(serviceLevel)) return "Priority service level";
if ("dedicated".equalsIgnoreCase(serviceLevel)) return "Dedicated service level";
return "Standard service level";
private static long deployFeeMinor(String deployment, PricingRates rates) {
if ("airgap".equalsIgnoreCase(deployment)) return rates.airgapDeployMinor();
if ("selfhost".equalsIgnoreCase(deployment)) return rates.selfHostDeployMinor();
return 0L; // cloud (managed) has no deployment fee
}
/** Buyer-facing posture name for the intensity (policy count); the demo's POLICY_POSTURES. */
private static String postureLabel(int intensity) {
return switch (intensity) {
case 2 -> "Essentials";
case 4 -> "Governed";
case 7 -> "Regulated";
default -> intensity + "-policy";
};
}
private static String deploymentLabel(String deployment) {
if ("airgap".equalsIgnoreCase(deployment)) return "Air-gapped";
if ("selfhost".equalsIgnoreCase(deployment)) return "Self-hosted";
return "Stirling Cloud";
}
}
@@ -9,17 +9,23 @@ package stirling.software.saas.procurement.pricing;
public record QuoteConfig(
long volume, // committed PDFs per year
int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden)
String deployment, // cloud | selfhost | airgap (inherited from the trial; not priced)
int intensity, // policy posture: runs per PDF — Essentials 2, Governed 4, Regulated 7
String deployment, // cloud | selfhost | airgap (priced flat; inherited from the trial)
int termYears, // 1..5
String serviceLevel, // standard | priority | dedicated
String serviceLevel, // standard | priority (both included) | dedicated (flat SE/CSM fee)
boolean indemnification,
boolean training,
boolean qbr,
String currency) { // USD | EUR | GBP
boolean offlineLicense, // offline .lic availability (gates download; no longer priced here)
String currency) { // USD only for now
/** Default posture when none is chosen — Governed (x4), per the pricing alignment decision. */
public static final int DEFAULT_INTENSITY = 4;
public QuoteConfig {
if (termYears < 1) termYears = 1;
if (termYears > 5) termYears = 5;
if (intensity < 1) intensity = DEFAULT_INTENSITY;
if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard";
if (currency == null || currency.isBlank()) currency = "USD";
}
@@ -16,8 +16,11 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
import stirling.software.saas.procurement.license.EnterpriseLicenseService;
import stirling.software.saas.procurement.license.LicenseEntitlements;
import stirling.software.saas.procurement.model.ProcurementDeal;
import stirling.software.saas.procurement.model.ProcurementQuote;
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
@@ -25,6 +28,7 @@ import stirling.software.saas.procurement.pricing.QuoteBreakdown;
import stirling.software.saas.procurement.pricing.QuoteConfig;
import stirling.software.saas.procurement.repository.ProcurementDealRepository;
import stirling.software.saas.procurement.repository.ProcurementQuoteRepository;
import stirling.software.saas.repository.TeamMembershipRepository;
/**
* Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a
@@ -46,18 +50,37 @@ public class ProcurementService {
private final ProcurementPricingService pricing;
private final EnterpriseLicenseService licenses;
private final ProcurementConfigurationProperties config;
private final TeamMembershipRepository memberRepo;
public ProcurementService(
ProcurementDealRepository dealRepo,
ProcurementQuoteRepository quoteRepo,
ProcurementPricingService pricing,
EnterpriseLicenseService licenses,
ProcurementConfigurationProperties config) {
ProcurementConfigurationProperties config,
TeamMembershipRepository memberRepo) {
this.dealRepo = dealRepo;
this.quoteRepo = quoteRepo;
this.pricing = pricing;
this.licenses = licenses;
this.config = config;
this.memberRepo = memberRepo;
}
/**
* The team leader's email — the natural owner of the team's Keygen licence. Falls back to the
* username when no email is set; null when the team has no leader.
*/
private String leaderEmail(Long teamId) {
return memberRepo.findByTeamIdAndRole(teamId, TeamRole.LEADER).stream()
.findFirst()
.map(TeamMembership::getUser)
.map(
u ->
u.getEmail() != null && !u.getEmail().isBlank()
? u.getEmail()
: u.getUsername())
.orElse(null);
}
@Transactional(readOnly = true)
@@ -85,7 +108,7 @@ public class ProcurementService {
deal.setTrialStartedAt(now);
deal.setTrialEndsAt(ends);
deal.setTrialExtensionsUsed(0);
deal.setLicenseRef(licenses.issueTrialLicense(teamId, ends));
deal.setLicenseRef(licenses.issueTrialLicense(teamId, leaderEmail(teamId), ends));
deal = dealRepo.save(deal);
log.info(
"[procurement] trial started team={} deal={} ends={}",
@@ -144,12 +167,14 @@ public class ProcurementService {
quote.setCurrency(cfg.currency());
quote.setVolume(cfg.volume());
quote.setSeats(cfg.users() > 0 ? cfg.users() : null);
quote.setIntensity(cfg.intensity());
quote.setDeployment(cfg.deployment());
quote.setTermYears(cfg.termYears());
quote.setServiceLevel(cfg.serviceLevel());
quote.setIndemnification(cfg.indemnification());
quote.setTraining(cfg.training());
quote.setQbr(cfg.qbr());
quote.setOfflineLicense(cfg.offlineLicense());
quote.setBusinessName(businessName);
quote.setAnnualNetMinor(breakdown.annualNetMinor());
quote.setTcvMinor(breakdown.tcvMinor());
@@ -188,35 +213,104 @@ public class ProcurementService {
}
/**
* Mark the deal live: issue the annual licence and advance to the active stage. In production
* this is driven by the {@code invoice.paid} webhook once the first invoice is settled; this
* method is the demo/manual stand-in until that webhook lands.
* Provision on accept: upgrade the team's licence to the committed annual term (valid
* immediately), so the buyer can get going the moment they accept — before the invoice is paid.
* Driven by the accept edge function once the subscription + invoice are created. Idempotent
* (upgrades the existing licence in place); deliberately does NOT change the stage — the deal
* stays in the payment step so the outstanding invoice remains visible until it settles.
*/
@Transactional
public ProcurementDeal provisionLicense(Long teamId) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId)
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
deal.setLicenseRef(issueOrUpgradeAnnual(deal));
deal = dealRepo.save(deal);
log.info("[procurement] licence provisioned team={} deal={}", teamId, deal.getDealId());
return deal;
}
/**
* Mark the deal fully live (advance to the active stage) once payment settles. In production
* this is the {@code invoice.paid} webhook; here it's the demo/manual stand-in. Re-affirms the
* annual licence in case provisioning didn't run at accept.
*/
@Transactional
public ProcurementDeal markLive(Long teamId) {
ProcurementDeal deal =
dealRepo.findByTeamId(teamId)
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
int term = 1;
String deployment = "cloud";
if (deal.getAcceptedQuoteId() != null) {
ProcurementQuote q = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
if (q != null) {
term = Math.max(1, q.getTermYears());
if (q.getDeployment() != null && !q.getDeployment().isBlank()) {
deployment = q.getDeployment();
}
}
}
deal.setLicenseRef(
licenses.issueAnnualLicense(
teamId, deployment, LocalDateTime.now().plusYears(term)));
deal.setLicenseRef(issueOrUpgradeAnnual(deal));
deal.setStage(ProcurementDeal.STAGE_LIVE);
deal = dealRepo.save(deal);
log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId());
return deal;
}
/**
* Issue or upgrade the committed annual licence from the deal's accepted (else latest) quote,
* stamping the full entitlement snapshot onto it and upgrading the trial licence in place when
* one exists.
*/
private String issueOrUpgradeAnnual(ProcurementDeal deal) {
ProcurementQuote q =
deal.getAcceptedQuoteId() != null
? quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null)
: quoteRepo.findByDealIdOrderByCreatedAtDesc(deal.getDealId()).stream()
.findFirst()
.orElse(null);
int term = q != null ? Math.max(1, q.getTermYears()) : 1;
String deployment =
q != null && q.getDeployment() != null && !q.getDeployment().isBlank()
? q.getDeployment()
: "cloud";
int seats = q != null && q.getSeats() != null ? q.getSeats() : 0; // 0 = unlimited
LicenseEntitlements entitlements =
new LicenseEntitlements(
q != null ? q.getVolume() : 0,
seats,
deployment,
term,
q != null ? q.getServiceLevel() : null,
q != null && q.isIndemnification(),
q != null && q.isTraining(),
q != null && q.isQbr(),
q != null && q.isOfflineLicense(),
deal.getDealId(),
deal.getSubscriptionId());
return licenses.issueAnnualLicense(
deal.getTeamId(),
leaderEmail(deal.getTeamId()),
LocalDateTime.now().plusYears(term),
deal.getLicenseRef(),
entitlements);
}
/**
* Check out the offline/air-gapped licence file for a team, when the offline add-on was
* purchased. Requires an issued licence on the deal and the accepted quote to carry the offline
* add-on; returns empty otherwise (so the controller can 404 rather than leak that a licence
* exists). The certificate is generated on demand by Keygen and never stored.
*/
@Transactional(readOnly = true)
public Optional<String> offlineLicenseFile(Long teamId) {
ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElse(null);
if (deal == null || deal.getLicenseRef() == null) return Optional.empty();
if (!hasOfflineAddOn(deal)) return Optional.empty();
return Optional.of(licenses.checkOutLicenseFile(deal.getLicenseRef()));
}
/**
* Whether the deal's <b>accepted</b> quote carries the paid offline-licence add-on. Gated on
* the accepted quote (not the latest) so merely toggling the add-on on an unaccepted draft
* can't unlock the offline file — it's only available once the add-on has actually been bought.
*/
private boolean hasOfflineAddOn(ProcurementDeal deal) {
if (deal.getAcceptedQuoteId() == null) return false;
ProcurementQuote quote = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
return quote != null && quote.isOfflineLicense();
}
/**
* Reset a team's procurement: delete the deal (quotes + activity cascade). For
* re-demos/testing.
@@ -0,0 +1,45 @@
package stirling.software.saas.security;
import java.util.List;
import java.util.Objects;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.audit.PortalAuditScope;
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
import stirling.software.saas.repository.TeamMembershipRepository;
/** SaaS audit visibility: admins see the server; team LEADERs see their team (by member email). */
@Component
@Primary
@Profile("saas")
@RequiredArgsConstructor
public class SaasPortalAuditScopeResolver implements PortalAuditScopeResolver {
private final TeamSecurityExpressions teamSecurity;
private final TeamMembershipRepository membershipRepository;
@Override
public PortalAuditScope resolve() {
if (PortalAuditScopeResolver.hasAdminAuthority()) {
return PortalAuditScope.server();
}
if (!teamSecurity.isCurrentUserTeamLeader()) {
return PortalAuditScope.denied();
}
Long teamId = teamSecurity.currentUserTeamId();
if (teamId == null) {
return PortalAuditScope.denied();
}
List<String> memberEmails =
membershipRepository.findByTeamId(teamId).stream()
.map(m -> m.getUser() == null ? null : m.getUser().getEmail())
.filter(Objects::nonNull)
.toList();
return PortalAuditScope.team("team:" + teamId, memberEmails);
}
}
@@ -35,6 +35,15 @@ app.supabase.clock-skew-seconds=${app.jwt.clock-skew-seconds:120}
app.supabase.edge-function-url=https://${app.supabase.project-ref}.supabase.co/functions/v1
app.supabase.edge-function-secret=${SUPABASE_EDGE_FUNCTION_SECRET:}
# ---------- Enterprise procurement licences (Keygen) ----------
# Off by default → MockEnterpriseLicenseService (no real Keygen calls). Set enabled=true + the
# three creds to switch to the real KeygenEnterpriseLicenseService. Same account/policy as the
# self-hosted products; env names match the edge functions' KEYGEN_* for consistency.
stirling.keygen.enabled=${STIRLING_KEYGEN_ENABLED:false}
stirling.keygen.account-id=${KEYGEN_ACCOUNT_ID:}
stirling.keygen.api-token=${KEYGEN_API_TOKEN:}
stirling.keygen.policy-id=${KEYGEN_POLICY_ID:}
# ---------- PAYG meter reporting ----------
# Posts billable usage to the Supabase `meter-payg-units` edge function in the JobChargeService
# close() afterCommit hook. Defaults to empty so unit tests / local dev are no-ops; set
@@ -0,0 +1,6 @@
-- Offline / air-gapped licence add-on flag on the quote (a paid add-on; priced like QBR). Written
-- and read by the Java backend via JPA. Twin of Supabase migration
-- 20260710000000_procurement_offline_license.sql.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS offline_license BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,6 @@
-- Policy posture (runs per PDF) on the quote: the D71 meter is denominated in runs, so a quote
-- must remember the posture it was priced at (Essentials 2, Governed 4, Regulated 7). Defaults to
-- Governed (4). Written and read by the Java backend via JPA. A Supabase twin migration mirrors it.
ALTER TABLE stirling_pdf.procurement_quote
ADD COLUMN IF NOT EXISTS intensity INTEGER NOT NULL DEFAULT 4;
@@ -7,72 +7,138 @@ import org.junit.jupiter.api.Test;
import stirling.software.saas.procurement.pricing.QuoteLineItem.Kind;
/**
* Locks the pricing engine to the numbers the marketing prototype encodes — most importantly the
* canonical quote QT-AC9F-0001 (1M PDFs, priority, 3-year) = $41,400/yr, $124,200 TCV.
* Locks the D71 pricing engine to the numbers marketing publishes. The two anchor fixtures are the
* ones the demo memo foots against: acme (90M PDFs · Governed · self-hosted · dedicated · 3-yr =
* $1,752,000/yr, $5,256,000 TCV) and Northwind (6M · Governed · cloud · standard · 3-yr =
* $165,278/yr). If either moves, the engine has drifted from marketing.
*/
class ProcurementPricingServiceTest {
private final ProcurementPricingService pricing = new ProcurementPricingService();
private static QuoteConfig cfg(long volume, String sla, int term) {
return new QuoteConfig(volume, 0, "cloud", term, sla, false, false, false, "USD");
private static QuoteConfig cfg(
long volume, int intensity, String deployment, int term, String sla) {
return new QuoteConfig(
volume, 0, intensity, deployment, term, sla, false, false, false, false, "USD");
}
@Test
void canonicalQuoteMatchesPrototype() {
QuoteBreakdown q = pricing.price(cfg(1_000_000, "priority", 3));
void acmeFixtureFootsExactly() {
// 90M PDFs × Governed(×4) = 360M runs → curve floors at $0.005/run → $0.0200/PDF effective.
// meter $1.8M − 5% (3-yr) = $1,710,000 + self-hosted $12K + dedicated SE/CSM $30K.
QuoteBreakdown q = pricing.price(cfg(90_000_000, 4, "selfhost", 3, "dedicated"));
assertThat(q.annualNetMinor()).isEqualTo(4_140_000L); // $41,400
assertThat(q.tcvMinor()).isEqualTo(12_420_000L); // $124,200
assertThat(lineAmount(q, "usage")).isEqualTo(4_000_000L); // $40,000 @ $0.04
assertThat(lineAmount(q, "service-level")).isEqualTo(600_000L); // +15%
assertThat(lineAmount(q, "multi-year")).isEqualTo(-460_000L); // -10%
assertThat(q.annualNetMinor()).isEqualTo(175_200_000L); // $1,752,000
assertThat(q.tcvMinor()).isEqualTo(525_600_000L); // $5,256,000
assertThat(lineAmount(q, "support")).isEqualTo(3_000_000L); // dedicated SE/CSM $30K
assertThat(lineAmount(q, "deployment")).isEqualTo(1_200_000L); // self-hosted $12K
}
@Test
void volumeBandsPickTheRightPerPdfRate() {
assertThat(lineAmount(pricing.price(cfg(500_000, "standard", 1)), "usage"))
.isEqualTo(2_500_000L); // 500k @ $0.05
assertThat(lineAmount(pricing.price(cfg(1_000_000, "standard", 1)), "usage"))
.isEqualTo(4_000_000L); // 1M @ $0.04
assertThat(lineAmount(pricing.price(cfg(5_000_000, "standard", 1)), "usage"))
.isEqualTo(15_000_000L); // 5M @ $0.03
void northwindFixtureFootsExactly() {
// 6M × Governed(×4) = 24M runs → $0.0290/PDF effective → $165,278/yr at 3-yr.
QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard"));
assertThat(q.annualNetMinor()).isEqualTo(16_527_800L); // $165,278
assertThat(q.tcvMinor()).isEqualTo(49_583_400L); // × 3 years
// Cloud + standard: no deployment or support line.
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("deployment"));
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("support"));
}
@Test
void addOnsAndTermStack() {
void rateFloorsAtHalfACent() {
// 100M × Regulated(×7) = 700M runs — deep past the knee, so the per-run rate is pinned to
// the $0.005 floor: base meter = 700M × $0.005 = $3,500,000 (1-yr, no term discount).
QuoteBreakdown q = pricing.price(cfg(100_000_000, 7, "cloud", 1, "standard"));
assertThat(lineAmount(q, "usage")).isEqualTo(350_000_000L); // $3,500,000
assertThat(q.annualNetMinor()).isEqualTo(350_000_000L);
}
@Test
void postureDrivesThePrice() {
// Same PDFs, three postures — the meter scales with runs, so Regulated > Governed >
// Essentials. (This is what the retired flat-per-PDF model could not express.)
long essentials = pricing.price(cfg(6_000_000, 2, "cloud", 3, "standard")).annualNetMinor();
long governed = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
long regulated = pricing.price(cfg(6_000_000, 7, "cloud", 3, "standard")).annualNetMinor();
assertThat(essentials).isLessThan(governed);
assertThat(governed).isLessThan(regulated);
assertThat(governed).isEqualTo(16_527_800L); // Governed is the Northwind anchor
}
@Test
void deploymentIsAFlatFeeNotAMultiplier() {
long cloud = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
long selfhost =
pricing.price(cfg(6_000_000, 4, "selfhost", 3, "standard")).annualNetMinor();
long airgap = pricing.price(cfg(6_000_000, 4, "airgap", 3, "standard")).annualNetMinor();
assertThat(selfhost - cloud).isEqualTo(1_200_000L); // +$12,000 flat
assertThat(airgap - cloud).isEqualTo(3_600_000L); // +$36,000 flat
}
@Test
void standardAndPriorityIncludedDedicatedIsFlat() {
long standard = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
long priority = pricing.price(cfg(6_000_000, 4, "cloud", 3, "priority")).annualNetMinor();
long dedicated = pricing.price(cfg(6_000_000, 4, "cloud", 3, "dedicated")).annualNetMinor();
assertThat(priority).isEqualTo(standard); // both included, no uplift
assertThat(dedicated - standard).isEqualTo(3_000_000L); // dedicated SE/CSM +$30,000 flat
}
@Test
void termDiscountsTheMeterOnly() {
long oneYear = pricing.price(cfg(6_000_000, 4, "cloud", 1, "standard")).annualNetMinor();
long twoYear = pricing.price(cfg(6_000_000, 4, "cloud", 2, "standard")).annualNetMinor();
// 2-yr is 3% off the meter (discounted on the raw meter, then rounded to whole dollars).
assertThat(oneYear).isEqualTo(17_397_700L); // no discount
assertThat(twoYear).isEqualTo(16_875_700L); // −3% on the meter
assertThat(twoYear).isLessThan(oneYear);
}
@Test
void indemnificationIsFivePercentOfTheMeter() {
long base = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
QuoteConfig c =
new QuoteConfig(1_000_000, 0, "cloud", 5, "dedicated", true, true, true, "USD");
new QuoteConfig(
6_000_000, 0, 4, "cloud", 3, "standard", true, false, false, false, "USD");
QuoteBreakdown q = pricing.price(c);
assertThat(lineAmount(q, "indemnification")).isEqualTo(Math.round(base * 0.05));
}
long usage = 4_000_000L;
long withSla = Math.round(usage * 1.30); // 5,200,000
long withIndemnity = Math.round(withSla * 1.05); // 5,460,000
long discount = Math.round(withIndemnity * 0.15); // 819,000
long qbr = 800_000L;
long expectedAnnual = (withIndemnity - discount) + qbr;
long expectedTcv = expectedAnnual * 5 + 750_000L; // + training one-time
@Test
void trainingIsOneTimeOutsideTheAnnual() {
QuoteConfig withTraining =
new QuoteConfig(
6_000_000, 0, 4, "cloud", 3, "standard", false, true, false, false, "USD");
QuoteBreakdown q = pricing.price(withTraining);
long baseAnnual = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
assertThat(q.annualNetMinor()).isEqualTo(expectedAnnual);
assertThat(q.tcvMinor()).isEqualTo(expectedTcv);
assertThat(q.annualNetMinor()).isEqualTo(baseAnnual); // one-time never touches the annual
assertThat(q.tcvMinor()).isEqualTo(baseAnnual * 3 + 750_000L); // + $7,500 once
assertThat(q.lineItems())
.anyMatch(l -> l.key().equals("training") && l.kind() == Kind.ONE_TIME);
assertThat(q.lineItems()).anyMatch(l -> l.key().equals("qbr"));
assertThat(q.lineItems()).anyMatch(l -> l.key().equals("indemnification"));
}
@Test
void standardSingleYearHasNoUpliftOrDiscountLines() {
QuoteBreakdown q = pricing.price(cfg(1_000_000, "standard", 1));
assertThat(q.annualNetMinor()).isEqualTo(4_000_000L);
assertThat(q.tcvMinor()).isEqualTo(4_000_000L);
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("service-level"));
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("multi-year"));
void unsetPostureDefaultsToGoverned() {
long defaulted = pricing.price(cfg(6_000_000, 0, "cloud", 3, "standard")).annualNetMinor();
long governed = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
assertThat(defaulted).isEqualTo(governed);
}
@Test
void ssoIsAlwaysAnIncludedZeroLine() {
QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard"));
assertThat(q.lineItems())
.anyMatch(l -> l.key().equals("seats") && l.kind() == Kind.INCLUDED);
}
@Test
void volumeEstimateFromSeats() {
// ~2,013 PDFs/user/yr
assertThat(pricing.estimateAnnualVolume(100)).isEqualTo(201_250L);
assertThat(pricing.estimateAnnualVolume(0)).isZero();
}
+4
View File
@@ -42,6 +42,9 @@ COPY . .
ARG PROTOTYPES_BUILD=false
ARG STIRLING_FLAVOR=proprietary
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
# Embed the admin portal app at /portal. Set true by the deploy workflow when the
# portal or AI layers change; defaults false so normal builds skip the extra app.
ARG BUILD_PORTAL=false
# Bundle only the JPDFium native for this image's target arch.
ARG TARGETARCH
@@ -49,6 +52,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
STIRLING_FLAVOR=${STIRLING_FLAVOR} \
gradle clean build \
-PbuildWithFrontend=true \
-PbuildWithPortal=${BUILD_PORTAL} \
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
-PprototypesMode=${PROTOTYPES_BUILD} \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
+3
View File
@@ -40,12 +40,15 @@ RUN gradle dependencies --no-daemon || true
COPY . .
# Embed the admin portal app at /portal when the deploy workflow flags it.
ARG BUILD_PORTAL=false
# Bundle only the JPDFium native for this image's target arch.
ARG TARGETARCH
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
DISABLE_ADDITIONAL_FEATURES=false \
gradle clean build \
-PbuildWithFrontend=true \
-PbuildWithPortal=${BUILD_PORTAL} \
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
+3
View File
@@ -40,12 +40,15 @@ RUN ./gradlew dependencies --no-daemon || true
COPY . .
# Build ultra-lite JAR with embedded frontend (minimal features).
# Embed the admin portal app at /portal when the deploy workflow flags it.
ARG BUILD_PORTAL=false
# Bundle only the JPDFium native for this image's target arch.
ARG TARGETARCH
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
DISABLE_ADDITIONAL_FEATURES=true \
./gradlew clean build \
-PbuildWithFrontend=true \
-PbuildWithPortal=${BUILD_PORTAL} \
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
+5
View File
@@ -61,6 +61,11 @@ const config: StorybookConfig = {
config.define = {
...(config.define ?? {}),
"import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"),
// Keep the Supabase auth env empty so ensureSaasSupabase() is a no-op and
// never replaces the mock SaaS client stubbed in preview.tsx.
"import.meta.env.VITE_SUPABASE_URL": JSON.stringify(""),
"import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY":
JSON.stringify(""),
};
return config;
},
+4 -4
View File
@@ -29,10 +29,10 @@ initialize({ onUnhandledRequest: "bypass" }, handlers);
// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment
// method, wallet) clear the session check and reach the MSW handlers instead of
// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally
// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only
// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via
// .storybook/main.ts's viteFinal define, not a frontend/.env file.
// failing with "No SaaS session". VITE_SUPABASE_URL/KEY are defined empty (see
// .storybook/main.ts), so ensureSaasSupabase() is a no-op and never replaces this
// client; only VITE_SAAS_API_URL (a mock origin MSW matches) is configured —
// injected via .storybook/main.ts's viteFinal define, not a frontend/.env file.
const saasStub = configureSupabase({
url: "http://saas.mock",
key: "storybook-anon-key",
+7
View File
@@ -7,6 +7,10 @@
# API base URL — use / for same-origin (default for web builds)
VITE_API_BASE_URL=/
# Include the admin portal's lazy route/chunk in the build (set true by
# -PbuildWithPortal in the JAR). Off by default; always on in dev.
VITE_INCLUDE_PORTAL=false
# Google Drive integration
VITE_GOOGLE_DRIVE_CLIENT_ID=
VITE_GOOGLE_DRIVE_API_KEY=
@@ -22,3 +26,6 @@ VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Q56W2P9mY5IAnSnp3kcxG50uyFMLuhM4fFs774DAP3
# PostHog analytics
VITE_PUBLIC_POSTHOG_KEY=phc_VOdeYnlevc2T63m3myFGjeBlRcIusRgmhfx6XL5a1iz
VITE_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
# Calendly scheduling link (portal "Schedule a call")
VITE_CALENDLY_URL=https://calendly.com/d/cm4p-zz5-yy8/stirling-pdf-15-minute-group-discussion
-7
View File
@@ -18,13 +18,6 @@ VITE_EDITOR_URL=/
# backend.
VITE_PORTAL_MOCKS=
# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account
# linking (both values are public). Set per deploy; absent -> the account-link
# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase
# project the local backend links against.
VITE_SAAS_SUPABASE_URL=
VITE_SAAS_SUPABASE_ANON_KEY=
# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for
# ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the
# admin's Supabase JWT. Distinct from the local backend (reached same-origin via
@@ -6062,6 +6062,17 @@ stepOf = "Step {{step}} of {{total}}"
toolChainDesc = "Configure the tools this policy runs on each document."
typesSelected = "{{count}} types selected"
[policy]
badgeEnforcing = "{{name}} enforcing..."
badgeRan = "{{name}} policy ran on this file"
blockingAction = "{{action}} blocked while enforcing policy, please wait..."
dismiss = "Dismiss overlay"
enforcingTitle = "Enforcing policy..."
viewAnyway = "View file (policy still enforcing)"
[portal]
comingSoon = "Coming soon"
[portal.accountLink.card]
billingNote = "Unattended processing bills against your org wallet."
eyebrow = "Account link"
@@ -6273,7 +6284,6 @@ cost = "Cost"
editorsDeployed = "Editors deployed"
inviteTeammates = "Invite teammates"
pdfsEdited = "PDFs edited"
previewBadge = "Preview · sample data"
subtitle = "Deploy anywhere, for your whole team."
title = "Free PDF Editors"
@@ -6569,7 +6579,9 @@ beforeHelper = "header against your signing secret before trusting a payload. SD
beforeSignature = "Verify the"
[portal.documents]
subtitle = "Review and approve documents moving through your pipelines."
exportCsv = "Export CSV"
search = "Search by filename, ID..."
subtitle = "Every document your org has processed, with its full processing record. Content access is request-gated."
title = "Documents"
[portal.documents.audit]
@@ -6607,42 +6619,38 @@ value = "Value"
[portal.documents.filters]
all = "All"
archived = "Archived"
ariaLabel = "Filter documents by status"
needsReview = "Needs review"
flagged = "Flagged"
inReview = "In review"
processed = "Processed"
[portal.documents.overview]
confidence = "Confidence"
fieldsExtracted = "Fields extracted"
received = "Received"
source = "Source"
action = "Pipeline / Action"
product = "Product"
received = "Time"
status = "Status"
type = "Type"
user = "User"
[portal.documents.queue.empty]
description = "As sources feed documents into your pipelines they'll appear here for review."
title = "No documents in the queue"
[portal.documents.summary]
avgConfidence = "Avg confidence"
inQueue = "In queue"
needsReview = "Needs review"
processedToday = "Processed today"
[portal.documents.table]
auto = "Auto"
editorAction = "Editor"
empty = "No documents match this filter."
rowActions = "Row actions"
sensitiveLabel = "Sensitive"
sensitiveTitle = "Sensitive — access required"
[portal.documents.table.columns]
confidence = "Confidence"
fields = "Fields"
name = "Name"
source = "Source"
action = "Pipeline / Action"
document = "Document"
product = "Product"
status = "Status"
time = "Time"
type = "Type"
user = "User"
[portal.editorAdmin]
subtitle = "Deploy the Stirling PDF Editor, pair self-hosted instances to your org, and operate the running fleet — targets, health, credentials, and offline activation in one place."
@@ -6739,6 +6747,23 @@ ready = "Ready to deploy"
description = "Once documents are processed, your 30-day usage appears here."
title = "No usage yet"
[portal.home.editor]
activeUsers = "{{n}} active"
invite = "Invite teammates"
name = "Stirling PDF Editor"
open = "Open in browser"
updated = "updated {{time}}"
[portal.home.editor.target]
cloud = "Managed Cloud"
docker = "Self-hosted · Docker"
kubernetes = "Self-hosted · Kubernetes"
[portal.home.greeting]
afternoon = "Good afternoon"
evening = "Good evening"
morning = "Good morning"
[portal.home.kpis.enterprise]
docs30d = "Docs / 30d"
evalPassRate = "Eval pass rate"
@@ -6759,15 +6784,31 @@ evalPassRate = "Eval pass rate"
pipelines = "Pipelines"
[portal.home.onboarding]
progress = "{{done}} / {{total}} done"
runAgain = "Run again"
start = "Start"
subtitle = "Four steps to a production-shaped Stirling project."
title = "Get to value"
dismiss = "Dismiss setup"
notStarted = "Not started"
progress = "{{done}} of {{total}} done"
title = "Finish setting up"
[portal.home.onboarding.empty]
description = "Onboarding tasks will appear here once your workspace is set up."
title = "No onboarding steps yet"
[portal.home.onboarding.enterprise]
body = "Org-wide SSO + SCIM + RBAC, committed volume pricing, and air-gapped deployment."
cta = "Start Trial"
ctaQuote = "Get Quote"
lead = "For 250+ employees."
tag = "Enterprise"
[portal.home.onboarding.steps.editor]
blurb = "Deploy the desktop app, free forever."
title = "Download the PDF Editor"
[portal.home.onboarding.steps.policies]
blurb = "{{active}} active · {{recommended}} recommended"
chip = "{{active}} active"
title = "Turn on policies"
[portal.home.onboarding.steps.sources]
blurb = "{{connected}} connected · every PDF governed where it lands"
chip = "{{connected}} connected"
title = "Connect your sources"
[portal.home.productGrid]
ariaLabel = "Process PDFs at scale"
@@ -6854,13 +6895,32 @@ actor = "Actor"
event = "Event"
latency = "Latency"
status = "Status"
target = "Target"
target = "Resource"
timestamp = "Timestamp"
[portal.infrastructure.audit.empty]
description = "Workspace activity will appear here as it happens."
title = "No audit events"
[portal.infrastructure.audit.export]
cancel = "Cancel"
error = "Export failed. Please try again."
exportButton = "Export"
exporting = "Exporting…"
fieldsLegend = "Columns"
format = "Format"
open = "Export"
subtitle = "Download audit events as CSV or JSON. Choose which columns to include."
title = "Export audit log"
[portal.infrastructure.audit.export.fields]
date = "Date"
documentName = "Document name"
ipAddress = "IP address"
outcome = "Outcome"
tool = "Tool"
username = "Username"
[portal.infrastructure.audit.filters]
all = "All"
auth = "Auth"
@@ -6869,6 +6929,10 @@ elevation = "Elevation"
processing = "Processing"
security = "Security"
[portal.infrastructure.audit.forbidden]
description = "The audit log is available to team leads and admins. Ask your team lead or an admin for access."
title = "You don't have access"
[portal.infrastructure.audit.metrics]
config = "Config"
elevation = "Elevation"
@@ -7197,18 +7261,39 @@ title = "Pipelines"
[portal.pipelines.actions]
newPipeline = "New pipeline"
[portal.pipelines.builder]
addStep = "Add tool"
back = "Back to pipelines"
discard = "Discard changes"
enabled = "Enabled"
keepEditing = "Keep editing"
needsUpload = "Needs an uploaded file"
noToolMatches = "No tools match your search."
pipelineSettings = "Pipeline settings"
searchTools = "Search tools"
selectToolBody = "Add a tool to build your pipeline."
selectToolTitle = "No tools yet"
toolSettings = "Tool settings"
unknownStep = "Unrecognized operation, kept as-is."
unsavedBody = "You have unsaved changes. Save them before leaving, or discard them?"
unsavedTitle = "Unsaved changes"
uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}."
usesDefaults = "Runs with default settings"
[portal.pipelines.composer]
addTool = "Add tool"
cancel = "Cancel"
chainEmpty = "Add operations from the palette below."
chainEmpty = "Add a tool to start building your pipeline."
create = "Create pipeline"
directory = "Output folder"
directoryHelp = "Absolute path on the server. Must be within the configured allowed folders."
editTitle = "Edit pipeline"
editingUnsupported = "Displaying these tool params for editing is not supported yet."
moveDown = "Move down"
moveUp = "Move up"
name = "Name"
namePlaceholder = "e.g. Redaction sweep"
noSources = "No sources connected yet. The pipeline can still run on files supplied to it directly."
noToolSettings = "This tool has no configurable settings."
operations_one = "Operation ({{count}})"
operations_other = "Operations ({{count}})"
output = "Output"
@@ -7217,8 +7302,6 @@ save = "Save changes"
scheduleEvery = "Run every"
sources = "Sources"
sourcesLoading = "Loading sources..."
subtitle = "Pick the sources it runs over, chain the operations, then choose when it runs and where output goes."
title = "New pipeline"
trigger = "Trigger"
triggerManual = "Manual only"
@@ -7234,18 +7317,8 @@ confirm = "Delete"
title = "Delete pipeline?"
[portal.pipelines.detail]
closeAriaLabel = "Close detail"
delete = "Delete pipeline"
edit = "Edit"
noSources = "No sources. Files are supplied directly to each run."
noSteps = "No operations configured."
output = "Output"
pause = "Pause"
resume = "Resume"
run = "Run now"
sources = "Sources"
steps = "Operations"
subtitle = "{{trigger}} · {{status}}"
[portal.pipelines.empty]
action = "Create a pipeline"
@@ -7267,6 +7340,7 @@ completed_other = "All {{count}} runs completed."
empty = "Nothing to run: the sources had no documents to process."
failed = "Run failed: {{error}}"
running = "Run started; still in progress."
timeout = "Run is taking longer than expected; it may still finish in the background."
[portal.pipelines.status]
active = "Active"
@@ -7477,6 +7551,8 @@ generate = "Generate quote"
included = "Included"
indemnification = "IP indemnification"
indemnificationSub = "We defend qualifying IP claims, per the EULA"
offlineLicense = "Offline / air-gapped licence"
offlineLicenseSub = "A downloadable licence file for an air-gapped self-hosted instance"
qbr = "Quarterly business reviews"
qbrSub = "Your SE reviews usage and roadmap each quarter"
running = "{{annual}} / yr · {{years}}-yr {{tcv}}"
@@ -7562,6 +7638,14 @@ subtitle = "Your solutions engineer is on every step. One next action at a time;
title = "From trial to live, one guided path"
trialTitle = "Enterprise trial"
[portal.procurement.license]
copied = "Copied"
copy = "Copy key"
downloadError = "Could not generate the offline licence file just yet — please try again in a moment."
downloadOffline = "Download offline licence (.lic)"
hint = "Paste this key into a self-hosted instance to activate it, or keep it for your records. Keep it safe."
label = "Your licence key"
[portal.procurement.link]
cta = "Link account"
description = "Procurement runs on your linked Stirling account: it's how we provision the trial, price your quote, and start billing. Link an account to start."
@@ -7614,12 +7698,18 @@ uploadCta = "Upload purchase order"
uploadTitle = "Upload your purchase order"
[portal.procurement.payment]
description = "Your quote is accepted and a committed annual subscription has been created. Pay the first invoice to go live — you can pay or download it right here, no email needed."
description = "Your quote is accepted and your licence is already active — your team can start right away. Pay the first invoice when you're ready; you can pay or download it here, no email needed."
downloadInvoice = "Download invoice"
simulate = "Simulate payment received (demo)"
title = "Subscription created"
viewInvoice = "View & pay invoice"
[portal.procurement.schedule]
fallback = "Couldn't load the scheduler."
fallbackLink = "Open scheduling in a new tab"
subtitle = "Your solutions engineer will walk your team through the rollout. Pick a time that suits you."
title = "Schedule a call"
[portal.procurement.status]
action = "Action needed"
available = "Available"
@@ -8015,36 +8105,14 @@ status = "Status"
suspend = "Suspend"
[portal.welcome]
ariaLabel = "Stirling product highlights"
pagination = "Carousel pagination"
slideLabel = "Slide {{number}}: {{title}}"
[portal.welcome.ornament.editor]
critical = "Critical"
ocrClean = "OCR-clean"
schemaMatch = "schema match 0.97"
signed = "signed"
[portal.welcome.slides.agents]
eyebrow = "AI Agents"
primary = "Try PDF Processor"
secondary = "View MCP docs"
sub = "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship."
title = "PDF Processor for AI Agents"
[portal.welcome.slides.editor]
eyebrow = "PDF Editor"
primary = "Install PDF Editor"
secondary = "Connect an instance"
sub = "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane."
title = "The #1 PDF Editor on GitHub"
[portal.welcome.slides.platform]
eyebrow = "Platform"
primary = "Try a PDF operation"
secondary = "Get an API key"
sub = "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls."
title = "PDF Infrastructure for Developers"
ariaLabel = "Welcome to Stirling PDF"
badge = "Open-source"
installEditor = "Install the Editor"
inviteTeammates = "Invite teammates"
perks = "Free forever · Self-hostable"
subtitle = "The world's most secure PDF Editor is free for teams of all sizes. Includes 60+ PDF operations and SSO."
title = "Welcome to"
titleAccent = "Stirling PDF"
[printFile]
title = "Print File"
@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, useEffect } from "react";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { flushSync } from "react-dom";
import { Center, Box, LoadingOverlay } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
@@ -19,6 +19,10 @@ import { FileId, StirlingFile } from "@app/types/fileContext";
import { alert } from "@app/components/toast";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges";
const EMPTY_POLICIES: FileItemPolicyRef[] = [];
interface FileEditorProps {
onOpenPageEditor?: () => void;
@@ -31,6 +35,8 @@ const FileEditor = ({
toolMode = false,
supportedExtensions = ["pdf"],
}: FileEditorProps) => {
const policyFileBadges = usePolicyFileBadges();
// Utility function to check if a file extension is supported
const isFileSupported = useCallback(
(fileName: string): boolean => {
@@ -52,6 +58,14 @@ const FileEditor = ({
[state.files.byId, state.files.ids],
);
// Always-current refs so callbacks can read the latest stubs/selection without
// closing over them as deps — prevents every callback from regenerating whenever
// any stub changes (e.g. thumbnail load), which would bust React.memo on every thumbnail.
const stubsRef = useRef(activeStirlingFileStubs);
stubsRef.current = activeStirlingFileStubs;
const selectedFileIdsRef = useRef(selectedFileIds);
selectedFileIdsRef.current = selectedFileIds;
// Get navigation actions
const { actions: navActions } = useNavigationActions();
@@ -141,7 +155,7 @@ const FileEditor = ({
// File reordering handler for drag and drop
const handleReorderFiles = useCallback(
(sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
const currentIds = activeStirlingFileStubs.map((r) => r.id);
const currentIds = stubsRef.current.map((r) => r.id);
// Find indices
const sourceIndex = currentIds.findIndex((id) => id === sourceFileId);
@@ -211,38 +225,27 @@ const FileEditor = ({
const moveCount = filesToMove.length;
showStatus(`${moveCount > 1 ? `${moveCount} files` : "File"} reordered`);
},
[activeStirlingFileStubs, reorderFiles, _setStatus],
[reorderFiles, showStatus],
);
// File operations using context
const handleCloseFile = useCallback(
(fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const record = stubsRef.current.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
// Remove file from context but keep in storage (close, don't delete)
const contextFileId = record.id;
removeFiles([contextFileId], false);
// Remove from context selections
const currentSelected = selectedFileIds.filter(
(id) => id !== contextFileId,
removeFiles([record.id], false);
setSelectedFiles(
selectedFileIdsRef.current.filter((id) => id !== record.id),
);
setSelectedFiles(currentSelected);
}
},
[
activeStirlingFileStubs,
selectors,
removeFiles,
setSelectedFiles,
selectedFileIds,
],
[selectors, removeFiles, setSelectedFiles],
);
const handleDownloadFile = useCallback(
async (fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const record = stubsRef.current.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
console.log("[FileEditor] handleDownloadFile called:", {
fileId,
@@ -278,12 +281,12 @@ const FileEditor = ({
}
}
},
[activeStirlingFileStubs, selectors, fileActions],
[selectors, fileActions],
);
const handleUnzipFile = useCallback(
async (fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const record = stubsRef.current.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
try {
@@ -326,24 +329,19 @@ const FileEditor = ({
}
}
},
[activeStirlingFileStubs, selectors, fileActions, removeFiles],
[selectors, fileActions, removeFiles],
);
const handleViewFile = useCallback(
(fileId: FileId) => {
const index = activeStirlingFileStubs.findIndex((r) => r.id === fileId);
const index = stubsRef.current.findIndex((r) => r.id === fileId);
if (index !== -1) {
setActiveFileId(fileId as string);
setActiveFileIndex(index);
navActions.setWorkbench("viewer");
}
},
[
activeStirlingFileStubs,
setActiveFileId,
setActiveFileIndex,
navActions.setWorkbench,
],
[setActiveFileId, setActiveFileIndex, navActions.setWorkbench],
);
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
@@ -412,6 +410,10 @@ const FileEditor = ({
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
policies={
policyFileBadges.get(record.id as string) ??
EMPTY_POLICIES
}
/>
);
})}
@@ -123,6 +123,14 @@
left: 0;
right: 0;
bottom: calc(var(--file-meta-line-height) + var(--file-meta-gap));
display: flex;
align-items: flex-start;
justify-content: center;
gap: 4px;
overflow: hidden;
}
.fileNameText {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
@@ -131,6 +139,12 @@
text-overflow: ellipsis;
overflow-wrap: break-word;
word-break: normal;
min-width: 0;
}
/* Layout-only wrapper for the shared PolicyBadges row in the file-name line. */
.fileNameBadges {
padding-top: 2px;
}
.fileMeta {
@@ -207,6 +221,7 @@
pointer-events: auto;
}
/* Policy badges pinned to top-right of thumbnail */
.statusDot {
display: inline-block;
width: 8px;
@@ -1,5 +1,5 @@
import React, { useState, useCallback, useRef, useMemo } from "react";
import { Text, Modal, Group, Stack, Tooltip } from "@mantine/core";
import { Text, Modal, Group, Loader, Stack, Tooltip } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Button } from "@app/ui/Button";
import { useIsMobile } from "@app/hooks/useIsMobile";
@@ -16,11 +16,17 @@ import HistoryIcon from "@mui/icons-material/History";
import PushPinIcon from "@mui/icons-material/PushPin";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { StirlingFileStub } from "@app/types/fileContext";
import {
PolicyBadges,
type FileItemPolicyRef,
} from "@app/components/shared/PolicyBadges";
import { PolicyEnforcingOverlay } from "@app/components/shared/PolicyEnforcingOverlay";
import { zipFileService } from "@app/services/zipFileService";
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
@@ -57,6 +63,7 @@ interface FileEditorThumbnailProps {
onUnzipFile?: (fileId: FileId) => void;
toolMode?: boolean;
isSupported?: boolean;
policies?: FileItemPolicyRef[];
}
const FileEditorThumbnail = ({
@@ -67,6 +74,7 @@ const FileEditorThumbnail = ({
onDownloadFile,
onUnzipFile,
isSupported = true,
policies = [],
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
const { config } = useAppConfig();
@@ -286,8 +294,28 @@ const FileEditorThumbnail = ({
const [showVersionHistory, setShowVersionHistory] = useState(false);
const hoverActions = useMemo<HoverAction[]>(
() => [
const policyEnforcing = policies.some((p) => p.enforcing);
const hoverActions = useMemo<HoverAction[]>(() => {
const uploadLabel = isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server");
const enforcingTooltip = (action: string): React.ReactNode => (
<Stack gap={4} py={2} w={180}>
<Group gap={6} wrap="nowrap">
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
<Text size="xs" fw={600}>
{t(
"policy.blockingAction",
"{{action}} blocked while enforcing policy, please wait...",
{ action },
)}
</Text>
</Group>
<Loader size="xs" />
</Stack>
);
return [
{
id: "view",
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
@@ -331,6 +359,10 @@ const FileEditorThumbnail = ({
id: "download",
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
label: terminology.download,
disabled: policyEnforcing,
tooltip: policyEnforcing
? enforcingTooltip(terminology.download)
: undefined,
onClick: (e) => {
e.stopPropagation();
onDownloadFile(file.id);
@@ -341,9 +373,11 @@ const FileEditorThumbnail = ({
{
id: "upload",
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
label: isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server"),
label: uploadLabel,
disabled: policyEnforcing,
tooltip: policyEnforcing
? enforcingTooltip(uploadLabel)
: undefined,
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowUploadModal(true);
@@ -357,6 +391,10 @@ const FileEditorThumbnail = ({
id: "share",
icon: <LinkIcon style={{ fontSize: 20 }} />,
label: t("fileManager.share", "Share"),
disabled: policyEnforcing,
tooltip: policyEnforcing
? enforcingTooltip(t("fileManager.share", "Share"))
: undefined,
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowShareModal(true);
@@ -402,30 +440,30 @@ const FileEditorThumbnail = ({
},
color: "red",
},
],
[
t,
file.id,
file.name,
file.versionNumber,
isZipFile,
isCBZ,
isCBR,
isPinned,
actualFile,
terminology,
DownloadOutlinedIcon,
onViewFile,
onDownloadFile,
onUnzipFile,
handleCloseWithConfirmation,
canUpload,
canShare,
isUploaded,
pinFile,
unpinFile,
],
);
];
}, [
t,
file.id,
file.name,
file.versionNumber,
isZipFile,
isCBZ,
isCBR,
isPinned,
actualFile,
terminology,
DownloadOutlinedIcon,
onViewFile,
onDownloadFile,
onUnzipFile,
handleCloseWithConfirmation,
policyEnforcing,
canUpload,
canShare,
isUploaded,
pinFile,
unpinFile,
]);
const handleCardClick = () => {
if (!isSupported) return;
@@ -497,6 +535,9 @@ const FileEditorThumbnail = ({
</div>
)}
{/* Policy enforcement overlay — shown while any policy is in-flight */}
<PolicyEnforcingOverlay enforcing={policyEnforcing} zIndex={2} />
{/* Thumbnail image or loading state */}
<DocumentThumbnail
file={file}
@@ -580,7 +621,10 @@ const FileEditorThumbnail = ({
{/* File name + meta */}
<div className={styles.fileText}>
<p className={styles.fileName}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
<span className={styles.fileNameText}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
</span>
<PolicyBadges policies={policies} className={styles.fileNameBadges} />
</p>
<p className={styles.fileMeta}>{metaLine}</p>
</div>
@@ -4,7 +4,7 @@ import { Checkbox, Menu, Tooltip } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import { PolicyBadges as PolicyBadgeRow } from "@app/components/shared/PolicyBadges";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
@@ -581,26 +581,7 @@ function FolderCard({
/** Shield badges for the policies that have run on a file. */
function PolicyBadges({ fileId }: { fileId: string }) {
const badges = usePolicyFileBadges().get(fileId) ?? [];
if (badges.length === 0) return null;
return (
<span className="files-page-policy-badges" data-no-select>
{badges.slice(0, 3).map((policy) => (
<Tooltip
key={policy.id}
label={`${policy.name} policy ran on this file`}
withArrow
position="top"
>
<span
className="files-page-policy-badge"
style={{ color: policy.accentColor }}
>
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
</span>
</Tooltip>
))}
</span>
);
return <PolicyBadgeRow policies={badges} />;
}
interface FileCardProps {
@@ -484,24 +484,6 @@
gap: 0.4rem;
}
/* Policy activity badges (a shield per policy that has run on the file). */
.files-page-policy-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.files-page-policy-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 4px;
/* `color` set inline to the policy accent; tint follows it. */
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* Parent-folder breadcrumb shown on cards/rows during recursive search so
the user can tell which folder each hit lives in without navigating. */
.files-page-card-path {
@@ -0,0 +1,27 @@
/**
* Core stub — the real implementation lives in the proprietary overlay.
* Returns empty state so core-build consumers compile without a policyRunStore module.
*/
export interface PolicyRunRecord {
runId: string;
categoryId: string;
fileId: string;
fileName: string;
status: string;
currentStep?: number;
stepCount?: number;
error: string | null;
retrying?: boolean;
startedAt: number;
}
export const POLICY_IN_FLIGHT_STATUSES = [
"PENDING",
"RUNNING",
"WAITING_FOR_INPUT",
] as const;
export function usePolicyRuns(): PolicyRunRecord[] {
return [];
}
@@ -0,0 +1,27 @@
/* Trigger: bare square icon button that blends into either sidebar's header. */
.app-switch-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: none;
background: none;
cursor: pointer;
border-radius: var(--radius-sm);
color: var(--color-text-4);
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.app-switch-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text-2);
}
.app-switch-icon {
width: 1rem;
height: 1.0625rem;
display: block;
}
@@ -0,0 +1,82 @@
import { useTranslation } from "react-i18next";
import { Button, Dropdown } from "@app/ui";
import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
import "@app/components/shared/AppSwitch.css";
export type AppSwitchTarget = "editor" | "processor";
function ChevronDownIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={14}
height={14}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
interface AppSwitchProps {
/** The app this switcher is rendered in (shown as active in the menu). */
current: AppSwitchTarget;
/** Resolved color scheme; picks the brand mark for the menu items. */
theme: "light" | "dark";
/** Invoked with the selected app; only called for apps other than `current`. */
onSwitch: (app: AppSwitchTarget) => void;
className?: string;
}
/**
* The editor ⇄ processor app switcher (chevron button → app menu). The editor
* and portal sidebars render this same element so the two apps present one
* identical switcher; each host supplies its own theme source and navigation.
*/
export function AppSwitch({
current,
theme,
onSwitch,
className,
}: AppSwitchProps) {
const { t } = useTranslation();
const mark = theme === "dark" ? markDark : markLight;
const apps: Array<{ id: AppSwitchTarget; label: string }> = [
{
id: "processor",
label: t("portal.shell.sidebar.appProcessor", "Processor"),
},
{ id: "editor", label: t("portal.shell.sidebar.appEditor", "Editor") },
];
return (
<Dropdown.Root align="end" className={className}>
<Dropdown.Trigger>
<Button
variant="tertiary"
className="app-switch-btn"
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
>
<ChevronDownIcon />
</Button>
</Dropdown.Trigger>
<Dropdown.Menu width="11rem">
{apps.map((app) => (
<Dropdown.Item
key={app.id}
active={current === app.id}
onSelect={app.id === current ? undefined : () => onSwitch(app.id)}
leading={<img className="app-switch-icon" src={mark} alt="" />}
>
{app.label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown.Root>
);
}
@@ -0,0 +1,8 @@
/**
* Core stub for the sidebar app switcher. Builds that bundle the admin portal
* (proprietary/saas) shadow this with a real switcher; core has no portal, so
* there is nothing to switch to.
*/
export function AppSwitcher() {
return null;
}
@@ -102,6 +102,18 @@
flex-shrink: 0;
}
/* App switcher (portal builds only) sits at the far end of the header row.
The content-fade animation makes this span a stacking context, which would
trap the menu's z-index below later sidebar rows — elevate the span so the
open menu paints above them. */
.file-sidebar-app-switch {
margin-inline-start: auto;
display: flex;
align-items: center;
position: relative;
z-index: var(--z-dropdown);
}
/* ---- Search row ---- */
.file-sidebar-search-row {
display: flex;
@@ -29,6 +29,7 @@ import {
import { accountService } from "@app/services/accountService";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import { Wordmark } from "@app/components/shared/Wordmark";
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
import type { StirlingFileStub } from "@app/types/fileContext";
import MenuIcon from "@mui/icons-material/Menu";
import SearchIcon from "@mui/icons-material/Search";
@@ -679,6 +680,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
className="file-sidebar-brand-text sidebar-content-fade"
/>
)}
{!collapsed && (
// The header row itself toggles collapse; stop the switcher's
// clicks and key presses from reaching it.
<span
className="file-sidebar-app-switch sidebar-content-fade"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<AppSwitcher />
</span>
)}
</div>
</Tooltip>
@@ -36,32 +36,6 @@
background-color: rgba(59, 130, 246, 0.06);
}
/* A policy-enforced file glows in that policy's accent colour so it's obvious
the file has had a policy applied: it pulses a few times to catch the eye and
then fades out (no lingering glow). */
.file-sidebar-file-item.policy-enforced {
animation: policy-glow-fade 4.5s ease-in-out forwards;
}
@keyframes policy-glow-fade {
0%,
24%,
48% {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 30%, transparent),
0 0 6px -2px var(--policy-glow);
}
12%,
36%,
60% {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 75%, transparent),
0 0 16px 0 var(--policy-glow);
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
.file-sidebar-file-item.selected {
background-color: rgba(59, 130, 246, 0.12);
}
@@ -160,25 +134,6 @@
text-overflow: ellipsis;
}
/* ---- Policy activity badges (a shield per policy that has run on the file) ---- */
.file-sidebar-policy-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.file-sidebar-policy-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 4px;
/* `color` is set inline to the policy's accent; the tint follows it. */
color: var(--text-secondary);
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* ---- Cloud badge (file saved to the server) ---- */
.file-sidebar-cloud-badge {
display: inline-flex;
@@ -1,6 +1,6 @@
import { useState, useCallback, useRef } from "react";
import React, { useState, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { Menu, Tooltip } from "@mantine/core";
import { Group, Loader, Menu, Stack, Text, Tooltip } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
@@ -13,6 +13,10 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import HistoryIcon from "@mui/icons-material/History";
import type { FileId } from "@app/types/file";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import {
PolicyBadges,
type FileItemPolicyRef,
} from "@app/components/shared/PolicyBadges";
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
@@ -132,17 +136,6 @@ export interface FileItemFolderRef {
accentColor: string;
}
/** A policy that has run on this file, used for the activity badges. */
export interface FileItemPolicyRef {
id: string;
name: string;
/** CSS colour for the badge (matches the policy's accent). */
accentColor: string;
/** True only just after the policy was applied — drives the one-off glow, so
* it doesn't replay on every reload of an already-enforced file. */
recent: boolean;
}
export interface FileItemProps {
fileId: FileId;
name: string;
@@ -178,7 +171,6 @@ export interface FileItemProps {
}
const MAX_VISIBLE_FOLDER_TAGS = 2;
const MAX_VISIBLE_POLICY_BADGES = 3;
export function FileItem({
fileId,
@@ -208,6 +200,23 @@ export function FileItem({
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
const typeLabel = ext ? ext.toUpperCase() : "File";
const policyEnforcing = policies.some((p) => p.enforcing);
const enforcingTooltip = (action: string): React.ReactNode => (
<Stack gap={6} py={2} w={200}>
<Group gap={6} wrap="nowrap">
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
<Text size="xs" fw={600}>
{t(
"policy.blockingAction",
"{{action}} blocked while enforcing policy, please wait...",
{ action },
)}
</Text>
</Group>
<Loader size="xs" />
</Stack>
);
const visibleFolders = folders.slice(0, MAX_VISIBLE_FOLDER_TAGS);
const overflowFolders = folders.slice(MAX_VISIBLE_FOLDER_TAGS);
@@ -228,9 +237,6 @@ export function FileItem({
const handleMouseLeave = useCallback(() => setHoverRect(null), []);
// A just-applied policy (recent run) drives the one-off row glow.
const recentPolicy = policies.find((p) => p.recent);
// Reactive: tooltip appears as soon as both hover rect and thumbnail are ready
const thumbPos =
hoverRect && resolvedThumbnail
@@ -244,14 +250,7 @@ export function FileItem({
<>
<div
ref={itemRef}
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}${recentPolicy ? " policy-enforced" : ""}`}
style={
recentPolicy
? ({
"--policy-glow": recentPolicy.accentColor,
} as React.CSSProperties)
: undefined
}
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
onClick={() => onClick(fileId)}
draggable={draggable}
onDragStart={
@@ -301,25 +300,7 @@ export function FileItem({
</span>
</Tooltip>
)}
{policies.length > 0 && (
<span className="file-sidebar-policy-badges" data-no-select>
{policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => (
<Tooltip
key={policy.id}
label={`${policy.name} policy ran on this file`}
withArrow
position="top"
>
<span
className="file-sidebar-policy-badge"
style={{ color: policy.accentColor }}
>
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
</span>
</Tooltip>
))}
</span>
)}
<PolicyBadges policies={policies} />
</span>
{folders.length > 0 && (
<span className="file-sidebar-folder-tags" data-no-select>
@@ -395,7 +376,7 @@ export function FileItem({
/>
</ActionIcon>
{(onDelete ||
onSaveToCloud ||
(canSaveToCloud && onSaveToCloud) ||
(hasVersionHistory && onVersionHistory)) && (
<Menu position="bottom-end" withinPortal shadow="md" width={190}>
<Menu.Target>
@@ -425,17 +406,10 @@ export function FileItem({
{t("fileSidebar.fileItem.versionHistory", "Version history")}
</Menu.Item>
)}
{canSaveToCloud && onSaveToCloud && (
<Menu.Item
leftSection={
<CloudUploadOutlinedIcon sx={{ fontSize: 16 }} />
}
onClick={(e) => {
e.stopPropagation();
onSaveToCloud(fileId);
}}
>
{isUploadedToCloud
{canSaveToCloud &&
onSaveToCloud &&
(() => {
const uploadLabel = isUploadedToCloud
? t(
"fileSidebar.fileItem.updateOnServer",
"Update on server",
@@ -443,21 +417,64 @@ export function FileItem({
: t(
"fileSidebar.fileItem.uploadToServer",
"Upload to server",
)}
</Menu.Item>
)}
{onDelete && (
<Menu.Item
color="red"
leftSection={<DeleteOutlineIcon sx={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onDelete(fileId);
}}
>
{t("fileSidebar.fileItem.delete", "Delete")}
</Menu.Item>
)}
);
return (
<Tooltip
label={enforcingTooltip(uploadLabel)}
disabled={!policyEnforcing}
position="left"
offset={6}
withArrow
>
<div>
<Menu.Item
disabled={policyEnforcing}
leftSection={
<CloudUploadOutlinedIcon sx={{ fontSize: 16 }} />
}
onClick={(e) => {
e.stopPropagation();
onSaveToCloud(fileId);
}}
>
{uploadLabel}
</Menu.Item>
</div>
</Tooltip>
);
})()}
{onDelete &&
(() => {
const deleteLabel = t(
"fileSidebar.fileItem.delete",
"Delete",
);
return (
<Tooltip
label={enforcingTooltip(deleteLabel)}
disabled={!policyEnforcing}
position="left"
offset={6}
withArrow
>
<div>
<Menu.Item
disabled={policyEnforcing}
color="red"
leftSection={
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
}
onClick={(e) => {
e.stopPropagation();
onDelete(fileId);
}}
>
{deleteLabel}
</Menu.Item>
</div>
</Tooltip>
);
})()}
</Menu.Dropdown>
</Menu>
)}
@@ -10,6 +10,8 @@ export interface HoverAction {
label: string;
onClick: (e: React.MouseEvent) => void;
disabled?: boolean;
/** Overrides label in the tooltip — use for rich ReactNode content (e.g. enforcement messages). */
tooltip?: React.ReactNode;
color?: string;
hidden?: boolean;
dataTour?: string;
@@ -58,18 +60,22 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
onClick={(e) => e.stopPropagation()}
>
{visibleActions.map((action) => (
<Tooltip key={action.id} label={action.label}>
<ActionIcon
size="md"
variant="tertiary"
disabled={action.disabled}
onClick={action.onClick}
aria-label={action.label}
style={{ color: action.color || "var(--text-secondary)" }}
data-tour={action.dataTour}
>
{action.icon}
</ActionIcon>
<Tooltip key={action.id} label={action.tooltip ?? action.label}>
{/* Wrapper keeps the tooltip working when the button is disabled
(disabled buttons don't emit the pointer events Tooltip needs). */}
<div style={{ display: "inline-flex", alignItems: "center" }}>
<ActionIcon
size="md"
variant="tertiary"
disabled={action.disabled}
onClick={action.onClick}
aria-label={action.label}
style={{ color: action.color || "var(--text-secondary)" }}
data-tour={action.dataTour}
>
{action.icon}
</ActionIcon>
</div>
</Tooltip>
))}
</div>
@@ -0,0 +1,62 @@
/* Canonical policy badge styling — every per-file policy badge in the app
* (sidebar, thumbnails, files page, viewer indicator) uses these classes so
* colour and shape stay consistent. `color` is set inline to the policy's
* accent; the background tint follows it. */
.policy-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.policy-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 4px;
color: var(--text-secondary);
background: color-mix(in srgb, currentColor 16%, transparent);
pointer-events: auto;
}
/* Larger variant for standalone indicators (e.g. the minimised viewer overlay). */
.policy-badge--lg {
width: 28px;
height: 28px;
border-radius: 8px;
box-shadow: var(--shadow-md);
}
.policy-badge--enforcing svg {
animation: policy-badge-spin 1s linear infinite;
}
@keyframes policy-badge-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.policy-badge--recent {
animation: policy-badge-pulse 4.5s ease-in-out forwards;
}
@keyframes policy-badge-pulse {
0%,
24%,
48% {
box-shadow: 0 0 0 0 transparent;
}
12%,
36% {
box-shadow: 0 0 5px 2px currentColor;
}
60%,
100% {
box-shadow: 0 0 0 0 transparent;
}
}
@@ -0,0 +1,72 @@
import { Tooltip } from "@mantine/core";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import { useTranslation } from "react-i18next";
import "@app/components/shared/PolicyBadges.css";
/** A policy that has run on this file, used for the activity badges. */
export interface FileItemPolicyRef {
id: string;
name: string;
/** CSS colour for the badge (matches the policy's accent). */
accentColor: string;
/** True only just after the policy was applied — drives the one-off glow, so
* it doesn't replay on every reload of an already-enforced file. */
recent: boolean;
/** True while the policy run is actively in-flight on this file. */
enforcing?: boolean;
}
const MAX_VISIBLE = 3;
/**
* The canonical policy badge row: one accent-tinted shield per policy that has
* run on a file, spinning while a run is in flight, glowing briefly after it
* lands. Every surface that shows per-file policy badges (file sidebar, file
* editor thumbnails, files page) renders this so they stay identical.
*/
export function PolicyBadges({
policies,
className,
}: {
policies: FileItemPolicyRef[];
/** Appended to the row for surface-specific layout (spacing only). */
className?: string;
}) {
const { t } = useTranslation();
if (policies.length === 0) return null;
return (
<span
className={`policy-badges${className ? ` ${className}` : ""}`}
data-no-select
>
{policies.slice(0, MAX_VISIBLE).map((policy) => (
<Tooltip
key={policy.id}
label={
policy.enforcing
? t("policy.badgeEnforcing", "{{name}} enforcing…", {
name: policy.name,
})
: t("policy.badgeRan", "{{name}} policy ran on this file", {
name: policy.name,
})
}
withArrow
position="top"
>
<span
className={`policy-badge${policy.enforcing ? " policy-badge--enforcing" : ""}${policy.recent && !policy.enforcing ? " policy-badge--recent" : ""}`}
style={{ color: policy.accentColor }}
>
{policy.enforcing ? (
<AutorenewIcon sx={{ fontSize: "0.7rem" }} />
) : (
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
)}
</span>
</Tooltip>
))}
</span>
);
}
@@ -0,0 +1,7 @@
export function PolicyEnforcingOverlay(_props: {
enforcing: boolean;
progress?: number;
zIndex?: number;
}) {
return null;
}
@@ -5,6 +5,7 @@ import React, {
useRef,
useSyncExternalStore,
} from "react";
import { Group, Loader, Progress, Stack, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { SegmentedControl } from "@app/ui/SegmentedControl";
@@ -33,6 +34,11 @@ import { Tooltip } from "@app/components/shared/Tooltip";
import LocalIcon from "@app/components/shared/LocalIcon";
import ViewerShareButton from "@app/components/viewer/ViewerShareButton";
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import {
POLICY_IN_FLIGHT_STATUSES,
usePolicyRuns,
} from "@app/components/policies/policyRunStore";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
import { enforceExportPolicies } from "@app/services/policyExport";
import { downloadFile as downloadRaw } from "@app/services/downloadService";
@@ -46,6 +52,7 @@ import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutl
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import CloseIcon from "@mui/icons-material/Close";
import PrintIcon from "@mui/icons-material/Print";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import "@app/components/shared/WorkbenchBar.css";
const SECTION_ORDER: WorkbenchBarSection[] = ["top", "middle", "bottom"];
@@ -119,6 +126,60 @@ export default function WorkbenchBar({
const { actions: fileActions } = useFileActions();
const activeFiles = selectors.getFiles();
const { activeFileId, setActiveFileId } = useViewer();
const policyFileBadges = usePolicyFileBadges();
// Block print/export while any file the export would touch is under active
// policy enforcement: the viewer exports its active file, every other view
// exports the selection (or all files when nothing is selected).
const exportTargetIds: string[] =
currentView === "viewer"
? activeFileId
? [activeFileId]
: []
: selectedFileIds.length > 0
? selectedFileIds
: activeFiles.filter(isStirlingFile).map((f) => f.fileId);
const enforcingFileId = exportTargetIds.find((id) =>
(policyFileBadges.get(id) ?? []).some((p) => p.enforcing),
);
const policyEnforcing = enforcingFileId != null;
const policyRuns = usePolicyRuns();
const enforcingRun = policyEnforcing
? policyRuns.find(
(r) =>
r.fileId === enforcingFileId &&
(POLICY_IN_FLIGHT_STATUSES as readonly string[]).includes(r.status),
)
: undefined;
const enforcingProgress =
enforcingRun?.currentStep != null && enforcingRun.stepCount
? Math.round((enforcingRun.currentStep / enforcingRun.stepCount) * 100)
: undefined;
const makeEnforcingTooltip = (action: string): React.ReactNode => (
<Stack gap={6} py={2} w={200}>
<Group gap={6} wrap="nowrap">
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
<Text size="xs" fw={600}>
{t(
"policy.blockingAction",
"{{action}} blocked while enforcing policy, please wait",
{ action },
)}
</Text>
</Group>
{enforcingProgress != null ? (
<Progress
w="100%"
size="xs"
radius="xl"
value={enforcingProgress}
striped
animated
/>
) : (
<Loader size="xs" />
)}
</Stack>
);
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
const pageEditorSelectedCount =
pageEditorFunctions?.selectedPageIds?.length ?? 0;
@@ -504,13 +565,18 @@ export default function WorkbenchBar({
className="workbench-bar-action-icon"
onClick={handlePrint}
disabled={
totalItems === 0 || allButtonsDisabled || disableForFullscreen
totalItems === 0 ||
allButtonsDisabled ||
disableForFullscreen ||
policyEnforcing
}
aria-label={t("workbenchBar.print", "Print PDF")}
>
<PrintIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>,
t("workbenchBar.print", "Print PDF"),
policyEnforcing
? makeEnforcingTooltip(t("workbenchBar.print", "Print PDF"))
: t("workbenchBar.print", "Print PDF"),
)}
{/* Download (file-level action — not relevant in custom views) */}
@@ -522,7 +588,10 @@ export default function WorkbenchBar({
className="workbench-bar-action-icon"
onClick={() => handleExportAll()}
disabled={
disableForFullscreen || totalItems === 0 || allButtonsDisabled
disableForFullscreen ||
totalItems === 0 ||
allButtonsDisabled ||
policyEnforcing
}
aria-label={downloadTooltip}
>
@@ -532,7 +601,9 @@ export default function WorkbenchBar({
height="1rem"
/>
</ActionIcon>,
downloadTooltip,
policyEnforcing
? makeEnforcingTooltip(downloadTooltip)
: downloadTooltip,
)}
{/* Save As */}
@@ -545,7 +616,10 @@ export default function WorkbenchBar({
className="workbench-bar-action-icon"
onClick={() => handleExportAll(true)}
disabled={
disableForFullscreen || totalItems === 0 || allButtonsDisabled
disableForFullscreen ||
totalItems === 0 ||
allButtonsDisabled ||
policyEnforcing
}
aria-label={t("workbenchBar.saveAs", "Save As")}
>
@@ -555,7 +629,9 @@ export default function WorkbenchBar({
height="1rem"
/>
</ActionIcon>,
t("workbenchBar.saveAs", "Save As"),
policyEnforcing
? makeEnforcingTooltip(t("workbenchBar.saveAs", "Save As"))
: t("workbenchBar.saveAs", "Save As"),
)}
{/* Separator: export group | close */}
@@ -47,6 +47,7 @@ import { useWheelZoom } from "@app/hooks/useWheelZoom";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import { FormSaveBar } from "@app/tools/formFill/FormSaveBar";
import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import { alert } from "@app/components/toast";
// ─── Measure dictionary extraction ────────────────────────────────────────────
@@ -443,6 +444,15 @@ const EmbedPdfViewerContent = ({
const viewerKeyCommand = useViewerKeyCommand();
const policyFileBadges = usePolicyFileBadges();
const policyEnforcing =
!!activeFileId &&
(policyFileBadges.get(activeFileId) ?? []).some((p) => p.enforcing);
// Use a ref so the keydown handler always reads the latest value without
// needing to be in the effect's dependency array.
const policyEnforcingRef = useRef(false);
policyEnforcingRef.current = policyEnforcing;
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -468,7 +478,9 @@ const EmbedPdfViewerContent = ({
case "p":
case "P":
event.preventDefault();
printActions.print();
if (!policyEnforcingRef.current) {
printActions.print();
}
return;
case "a":
case "A":
@@ -1333,6 +1345,7 @@ const EmbedPdfViewerContent = ({
file={currentFile ?? null}
isFormFillToolActive={isFormFillToolActive}
onApply={handleFormApply}
policyEnforcing={policyEnforcing}
/>
<StampPlacementOverlay
containerRef={pdfContainerRef}
@@ -1,9 +1,10 @@
import { useState } from "react";
import { Group, Modal, Stack, Text } from "@mantine/core";
import { Group, Loader, Modal, Progress, Stack, Text } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import ShareIcon from "@mui/icons-material/Share";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import { Tooltip } from "@app/components/shared/Tooltip";
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
@@ -15,6 +16,11 @@ import { alert } from "@app/components/toast";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import {
POLICY_IN_FLIGHT_STATUSES,
usePolicyRuns,
} from "@app/components/policies/policyRunStore";
interface ViewerShareButtonProps {
disabled?: boolean;
@@ -48,8 +54,54 @@ export default function ViewerShareButton({
? stubs.find((s) => s.id === activeFileId)
: undefined;
const policyFileBadges = usePolicyFileBadges();
const runs = usePolicyRuns();
const enforcing =
!!activeFileId &&
(policyFileBadges.get(activeFileId) ?? []).some((p) => p.enforcing);
const enforcingRun = enforcing
? runs.find(
(r) =>
r.fileId === activeFileId &&
(POLICY_IN_FLIGHT_STATUSES as readonly string[]).includes(r.status),
)
: undefined;
const enforcingProgress =
enforcingRun?.currentStep != null && enforcingRun.stepCount
? Math.round((enforcingRun.currentStep / enforcingRun.stepCount) * 100)
: undefined;
const label = t("workbenchBar.share", "Share");
const isDisabled = Boolean(disabled) || !stub;
const isDisabled = Boolean(disabled) || !stub || enforcing;
const tooltipContent = enforcing ? (
<Stack gap={6} py={2} w={200}>
<Group gap={6} wrap="nowrap">
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
<Text size="xs" fw={600}>
{t(
"policy.blockingAction",
"{{action}} blocked while enforcing policy, please wait",
{ action: label },
)}
</Text>
</Group>
{enforcingProgress != null ? (
<Progress
w="100%"
size="xs"
radius="xl"
value={enforcingProgress}
striped
animated
/>
) : (
<Loader size="xs" />
)}
</Stack>
) : (
label
);
const openShare = (target: StirlingFileStub) => {
setShareStub(target);
@@ -125,7 +177,7 @@ export default function ViewerShareButton({
return (
<>
<Tooltip
content={label}
content={tooltipContent}
position="bottom"
offset={6}
arrow
@@ -244,6 +244,7 @@ function FileContextInner({
/** Persist to IDB without dispatching to workspace state. */
skipWorkspaceDispatch?: boolean;
skipUploadTracking?: boolean;
derivedFromTool?: boolean;
},
): Promise<StirlingFile[]> => {
const stirlingFiles = await addFiles(
@@ -254,6 +254,9 @@ interface AddFileOptions {
) => Promise<boolean>; // Optional callback to confirm extraction of large ZIP files
allowDuplicates?: boolean;
skipUploadTracking?: boolean;
/** When true, marks every added stub as derivedFromTool so the policy
* auto-run skips it — used for policy outputs imported via addFiles. */
derivedFromTool?: boolean;
}
/**
@@ -368,6 +371,7 @@ export async function addFiles(
// Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously
const fileStub = createNewStirlingFileStub(file, fileId);
if (options.derivedFromTool) fileStub.derivedFromTool = true;
// Early encryption detection for PDFs — set the flag before dispatch so the
// viewer gate and modal queue pick it up immediately instead of after hydration
@@ -1,5 +1,12 @@
import { buildChangeMetadataFormData } from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
import { ChangeMetadataParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
import {
buildChangeMetadataFormData,
changeMetadataToApiParams,
changeMetadataFromApiParams,
} from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
import {
ChangeMetadataParameters,
defaultParameters,
} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
import { TrappedStatus } from "@app/types/metadata";
import { describe, expect, test } from "vitest";
@@ -142,3 +149,78 @@ describe("buildChangeMetadataFormData", () => {
expect(formData.get("allRequestParams[customValue1]")).toBe("Engineering");
});
});
describe("changeMetadata mappers", () => {
const fullApi: Parameters<typeof changeMetadataFromApiParams>[0] = {
title: "Title",
author: "Author",
subject: "Subject",
keywords: "a, b",
creator: "Creator",
producer: "Producer",
creationDate: "2024/01/15 10:30:00",
modificationDate: "2024/02/20 14:05:09",
trapped: "True",
deleteAll: false,
allRequestParams: {
customKey1: "Department",
customValue1: "Engineering",
customKey2: "Project",
customValue2: "Falcon",
},
};
test("round-trips a full request through fromApiParams and back", () => {
const roundTripped = changeMetadataToApiParams({
...defaultParameters,
...changeMetadataFromApiParams(fullApi),
});
expect(roundTripped).toEqual(fullApi);
});
test("reconstructs dates in local time and clears absent ones", () => {
const params = changeMetadataFromApiParams(fullApi);
expect(params.creationDate).toEqual(new Date(2024, 0, 15, 10, 30, 0));
expect(params.modificationDate).toEqual(new Date(2024, 1, 20, 14, 5, 9));
const cleared = changeMetadataFromApiParams({
creationDate: "",
modificationDate: undefined,
});
expect(cleared.creationDate).toBeNull();
expect(cleared.modificationDate).toBeNull();
});
test.each([TrappedStatus.TRUE, TrappedStatus.FALSE, TrappedStatus.UNKNOWN])(
"round-trips trapped=%s",
(trapped) => {
const api = changeMetadataToApiParams({ ...defaultParameters, trapped });
expect(api.trapped).toBe(trapped);
expect(changeMetadataFromApiParams(api).trapped).toBe(trapped);
},
);
test("falls back to the default for an unrecognised trapped value", () => {
const params = changeMetadataFromApiParams({
trapped: "Bogus",
} as unknown as Parameters<typeof changeMetadataFromApiParams>[0]);
expect(params.trapped).toBe(defaultParameters.trapped);
});
test("reconstructs custom metadata, tolerating non-contiguous indices", () => {
const params = changeMetadataFromApiParams({
allRequestParams: {
customKey1: "Department",
customValue1: "Engineering",
customKey3: "Project",
customValue3: "Falcon",
},
});
expect(params.customMetadata).toEqual([
{ key: "Department", value: "Engineering", id: "custom1" },
{ key: "Project", value: "Falcon", id: "custom3" },
]);
});
});
@@ -3,13 +3,22 @@ import {
useToolOperation,
defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { TrappedStatus, CustomMetadataEntry } from "@app/types/metadata";
import {
ChangeMetadataParameters,
defaultParameters,
} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
// Helper function to format Date object to string
const ENDPOINT = "/api/v1/misc/update-metadata" satisfies ToolEndpoint;
type ChangeMetadataApiParams = ToolApiParams[typeof ENDPOINT];
// Backend date format (yyyy/MM/dd HH:mm:ss), in local time to mirror the parser.
const formatDateForBackend = (date: Date | null): string => {
if (!date) return "";
const year = date.getFullYear();
@@ -21,64 +30,135 @@ const formatDateForBackend = (date: Date | null): string => {
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
};
// Inverse of formatDateForBackend; returns null for empty or unparseable input.
const parseDateFromBackend = (value: string | undefined): Date | null => {
if (!value) return null;
const match = /^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(
value,
);
if (!match) return null;
const [, year, month, day, hours, minutes, seconds] = match;
return new Date(
Number(year),
Number(month) - 1,
Number(day),
Number(hours),
Number(minutes),
Number(seconds),
);
};
// Custom metadata is carried in the request's allRequestParams map as paired
// customKey<N>/customValue<N> entries; the backend rejoins them by the shared
// index N (see MetadataController). Only entries with a non-blank key and value
// are sent, and they are re-numbered from 1 so the indices stay contiguous.
const buildCustomMetadataMap = (
customMetadata: CustomMetadataEntry[],
): Record<string, string> | undefined => {
const validEntries = customMetadata.filter(
(entry) => entry.key.trim() && entry.value.trim(),
);
if (validEntries.length === 0) return undefined;
const map: Record<string, string> = {};
validEntries.forEach((entry, index) => {
const n = index + 1;
map[`customKey${n}`] = entry.key.trim();
map[`customValue${n}`] = entry.value.trim();
});
return map;
};
// Rebuild the UI's custom metadata list from the allRequestParams map by pairing
// customKey<N>/customValue<N> on their shared index N. Mirrors the backend
// (MetadataController), which pairs by index across all entries and does not
// assume the indices are contiguous, so a non-contiguous stored map round-trips.
const parseCustomMetadataMap = (
allRequestParams: ChangeMetadataApiParams["allRequestParams"],
): CustomMetadataEntry[] => {
if (!allRequestParams) return [];
return Object.keys(allRequestParams)
.map((key) => /^customKey(\d+)$/.exec(key)?.[1])
.filter((n): n is string => n !== undefined)
.map(Number)
.sort((a, b) => a - b)
.map((n) => ({
key: allRequestParams[`customKey${n}`] ?? "",
value: allRequestParams[`customValue${n}`] ?? "",
id: `custom${n}`,
}));
};
// Map the backend's trapped string onto the UI enum, validating against the
// actual enum values so an unrecognised value falls back to the default instead
// of being force-cast.
const parseTrapped = (value: string | undefined): TrappedStatus =>
Object.values(TrappedStatus).find((status) => status === value) ??
defaultParameters.trapped;
// Convert the tool's UI parameters into the update-metadata request body. The
// return type is the generated backend model, so a spec change that renames or
// drops a field breaks the build here.
export const changeMetadataToApiParams = (
parameters: ChangeMetadataParameters,
): ChangeMetadataApiParams => ({
title: parameters.title,
author: parameters.author,
subject: parameters.subject,
keywords: parameters.keywords,
creator: parameters.creator,
producer: parameters.producer,
creationDate: formatDateForBackend(parameters.creationDate),
modificationDate: formatDateForBackend(parameters.modificationDate),
trapped: parameters.trapped,
deleteAll: parameters.deleteAll,
allRequestParams: buildCustomMetadataMap(parameters.customMetadata),
});
// Reconstruct the tool's UI parameters from an update-metadata request body, so
// a stored or AI-authored step can be re-rendered in the settings UI.
export const changeMetadataFromApiParams = (
apiParams: ChangeMetadataApiParams,
): Partial<ChangeMetadataParameters> => ({
title: apiParams.title ?? defaultParameters.title,
author: apiParams.author ?? defaultParameters.author,
subject: apiParams.subject ?? defaultParameters.subject,
keywords: apiParams.keywords ?? defaultParameters.keywords,
creator: apiParams.creator ?? defaultParameters.creator,
producer: apiParams.producer ?? defaultParameters.producer,
creationDate: parseDateFromBackend(apiParams.creationDate),
modificationDate: parseDateFromBackend(apiParams.modificationDate),
trapped: parseTrapped(apiParams.trapped),
deleteAll: apiParams.deleteAll ?? defaultParameters.deleteAll,
customMetadata: parseCustomMetadataMap(apiParams.allRequestParams),
});
// Static function that can be used by both the hook and automation executor
export const buildChangeMetadataFormData = (
parameters: ChangeMetadataParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Standard metadata fields
formData.append("title", parameters.title || "");
formData.append("author", parameters.author || "");
formData.append("subject", parameters.subject || "");
formData.append("keywords", parameters.keywords || "");
formData.append("creator", parameters.creator || "");
formData.append("producer", parameters.producer || "");
// Date fields - convert Date objects to strings
formData.append(
"creationDate",
formatDateForBackend(parameters.creationDate),
);
formData.append(
"modificationDate",
formatDateForBackend(parameters.modificationDate),
);
// Trapped status
formData.append("trapped", parameters.trapped || "");
// Delete all metadata flag
formData.append("deleteAll", parameters.deleteAll.toString());
// Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]'
let keyNumber = 0;
if (parameters.customMetadata && Array.isArray(parameters.customMetadata)) {
parameters.customMetadata.forEach((entry) => {
if (entry.key.trim() && entry.value.trim()) {
keyNumber += 1;
formData.append(
`allRequestParams[customKey${keyNumber}]`,
entry.key.trim(),
);
formData.append(
`allRequestParams[customValue${keyNumber}]`,
entry.value.trim(),
);
}
});
// allRequestParams is a Spring-bound map: objectToFormData only serializes
// primitives, so the scalar fields go through it and the map is flattened into
// allRequestParams[<key>] form fields separately.
const { allRequestParams, ...scalarParams } =
changeMetadataToApiParams(parameters);
const formData = objectToFormData(scalarParams, { fileInput: file });
for (const [key, value] of Object.entries(allRequestParams ?? {})) {
if (value !== undefined) {
formData.append(`allRequestParams[${key}]`, value);
}
}
return formData;
};
// Static configuration object
export const changeMetadataOperationConfig = defineSingleFileTool({
buildFormData: buildChangeMetadataFormData,
toApiParams: changeMetadataToApiParams,
fromApiParams: changeMetadataFromApiParams,
operationType: "changeMetadata",
endpoint: "/api/v1/misc/update-metadata",
endpoint: ENDPOINT,
defaultParameters,
});
@@ -71,6 +71,8 @@ export const redactOperationConfig = defineSingleFileTool({
operationType: "redact",
endpoint: (parameters: RedactParameters) =>
parameters.mode === "automatic" ? AUTO_ENDPOINT : null,
// Routing set: `mode` is frontend-only, so a stored step matches by this rather than by replay.
endpoints: [AUTO_ENDPOINT],
defaultParameters,
});
@@ -1,5 +1,8 @@
import { describe, expect, test } from "vitest";
import { type RegistryToolOperationConfig } from "@app/hooks/tools/shared/toolOperationTypes";
import {
ToolType,
type RegistryToolOperationConfig,
} from "@app/hooks/tools/shared/toolOperationTypes";
import { objectToFormData } from "@app/hooks/tools/shared/toolApiMapping";
// Pilot tools.
@@ -17,6 +20,7 @@ import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale
import { autoRenameOperationConfig } from "@app/hooks/tools/autoRename/useAutoRenameOperation";
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
import { changeMetadataOperationConfig } from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
import { cropOperationConfig } from "@app/hooks/tools/crop/useCropOperation";
import { editTableOfContentsOperationConfig } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation";
@@ -57,6 +61,7 @@ const MIGRATED_CONFIGS = [
autoRenameOperationConfig,
bookletImpositionOperationConfig,
certSignOperationConfig,
changeMetadataOperationConfig,
changePermissionsOperationConfig,
cropOperationConfig,
editTableOfContentsOperationConfig,
@@ -102,15 +107,26 @@ describe("migrated tool mappers (sweep)", () => {
expect(config.toApiParams).toBeDefined();
expect(config.fromApiParams).toBeDefined();
// toApiParams(defaults) must produce a body objectToFormData can serialize
// (i.e. only primitives / arrays of primitives). A mapper that leaked a
// structured value would throw here.
// Serialize the defaults through the tool's own buildFormData - the real
// path the executor uses - so a tool whose toApiParams carries a structured
// field that buildFormData flattens itself (e.g. changeMetadata's
// allRequestParams map) is exercised too, not just tools whose mapper
// output is directly objectToFormData-able. Custom tools have no
// buildFormData, so fall back to serializing the mapper output directly.
const params =
config.defaultParameters ?? FALLBACK_PARAMS[config.operationType] ?? {};
const apiParams = config.toApiParams!(params);
expect(() =>
objectToFormData(apiParams, { fileInput: file }),
).not.toThrow();
if (config.toolType === ToolType.multiFile) {
const build = config.buildFormData;
expect(() => build(params, [file])).not.toThrow();
} else if (config.toolType === ToolType.singleFile) {
const build = config.buildFormData;
expect(() => build(params, file)).not.toThrow();
} else {
const toApiParams = config.toApiParams!;
expect(() =>
objectToFormData(toApiParams(params), { fileInput: file }),
).not.toThrow();
}
},
);
});
@@ -1,9 +1,18 @@
import { describe, expect, test } from "vitest";
import {
isToolEndpoint,
objectToFormData,
type ToolApiParams,
} from "@app/hooks/tools/shared/toolApiMapping";
describe("isToolEndpoint", () => {
test("accepts a generated endpoint and rejects an unknown path", () => {
expect(isToolEndpoint("/api/v1/misc/compress-pdf")).toBe(true);
expect(isToolEndpoint("/api/v1/misc/not-a-real-tool")).toBe(false);
expect(isToolEndpoint("")).toBe(false);
});
});
describe("objectToFormData", () => {
test("serializes primitive fields to string form values", () => {
const request: ToolApiParams["/api/v1/misc/compress-pdf"] = {
@@ -1,4 +1,5 @@
import {
TOOL_ENDPOINTS,
type ToolApiParams,
type ToolApiRequest,
type ToolEndpoint,
@@ -6,6 +7,16 @@ import {
export type { ToolApiParams, ToolApiRequest, ToolEndpoint };
const TOOL_ENDPOINT_SET: ReadonlySet<string> = new Set(TOOL_ENDPOINTS);
/**
* Runtime check that a string is one of the generated tool endpoints, so callers can narrow an
* arbitrary endpoint path to {@link ToolEndpoint} against the real supported set rather than casting.
*/
export function isToolEndpoint(value: string): value is ToolEndpoint {
return TOOL_ENDPOINT_SET.has(value);
}
/**
* Mapping for tools that take only a file and have no request parameters (their
* generated model is `Record<string, never>`). Both directions are empty; the
@@ -0,0 +1,215 @@
import { describe, expect, test } from "vitest";
import {
ToolCategoryId,
SubcategoryId,
type ToolRegistry,
type ToolRegistryEntry,
} from "@app/data/toolsTaxonomy";
import { type ToolId } from "@app/types/toolId";
import {
asRegistryConfig,
ToolType,
} from "@app/hooks/tools/shared/toolOperationTypes";
import {
deserializeToolStep,
getExecutableTools,
serializeToolStep,
stepRequiresUpload,
type WorkingToolStep,
} from "@app/hooks/tools/shared/toolAutomation";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import { defaultParameters as compressDefaults } from "@app/hooks/tools/compress/useCompressParameters";
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
import { SPLIT_METHODS } from "@app/constants/splitConstants";
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
function entry(over: Partial<ToolRegistryEntry>): ToolRegistryEntry {
return {
icon: null,
name: "",
component: null,
description: "",
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
automationSettings: null,
...over,
};
}
const NoopSettings = () => null;
// A migrated, param-less config (mappers present, no settings UI) -> "noSettings".
const repairConfig = asRegistryConfig({
toolType: ToolType.singleFile,
operationType: "repair",
endpoint: "/api/v1/misc/repair",
defaultParameters: {},
buildFormData: () => new FormData(),
toApiParams: () => ({}),
fromApiParams: () => ({}),
});
// A config with no mappers (not migrated) -> "unsupported".
const changeMetadataConfig = asRegistryConfig({
toolType: ToolType.singleFile,
operationType: "changeMetadata",
endpoint: "/api/v1/misc/update-metadata",
defaultParameters: {},
buildFormData: () => new FormData(),
});
const registry: Partial<ToolRegistry> = {
compress: entry({
name: "Compress",
automationSettings: NoopSettings,
operationConfig: asRegistryConfig(compressOperationConfig),
}),
repair: entry({ name: "Repair", operationConfig: repairConfig }),
changeMetadata: entry({
name: "Change metadata",
automationSettings: NoopSettings,
operationConfig: changeMetadataConfig,
}),
// Excluded: automation explicitly off.
sign: entry({
name: "Sign",
supportsAutomate: false,
operationConfig: repairConfig,
}),
// Excluded: no operationConfig at all.
extractPages: entry({ name: "Extract pages" }),
};
// Separate registry so these don't change the getExecutableTools expectations above.
const dynamicRegistry: Partial<ToolRegistry> = {
split: entry({
name: "Split",
automationSettings: NoopSettings,
operationConfig: asRegistryConfig(splitOperationConfig),
}),
redact: entry({
name: "Redact",
automationSettings: NoopSettings,
operationConfig: asRegistryConfig(redactOperationConfig),
}),
};
describe("getExecutableTools", () => {
test("lists automatable tools with a resolvable endpoint, classified by support", () => {
const tools = getExecutableTools(registry);
expect(tools.map((t) => t.toolId)).toEqual([
"changeMetadata",
"compress",
"repair",
]);
expect(Object.fromEntries(tools.map((t) => [t.toolId, t.support]))).toEqual(
{
compress: "editable",
repair: "noSettings",
changeMetadata: "unsupported",
},
);
});
});
describe("serialize/deserialize round-trip", () => {
test("compress maps UI params to the backend body and back", () => {
const step: WorkingToolStep = {
toolId: "compress" as ToolId,
operation: "/api/v1/misc/compress-pdf",
params: {
...compressDefaults,
compressionLevel: 7,
compressionMethod: "filesize",
fileSizeValue: "2",
fileSizeUnit: "MB",
},
support: "editable",
};
const api = serializeToolStep(step, registry);
expect(api.operation).toBe("/api/v1/misc/compress-pdf");
expect(api.parameters).toMatchObject({
optimizeLevel: 7,
expectedOutputSize: "2MB",
});
const back = deserializeToolStep(api, registry);
expect(back.toolId).toBe("compress");
expect(back.params).toMatchObject({
compressionLevel: 7,
compressionMethod: "filesize",
fileSizeValue: "2",
fileSizeUnit: "MB",
});
});
test("an unknown endpoint is preserved as an unmapped step", () => {
const step = deserializeToolStep(
{ operation: "/api/v1/unknown/thing", parameters: { keep: true } },
registry,
);
expect(step.toolId).toBeNull();
expect(step.support).toBe("unknown");
expect(serializeToolStep(step, registry)).toEqual({
operation: "/api/v1/unknown/thing",
parameters: { keep: true },
});
});
test("a dynamic-endpoint tool (split by chapters) round-trips as an editable step", () => {
const step: WorkingToolStep = {
toolId: "split" as ToolId,
operation: "/api/v1/general/split-pdf-by-chapters",
params: { method: SPLIT_METHODS.BY_CHAPTERS, bookmarkLevel: "2" },
support: "editable",
};
const api = serializeToolStep(step, dynamicRegistry);
expect(api.operation).toBe("/api/v1/general/split-pdf-by-chapters");
expect(api.parameters).toMatchObject({ bookmarkLevel: 2 });
// No `method` in the stored body, so this only matches via the declared endpoint set.
const back = deserializeToolStep(api, dynamicRegistry);
expect(back.toolId).toBe("split");
expect(back.support).toBe("editable");
expect(back.operation).toBe("/api/v1/general/split-pdf-by-chapters");
expect(back.params).toMatchObject({ method: SPLIT_METHODS.BY_CHAPTERS });
});
test("a dynamic-endpoint tool whose routing field is dropped (redact) stays editable", () => {
const step: WorkingToolStep = {
toolId: "redact" as ToolId,
operation: "/api/v1/security/auto-redact",
params: { mode: "automatic", wordsToRedact: ["secret"] },
support: "editable",
};
const api = serializeToolStep(step, dynamicRegistry);
expect(api.operation).toBe("/api/v1/security/auto-redact");
expect(api.parameters).not.toHaveProperty("mode");
// No `mode` in the body, so it only matches via the declared set (replay would yield null).
const back = deserializeToolStep(api, dynamicRegistry);
expect(back.toolId).toBe("redact");
expect(back.support).toBe("editable");
expect(back.operation).toBe("/api/v1/security/auto-redact");
expect(back.params).toMatchObject({ mode: "automatic" });
});
});
describe("stepRequiresUpload", () => {
const step = (params: Record<string, unknown>): WorkingToolStep => ({
toolId: "compress" as ToolId,
operation: "/api/v1/misc/compress-pdf",
params,
support: "editable",
});
test("detects a File (or list of Files) among the parameters", () => {
const image = new File(["x"], "logo.png", { type: "image/png" });
expect(stepRequiresUpload(step({ level: 5 }))).toBe(false);
expect(stepRequiresUpload(step({ watermarkImage: image }))).toBe(true);
expect(stepRequiresUpload(step({ attachments: [image] }))).toBe(true);
});
});
@@ -0,0 +1,260 @@
/**
* Registry-level automation logic: which tools can be run as a backend operation step, how much
* of their parameters can be edited in a UI, and conversion between a tool's frontend parameter
* shape and the backend step contract (endpoint + backend parameters).
*
* This is core behaviour shared by every surface that composes or replays tool operations against
* the backend engine (portal pipelines today; backend-executed automations and AI plans later),
* so the "is this tool usable, and how" decision lives with the tools rather than in any one
* feature. It builds on each tool's `operationConfig` mappers (`toApiParams` / `fromApiParams`),
* keeping the frontend<->backend parameter mapping single-sourced in the tools.
*/
import { type ReactNode } from "react";
import {
getToolSupportsAutomate,
type SubcategoryId,
type ToolRegistry,
type ToolRegistryEntry,
} from "@app/data/toolsTaxonomy";
import { type ToolId } from "@app/types/toolId";
import {
isToolEndpoint,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import {
type ErasedToolParams,
type RegistryToolOperationConfig,
} from "@app/hooks/tools/shared/toolOperationTypes";
/**
* How much of a tool's parameters a UI can edit when composing a backend step:
* - `editable`: has both mappers and a settings UI -> render the settings UI.
* - `noSettings`: migrated to the mapper seam but has no parameters to configure.
* - `unsupported`: not migrated to the mapper seam -> parameters can't be mapped for editing yet;
* the step runs with the backend's defaults.
*/
export type ToolStepSupport = "editable" | "noSettings" | "unsupported";
/** A tool that can be added to a backend operation chain, with its editing support. */
export interface ExecutableTool {
toolId: ToolId;
name: string;
icon: ReactNode;
/** Subcategory the tool belongs to, so a picker can group tools without re-reading the registry. */
subcategoryId: SubcategoryId;
/** Endpoint resolved from default parameters, for display/inclusion. The stored step's endpoint is re-resolved from the configured parameters at serialization time. */
endpoint: ToolEndpoint;
support: ToolStepSupport;
}
/**
* The backend step contract: an endpoint path plus its backend-shaped parameters. `operation` is a
* plain string, not a {@link ToolEndpoint}, because this is the raw backend boundary (it mirrors
* the stored policy step) and a pipeline may reference endpoints the frontend does not model.
*/
export interface ToolApiStep {
operation: string;
parameters: Record<string, unknown>;
}
/** A step being edited in a UI that maps to a known tool: parameters are in the tool's frontend shape. */
export interface KnownToolStep {
toolId: ToolId;
operation: ToolEndpoint;
params: ErasedToolParams;
support: ToolStepSupport;
}
/** A stored step whose endpoint maps to no known tool: preserved verbatim, not editable. */
export interface UnknownToolStep {
toolId: null;
operation: string;
params: ErasedToolParams;
support: "unknown";
}
/** A step being edited in a UI, discriminated by whether its endpoint maps to a known tool. */
export type WorkingToolStep = KnownToolStep | UnknownToolStep;
/**
* Resolve a tool's endpoint from parameters. Static endpoints ignore params; dynamic ones may
* return undefined if the params don't determine one. The result is validated against the generated
* endpoint set (via {@link isToolEndpoint}) rather than cast, so a config endpoint that is not a
* known {@link ToolEndpoint} (e.g. a custom tool's arbitrary string) resolves to undefined.
*/
function resolveEndpoint(
config: RegistryToolOperationConfig | undefined,
params: ErasedToolParams,
): ToolEndpoint | undefined {
const endpoint = config?.endpoint;
if (typeof endpoint === "string") {
return isToolEndpoint(endpoint) ? endpoint : undefined;
}
if (typeof endpoint === "function") {
const resolved = safeCall(endpoint, params);
return typeof resolved === "string" && isToolEndpoint(resolved)
? resolved
: undefined;
}
return undefined;
}
/** Invoke a dynamic-endpoint function defensively; a throw means the params don't determine one. */
function safeCall(
fn: (params: ErasedToolParams) => string | null | undefined,
params: ErasedToolParams,
): string | null | undefined {
try {
return fn(params);
} catch {
return undefined;
}
}
/** Classify how much of a tool's parameters a UI can edit when composing a step. */
export function classifyToolStepSupport(
entry: ToolRegistryEntry,
): ToolStepSupport {
const config = entry.operationConfig;
const hasMappers = Boolean(config?.toApiParams && config?.fromApiParams);
if (!hasMappers) return "unsupported";
return entry.automationSettings ? "editable" : "noSettings";
}
function isFileValue(value: unknown): boolean {
if (typeof File === "undefined") return false;
if (value instanceof File) return true;
return Array.isArray(value) && value.some((item) => item instanceof File);
}
/**
* True if any of a step's parameters is an uploaded file (or list of files). Such a step cannot be
* saved into a stored pipeline yet: the file bytes are not persisted with the policy, so a later
* (e.g. scheduled) run would have nothing to send for that named file field.
*/
export function stepRequiresUpload(step: WorkingToolStep): boolean {
return Object.values(step.params).some(isFileValue);
}
/**
* The tools that can be run as a backend operation step, sorted by name. Includes only automatable
* tools whose endpoint resolves from defaults (so they can become a backend step); this drops
* tools with no operationConfig and tools whose endpoint needs runtime input (e.g. convert).
*/
export function getExecutableTools(
registry: Partial<ToolRegistry>,
): ExecutableTool[] {
const tools: ExecutableTool[] = [];
for (const [id, entry] of Object.entries(registry)) {
if (!entry || !getToolSupportsAutomate(entry)) continue;
const config = entry.operationConfig;
if (!config) continue;
const endpoint = resolveEndpoint(config, config.defaultParameters ?? {});
if (!endpoint) continue;
tools.push({
toolId: id as ToolId,
name: entry.name,
icon: entry.icon,
subcategoryId: entry.subcategoryId,
endpoint,
support: classifyToolStepSupport(entry),
});
}
return tools.sort((a, b) => a.name.localeCompare(b.name));
}
/** A fresh working step for a tool just added to a chain, seeded with its default parameters. */
export function newWorkingToolStep(
tool: ExecutableTool,
registry: Partial<ToolRegistry>,
): KnownToolStep {
const config = registry[tool.toolId]?.operationConfig;
return {
toolId: tool.toolId,
operation: tool.endpoint,
params: { ...(config?.defaultParameters ?? {}) },
support: tool.support,
};
}
/** Serialize a working step into the backend step contract (endpoint + backend parameters). */
export function serializeToolStep(
step: WorkingToolStep,
registry: Partial<ToolRegistry>,
): ToolApiStep {
const config =
step.toolId !== null ? registry[step.toolId]?.operationConfig : undefined;
if (!config) {
// Unmapped step (unknown endpoint on edit): round-trip it unchanged.
return { operation: step.operation, parameters: step.params };
}
const merged = { ...(config.defaultParameters ?? {}), ...step.params };
const operation = resolveEndpoint(config, merged) ?? step.operation;
const parameters = config.toApiParams
? (config.toApiParams(merged) as Record<string, unknown>)
: {};
return { operation, parameters };
}
/**
* Find the registry tool for a stored step's endpoint: exact match for static endpoints, else
* membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a
* frontend-only routing field). Replay is only the fallback when no set is declared.
*/
function findToolByEndpoint(
step: ToolApiStep,
registry: Partial<ToolRegistry>,
): [ToolId, ToolRegistryEntry] | undefined {
let dynamic: [ToolId, ToolRegistryEntry] | undefined;
for (const [id, entry] of Object.entries(registry)) {
const endpoint = entry?.operationConfig?.endpoint;
if (typeof endpoint === "string") {
if (endpoint === step.operation) return [id as ToolId, entry];
} else if (typeof endpoint === "function" && !dynamic) {
const declared = entry?.operationConfig?.endpoints;
const matched = declared
? declared.some((e) => e === step.operation)
: safeCall(endpoint, step.parameters) === step.operation;
if (matched) dynamic = [id as ToolId, entry];
}
}
return dynamic;
}
/** A stored step kept verbatim because its endpoint maps to no known tool. */
function unmappedStep(step: ToolApiStep): UnknownToolStep {
return {
toolId: null,
operation: step.operation,
params: { ...step.parameters },
support: "unknown",
};
}
/**
* Rehydrate a stored backend step into a working step for editing: map the endpoint back to a tool
* and its backend parameters back to the frontend shape via `fromApiParams`. Steps whose endpoint
* maps to no known tool are kept as an unmapped, non-editable working step (parameters preserved).
*/
export function deserializeToolStep(
step: ToolApiStep,
registry: Partial<ToolRegistry>,
): WorkingToolStep {
const match = findToolByEndpoint(step, registry);
if (!match) return unmappedStep(step);
const [toolId, entry] = match;
const config = entry.operationConfig;
const params: ErasedToolParams = config?.fromApiParams
? {
...(config.defaultParameters ?? {}),
...config.fromApiParams(step.parameters as never),
}
: { ...(config?.defaultParameters ?? {}) };
// Validate against the generated endpoint set instead of casting the matched string.
const operation =
resolveEndpoint(config, params) ??
(isToolEndpoint(step.operation) ? step.operation : undefined);
if (operation === undefined) return unmappedStep(step);
return { toolId, operation, params, support: classifyToolStepSupport(entry) };
}
@@ -101,9 +101,9 @@ interface BaseToolOperationConfig<TParams, TEndpoint extends ToolEndpoint> {
consumesAllInputs?: boolean;
}
export interface SingleFileToolOperationConfig<
interface SingleFileToolBody<
TParams,
TEndpoint extends ToolEndpoint = ToolEndpoint,
TEndpoint extends ToolEndpoint,
> extends BaseToolOperationConfig<TParams, TEndpoint> {
/** This tool processes one file at a time. */
toolType: ToolType.singleFile;
@@ -111,18 +111,19 @@ export interface SingleFileToolOperationConfig<
/** Builds FormData for API request. */
buildFormData: (params: TParams, file: File) => FormData;
/**
* API endpoint for the operation, or a function for dynamic routing. `null`
* when the operation has no backend endpoint (see {@link ToolOperationEndpoint}).
*/
endpoint: TEndpoint | null | ((params: TParams) => TEndpoint | null);
customProcessor?: undefined;
}
export interface MultiFileToolOperationConfig<
/** Single-file tool config; see {@link EndpointBinding} for the endpoint/endpoints rule. */
export type SingleFileToolOperationConfig<
TParams,
TEndpoint extends ToolEndpoint = ToolEndpoint,
> = SingleFileToolBody<TParams, TEndpoint> &
EndpointBinding<TParams, TEndpoint>;
interface MultiFileToolBody<
TParams,
TEndpoint extends ToolEndpoint,
> extends BaseToolOperationConfig<TParams, TEndpoint> {
/** This tool processes multiple files at once. */
toolType: ToolType.multiFile;
@@ -133,15 +134,15 @@ export interface MultiFileToolOperationConfig<
/** Builds FormData for API request. */
buildFormData: (params: TParams, files: File[]) => FormData;
/**
* API endpoint for the operation, or a function for dynamic routing. `null`
* when the operation has no backend endpoint (see {@link ToolOperationEndpoint}).
*/
endpoint: TEndpoint | null | ((params: TParams) => TEndpoint | null);
customProcessor?: undefined;
}
/** Multi-file counterpart of {@link SingleFileToolOperationConfig}. */
export type MultiFileToolOperationConfig<
TParams,
TEndpoint extends ToolEndpoint = ToolEndpoint,
> = MultiFileToolBody<TParams, TEndpoint> & EndpointBinding<TParams, TEndpoint>;
export interface CustomToolOperationConfig<
TParams,
> extends BaseToolOperationConfig<TParams, ToolEndpoint> {
@@ -157,6 +158,9 @@ export interface CustomToolOperationConfig<
*/
endpoint?: string | ((params: TParams) => string | undefined);
/** `never` so `endpoints` stays readable across the union; custom tools declare no set. */
endpoints?: never;
/**
* Custom processing logic that completely bypasses standard file processing.
* This tool handles all API calls, response processing, and file creation.
@@ -180,6 +184,23 @@ export type ToolOperationConfig<
| MultiFileToolOperationConfig<TParams, TEndpoint>
| CustomToolOperationConfig<TParams>;
/**
* A static `endpoint` declares no set; a dynamic (function) `endpoint` must declare its full
* `endpoints` set, which is how findToolByEndpoint maps a stored step back to its tool when the
* endpoint-selecting parameter is frontend-only.
*/
type EndpointBinding<TParams, TEndpoint extends ToolEndpoint> =
| { endpoint: TEndpoint | null; endpoints?: never }
| {
endpoint: (params: TParams) => TEndpoint | null;
endpoints: readonly TEndpoint[];
};
/** Union-distributing Omit, so stripping a key from a discriminated config keeps its branches. */
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown
? Omit<T, K>
: never;
/**
* Define a single-file tool's operation config. Infers the endpoint literal from
* `endpoint` and binds toApiParams/fromApiParams to that endpoint's request
@@ -189,9 +210,15 @@ export function defineSingleFileTool<
TParams,
const TEndpoint extends ToolEndpoint,
>(
config: Omit<SingleFileToolOperationConfig<TParams, TEndpoint>, "toolType">,
config: DistributiveOmit<
SingleFileToolOperationConfig<TParams, TEndpoint>,
"toolType"
>,
): SingleFileToolOperationConfig<TParams, TEndpoint> {
return { ...config, toolType: ToolType.singleFile };
return {
...config,
toolType: ToolType.singleFile,
} as SingleFileToolOperationConfig<TParams, TEndpoint>;
}
/** Multi-file counterpart of {@link defineSingleFileTool}. */
@@ -199,9 +226,15 @@ export function defineMultiFileTool<
TParams,
const TEndpoint extends ToolEndpoint,
>(
config: Omit<MultiFileToolOperationConfig<TParams, TEndpoint>, "toolType">,
config: DistributiveOmit<
MultiFileToolOperationConfig<TParams, TEndpoint>,
"toolType"
>,
): MultiFileToolOperationConfig<TParams, TEndpoint> {
return { ...config, toolType: ToolType.multiFile };
return {
...config,
toolType: ToolType.multiFile,
} as MultiFileToolOperationConfig<TParams, TEndpoint>;
}
/**

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