Compare commits

...
Author SHA1 Message Date
Connor Yoh 9ea188e9a8 fix(payg): land limit-modal CTAs on the Plan section (fix at the source)
My previous swap to openPlanSettings() regressed: openAppSettings dispatches
appConfig:open, which nothing listens for, so the modal never opened at all.

Root cause: the config opener (QuickAccessBar) opens the modal whenever the path
is /settings/*, but it never carries the section, and AppConfigModal defaults
`active` to "overview" — so navigateToSettings("plan") opened config on Overview,
not Plan.

Fix: revert both modal CTAs to navigateToSettings("plan") (which does open the
modal via the URL), and have the saas AppConfigModal select its section from the
/settings/<section> path when it opens. This fixes the CTAs at the source and
makes any /settings/<section> deep link land correctly, with no event-timing race.
2026-06-11 21:31:55 +01:00
Connor Yoh f25132f90a fix(payg): limit-modal CTA opens the Plan section, not config's default
FreeLimitReachedModal ("View Processor Plan") and SpendCapReachedModal ("View
Spending Limit") called navigateToSettings("plan"), which only pushes a
/settings/plan URL — in SaaS that opens the App Config modal at its default
section, not the Plan page. Switch to openPlanSettings(), which dispatches the
appConfig:open + appConfig:navigate events the config modal listens for (the same
proven path the old upgrade toast + UpgradeModal use), so the CTA lands on Plan.
2026-06-11 21:25:25 +01:00
Connor Yoh da9b5bd48c feat(payg): pop the usage-limit modal when a policy run is blocked
#6626 wired the limit modals for direct browser→API calls, but a policy's tool
calls run server-side, so their 402 entitlement block never reaches the apiClient
interceptor — the run just showed up "failed" with no modal. Bridge it:

BE (proprietary, billing-layer-agnostic):
- PolicyEngine.runToCompletion catches a downstream RestClientResponseException;
  on a 401/402 it regex-extracts the body's `error` sentinel + `subscribed` flag
  and surfaces them on the run (PolicyRun.errorCode/errorSubscribed →
  PolicyRunView), instead of only a generic failure string. The code is passed
  through, not interpreted, so proprietary stays free of saas coupling.

FE:
- usePolicyAutoRun: when a polled run finishes with an entitlement errorCode,
  broadcast POLICY_LIMIT_REACHED_EVENT (with `subscribed`), deduped per run so a
  folder-watch burst opens the modal once. The proprietary hook can't import the
  saas modal API, so it bridges via a window event.
- UsageLimitModalHost (saas): listens for that event and opens the spend-cap
  (subscribed) or free-limit (free) modal — the same modals direct calls use.

Tests: PolicyEngineTest asserts a downstream 402 surfaces errorCode +
errorSubscribed on the run. proprietary compileJava, PolicyEngineTest, eslint,
tsc (proprietary + saas), and policies/interceptor vitest all green.
2026-06-11 21:21:29 +01:00
Connor Yoh 124f4af6dd fe(payg): show the usage-limit modal (not a toast) when the limit is hit
Wires the backend's 402 entitlement responses to Ethan's usage-limit modals
(#6623) instead of the old upgrade toast. On a limit hit the apiClient
interceptor now pops the matching modal and shows no toast:

- subscribed team over its spending cap  -> openSpendCapModal()
- un-subscribed team over its free grant  -> openFreeLimitModal()

paygErrorInterceptor:
- classifyPaygError also recognises 402 PAYG_LIMIT_REACHED (the API-key path),
  alongside the existing FEATURE_DEGRADED (JWT/web path); both pick the modal.
- handlePaygError fires the modal via the subscribed flag (extractSubscribed),
  defaulting to the free-limit modal when absent. No toast. SIGNUP_REQUIRED
  (anonymous) keeps its existing signup-modal event.

EntitlementGuard: FEATURE_DEGRADED 402 body now carries `subscribed` (it already
did on PAYG_LIMIT_REACHED), so the web path can choose free-limit vs spend-cap.

Tests: interceptor opens the right modal per sentinel × subscribed (and defaults
to free when subscribed is absent); guard asserts `subscribed` on the body.
:saas:test, spotless, eslint, saas tsc, and the interceptor vitest all green.

Covers direct UI/API tool calls (the apiClient catch point). The async
policy-run path (402 happens server-side; FE polls run status) is a separate
follow-up.
2026-06-11 20:49:46 +01:00
ConnorYohandGitHub ee9fdeed6b fix(payg): run the entitlement guard before the charge interceptor (#6622)
## Problem

When the `EntitlementGuard` refuses a request with **402** (team is over
its free allowance / spending cap, or has no subscription to bill), the
handler never runs — so it must not charge. But it did: the guard (order
**1100**) ran *after* the charge interceptor (**1000**), so
`openProcess` had already written the charge before the 402, and
`afterCompletion` then billed it as "customer paid for the attempt" — a
ledger debit, and a **Stripe meter for a subscribed-over-cap team**.

## Fix

**Run the guard first** (order **900**, before the charge interceptor at
1000). Spring runs interceptors in ascending order on the way in and
**skips a later interceptor's `preHandle` (and `afterCompletion`)
entirely once an earlier one returns `false`** — so a refused request
short-circuits with its 402 *before* the charge interceptor runs at all.
A blocked request never opens a process, materialises inputs, or writes
a charge.

This replaces the earlier attribute-flag + `afterCompletion`-refund
approach with a simpler reorder (per review): the no-charge-on-block
guarantee is now **structural**, and it also avoids the wasted
open-then-refund churn (no temp-file write, no debit/refund pair) for
refused requests.

### Why the reorder is safe
- `EntitlementGuard` reads no `PaygChargeInterceptor` state and has **no
`afterCompletion`** (only `preHandle`), so reverse-order teardown is a
non-issue.
- The legacy `UnifiedCreditInterceptor` (default order **0**, and only
registered under the `legacy-credits` profile) still runs first, so any
legacy rejection wins.
- For *admitted* requests both interceptors still run (guard then
charge) — behaviour is unchanged; only refused requests now
short-circuit before the charge.

## Tests

`PaygWebMvcConfigTest` locks the `ENTITLEMENT_GUARD_ORDER <
INTERCEPTOR_ORDER` invariant (if it's ever reversed, refused requests
would bill again — this fails first). Existing `EntitlementGuardTest`
already proves the guard returns 402 on a degraded/billable request.
`:saas:test` + spotless green.

## Related (separate, in progress)

The **fail-cleanly + fire-the-modal** half (suppress the error toast,
trigger the subscribe/raise-cap modal via the existing
`subscribed`/`category` signal — catching the 402 centrally so direct
API usage is handled, and propagating the entitlement reason through the
async policy-run status) lands **with Ethan's modal** so we don't remove
the toast before there's a popup to replace it.
2026-06-11 20:42:40 +01:00
Anthony Stirling b1a960a240 Skip self-host user-table bootstrap (team backfill, grandfathering) in SaaS 2026-06-11 20:39:48 +01:00
EthanHealy01andGitHub 7e493226c4 add popups for free limit hit and spend cap hit (#6623) 2026-06-11 20:34:59 +01:00
Anthony Stirling d48017a5b5 Skip engine free-port probe in containers (fixed port) 2026-06-11 20:18:54 +01:00
ConnorYohandGitHub 37b4d24a95 fix(payg): gate + charge AI document tools and AI Create sessions (#6617)
## Problem

Two AI surfaces slipped through PAYG unbilled:

1. **AI document tools** — `/api/v1/ai/tools/**`
(`PdfCommentAgentController`, `MathAuditorAgentController`) live in the
**proprietary** module, which can't depend on `saas` and so can't carry
the saas-only `@RequiresFeature`. They also lacked
`@AutoJobPostMapping`, so the charge interceptor's scope gate
short-circuited them **before** category resolution: **not charged, and
not even entitlement-gated** — whether called directly or dispatched by
the orchestrator.
2. **AI Create** — `/api/v1/ai/create` is JSON/session-based with no
file input, so the multipart charge path never fired. The old
per-generation charge ran through the now-dead legacy credit system, so
it currently charges nothing.

## Fix

- **`AiToolRoutes`** (new, saas) — single source of truth for the
`/api/v1/ai/tools/**` prefix. The proprietary controllers stay
untouched; the saas hot-path recognises them by path:
- **`PaygChargeInterceptor`**: brings these routes into scope and bills
them **AI** on a direct call. An orchestrator-dispatched call still
resolves to **AUTOMATION** first (the `X-Stirling-Automation` header is
checked before the path rule), so AI-tool-inside-a-workflow keeps
billing as automation.
  - **`EntitlementGuard`**: gates them on **`AI_SUPPORT`**.
- This keeps the `proprietary → saas` layering intact (no backwards
dependency).
- **`JobChargeService.chargeStandalone(ctx, units)`** — charges a fixed
unit count for a non-file billable action, reusing the existing
free-grant split + shadow row + ledger debit + `close()`→meter path on a
standalone bookkeeping job (no lineage inputs, so nothing lineage-joins
it). **`JobService.open(ctx, docUnits)`** opens that bare job.
- **`AiCreateController.createSession`** — charges **one document per
session** at creation (best-effort; entitlement is already enforced
upstream by the class-level `@RequiresFeature(AI_SUPPORT)`). Follow-up
edits (`outline` / `reprompt` / `draft` / `template` / `stream`) carry
**no** charge — they have no charge hook, so "charge on create,
follow-ups free" falls out naturally.

Per the agreed scope: charge AI Create on create now; we can optimise
follow-up handling later. **AI workflow categorisation (AUTOMATION vs
AI) intentionally left as-is** (the orchestrator's automation header
dominates by design).

## Tests

- `PaygChargeInterceptorTest`: AI-tool route (no annotations) is in
scope + **AI** category; same route with the automation header →
**AUTOMATION**; a plain non-AI route still short-circuits.
- `EntitlementGuardTest`: AI-tool route is in scope + gated on
**AI_SUPPORT** (degraded team → 402; anonymous → 401 with `category:
AI`).
- `JobChargeServiceTest`: `chargeStandalone` charges + meters the paid
portion for a subscribed team, draws the free grant (no meter) for an
unsubscribed team, and rejects `BYPASSED`.

`:saas:test` + `:saas:spotlessCheck` green; coverage gates met.

## Follow-ups (not in this PR)

- Make AI Create follow-ups explicitly cheaper / chained if we want
(currently free by absence of a hook).
- Decide whether AI-tool-inside-a-workflow should bill as AI rather than
AUTOMATION.
2026-06-11 20:10:44 +01:00
ConnorYohandGitHub aaa2599e23 fix(saas): block accepting an invite when it would orphan a paid team (#6616)
## Problem

A **free team can invite a paid team's leader** to join. The leader
accepts, and:
- `acceptInvitation` moves them onto the inviting team and removes their
membership from the old team, but only deletes the old team if it's
**personal**.
- Their paid (non-personal) team is left **memberless but still
subscribed** — an orphaned Stripe subscription billing for a team nobody
is in.

## Root cause

Two gaps in `SaasTeamService.acceptInvitation`:

1. The pre-accept guard checks `hasPaidSubscription(acceptingUser)` →
`existsActivePaidSubscriptionForUser(supabaseId)`, keyed on
**`user_id`**. A team's plan is keyed on **`team_id`**
(`existsActiveSubscriptionForTeam`), so a paid team's *leader* isn't
caught and accepts freely.
2. The 'leave existing teams' loop deletes the membership directly and
only marks **personal** teams for deletion — it bypasses the last-leader
protection `leaveTeam` already enforces (`"Cannot leave as the last team
leader. Transfer leadership first."`), and never cleans up / cancels the
non-personal team.

There is no in-app subscription-cancel path (cancellation is
Stripe-portal/webhook driven), so nothing reconciles the orphan after
the fact — it has to be prevented.

## Fix

Add `assertCanLeaveCurrentTeamsToJoinAnother(user)`, called in
`acceptInvitation` before any membership changes. For each non-personal
team where the user is the **last leader**, the accept is rejected:
- team has an active subscription → *"Cancel the plan or transfer
leadership before joining another team."*
- otherwise → *"Transfer leadership before joining another team."*

This mirrors the protection `leaveTeam` already has and is
team/leadership-aware, closing the `user_id`-vs-`team_id` gap. Regular
members and teams with another leader are unaffected.

## Verification

- `ENABLE_SAAS=true ./gradlew :saas:compileJava` — passes.
- Manual: with a paid team leader, accepting an invite to another team
should now be rejected with the message above; verify a non-leader
member can still accept.

## Note

This prevents *new* orphans. Any teams already orphaned by this bug
(memberless, still subscribed) would need a one-off reconciliation —
happy to follow up with a query/cleanup if useful.
2026-06-11 20:10:21 +01:00
EthanHealy01andGitHub 33026e1a82 update saas onboarding (#6619)
<img width="1002" height="487" alt="Screenshot 2026-06-11 at 6 20 10 PM"
src="https://github.com/user-attachments/assets/5ee3cfc2-6c4f-4b35-9586-ef45fa216c6a"
/>
2026-06-11 19:39:02 +01:00
Anthony StirlingandGitHub 1d598d5caa MCP OAuth discovery fix + Supabase consent page (#6608) 2026-06-11 18:30:49 +01:00
ConnorYohandGitHub 9e5fe2f4ca fix(payg): attribute policy runs to the owner so usage is charged (#6620)
## Problem

A policy ran over a file but the owner's **free usage was never
consumed**.

A policy run executes on a **background virtual thread**
(`PolicyEngine.submit` → `asyncExecutor`), and Spring's
`SecurityContextHolder` is thread-local — so the worker thread has no
identity. When `PolicyExecutor` → `InternalApiClient.post` resolves the
tool-call API key via `UserService.getCurrentUsername()`, it finds
nothing and falls back to the **`INTERNAL_API_USER`** key. The loopback
tool calls then authenticate as that system account, so
`PaygChargeInterceptor` attributes the charge to *its* team (or none) —
the real owner's free grant is untouched. Folder-watch / scheduled
triggers are even further removed (fired from a background watch loop
with no request context at all).

The charging *mechanism* was fine (AUTOMATION, multipart,
`openProcess`); only the **attribution** was wrong.

## Fix

Propagate the acting identity onto the worker thread using the
**audit-principal MDC key** that `UserService.getCurrentUsername()`
already reads as its documented async fallback (the same mechanism used
for other async jobs). No new plumbing through the executor.

- **`runPolicy`** (stored policies — covers triggers *and* manual
`runWith`) → bill the **policy owner**. `Policy.owner` is the username
stamped at creation, so `getApiKeyForUser(owner)` resolves it.
- **`submit`** (ad-hoc Automate/AI one-offs) → bill the **submitting
user**, captured on the request thread (it doesn't survive the hop to
the worker otherwise).

With the principal set, `InternalApiClient` dispatches each tool call as
that user → the interceptor resolves the right team → free grant draws /
Stripe meters correctly.

## Tests

`PolicyEngineTest`:
- `runPolicyDispatchesToolCallsAsTheOwner` — asserts MDC
`auditPrincipal` == the policy owner at the moment
`InternalApiClient.post` is invoked.
- `adHocRunDispatchesToolCallsAsTheSubmittingUser` — asserts it's the
submitting user for an ad-hoc run.

`:proprietary:test` + `:saas:test` + spotless green; coverage gates met.

## Heads-up (not in this PR)

Once attributed, **automatic folder-watch / scheduled runs consume free
grant (or bill) per file** — set up once, runs forever. That's
automation-is-billable working as intended, but a set-and-forget policy
can drain an allowance fast, so it may warrant a per-policy cap or a
heads-up in the UI. Flagging for a product decision.
2026-06-11 18:28:58 +01:00
Reece BrowneandGitHub 9ee0bc4b32 Policies: enforce on upload or export (#6614)
Follow-up to #6604 (merged). Builds the Security policy out so it
actually enforces, driven from the editor.

## What it does
- **Run on upload or export** — a single choice in the wizard: enforce
when a file is uploaded, or just before it's exported.
- **Output** — enforced result is a **new version** of the file
(default) or a **new file**, with optional filename
prefix/suffix/auto-number ("Output filename" subsection; auto-number
only for new files).
- **Export enforcement** — exporting an export-mode file runs the policy
first and downloads the enforced result; never hard-blocks (on failure
the original downloads). For new-version policies the in-editor file is
versioned too. Covers every export path incl. multi-file ZIP. A toast
(glowing in the policy's accent while it runs) reports progress and
fades after ~10s.
- **Affordances** — a freshly enforced file briefly glows its policy
accent and carries a shield badge.
- **Config tidy-up** — removed the unwired Security setting fields + the
wizard's review step; "Upgrade to enterprise" on locked categories;
category accent in the detail/wizard headers.

## Notes
- Builds on the manual-only (client-driven) policy model from #6587
(`trigger: null`, metadata in `output.options`), adding the `runOn`
field + export-time enforcement.
- The page-editor merge-export (no single source file) enforces +
downloads but doesn't version in place.

## Verification
typecheck (core + proprietary), eslint, prettier; proprietary suite
(105) green; flows checked in-app.
2026-06-11 18:12:01 +01:00
ConnorYohandGitHub 5fa5e12c64 fix(saas): show team invitation banner in SaaS web build (#6612)
## Problem

When a user is invited to a team, the SaaS web app shows **no invitation
banner** — even though the pending invite is returned by
`/api/v1/team/invitations/pending` on refresh.

## Root causes

1. **Never rendered in SaaS.** `TeamInvitationBanner` only existed in
`desktop/`, wired solely into `DesktopBannerInitializer`. The SaaS
banner stack rendered only `<UpgradeBanner />`.
2. **Single banner slot.** `BannerContext` holds one node; `setBanner`
replaces it. `TrialStatusBanner` called `setBanner(null)` when there was
no active trial (and re-fired once `trialStatus` resolved async), wiping
any other banner.
3. **Shadowing was too fragile.** A first attempt shadowed the
proprietary `UpgradeBannerInitializer` from the saas layer, but
`vite-tsconfig-paths` resolves the `@app` specifier once at dev-server
start — a newly-added shadow of an already-resolved module isn't picked
up on a browser refresh, only a full restart. So the proprietary
initializer kept running and no invite banner appeared (while the SaaS
team context still fetched + populated the invite, which is why the
pending call was visible).

## Fix

- Add `saas/components/shared/TeamInvitationBanner.tsx` — ported from
desktop, minus the desktop `connectionMode` gate and explicit billing
refresh (SaaS `acceptInvitation` already refreshes credits + session).
- Render it **inline in `saas/routes/Landing.tsx`** next to
`GuestUserBanner` — a new import specifier in an existing file
(HMR-friendly), unambiguously inside `SaaSTeamProvider`, mirroring the
proven `GuestUserBanner` pattern. No dependency on the single banner
slot.
- **Remove `TrialStatusBanner`** (trials are being retired) so it can't
clobber banners. Also drops the stale mention from the stripe-lazy-load
test comment.

## Verification

- `tsc --noEmit -p tsconfig.saas.vite.json`: clean in touched files;
total unchanged from baseline (37 pre-existing, unrelated).
- Manual: pull + verify the Accept/Decline banner appears for an account
with a pending invite.
2026-06-11 18:01:58 +01:00
James BruntonandGitHub 34ead60194 Kill off agents pane now that we have FAB (#6613)
# Description of Changes
Kill off agents pane now that we have the FAB. Also fixes a bug with the
FAB where it would sometimes fail to render the chat, and fixes a
duplicated entry in the Vite config which was throwing a warning
2026-06-11 17:28:05 +01:00
ConnorYohandGitHub 5bc7ae626d fix(payg): cancelled subscription left team gated as subscribed (#6611)
## Problem

A team that **cancelled** its PAYG subscription kept full subscribed
access:

- **UI didn't reflect cancellation** — the Plan tab still rendered the
subscribed view, never the free/upgrade view.
- **Automation wasn't stopped** — automation / AI / API kept running
without ever falling back to the free-grant gate.

## Root cause

`TeamBillingService.compute` decided `subscribed` as:

```java
boolean subscribed =
    subscriptionId != null
        || extOpt.map(PaygTeamExtensions::getStripeCustomerId).filter(s -> !s.isBlank()).isPresent();
```

On cancellation, the `customer.subscription.deleted` webhook calls
`payg_unlink_subscription`, which nulls `payg_subscription_id` but
**deliberately keeps `stripe_customer_id`** (so a future re-subscribe
can reuse the Stripe customer).

`payg_link_subscription` is the **only** writer of
`payg_team_extensions.stripe_customer_id`, and it writes it in the
*same* `UPDATE` as `payg_subscription_id` (on
`customer.subscription.created`). So the customer id is never set before
the subscription id — the "pre-webhook stand-in" the old comment claimed
**cannot happen**. The fallback only ever pinned a team that *ever*
subscribed to `subscribed` forever, because the Stripe customer outlives
the subscription.

Both symptoms are this one flag:
- `PaygWalletController` status → `SUBSCRIBED` vs `FREE`
- `EntitlementService` gate branch → monthly-cap vs free-grant

## Fix

Gate `subscribed` purely on `payg_subscription_id != null`. A cancelled
team now correctly drops to free (UI shows free; billable ops gate on
the one-time grant). This aligns the wallet/entitlement read with the
**meter path** (`JobChargeService.close`), which already gated on
`payg_subscription_id`.

Handles both Stripe cancel modes: "cancel at period end" keeps the sub
`active` (id stays set) until `.deleted` fires at period end → access
through the paid period; immediate cancel fires `.deleted` now → flips
to free now.

**No data migration / backfill** — already-cancelled teams have
`payg_subscription_id = NULL`, so they flip to free as soon as this
ships (within the 30s billing-cache TTL).

## Tests

Adds `TeamBillingServiceTest` — the `subscribed` computation previously
had **no** unit coverage (which is how this shipped). Covers: subscribed
iff subscription id present; **cancelled team (customer id remains,
subscription id null) ≠ subscribed** + free grant survives;
no-subscription/no-customer; no extension row.

`:saas:test` + `:saas:spotlessCheck` green; coverage gates met.

## Not included (optional hardening, can fast-follow)

- Cross-check the synced `stripe.subscriptions.status` to guard a
*missed* `.deleted` webhook leaving `payg_subscription_id` stale.
- Push cache-invalidation from the webhook (currently ≤30s TTL
staleness).
2026-06-11 17:26:58 +01:00
ConnorYohandGitHub f16ca4795c fe(payg): remove em dashes from Plan page copy (#6610)
## What

Removes all em dash (`—`) characters from the **user-facing text** on
the Plan page (PAYG section), replacing them with colons, commas, or
restructured punctuation so the copy reads naturally.

## Changes

- `frontend/editor/public/locales/en-GB/translation.toml` — all `payg.*`
strings (this is what actually renders on the page)
- `PaygFree.tsx` — `t()` default fallbacks + the `{" — "}` JSX
benefit-list separators (now `{": "}`)
- `Payg.tsx` — `t()` default fallback for the editor-plan body

## Notes

- The en-dash range separator (`{{start}} – {{end}}`) in the
billing-period string is intentionally **kept** — only em dashes were
targeted.
- JSDoc / code comments containing em dashes were **left unchanged**,
since they aren't rendered text on the page.
<img width="990" height="502" alt="image"
src="https://github.com/user-attachments/assets/13d89b0f-007c-4b4c-b72d-1d912f968bc7"
/>
2026-06-11 16:50:27 +01:00
James BruntonandGitHub d52c7ced7c Improvements to Stirling Engine to prepare for SaaS release (#6603)
# Description of Changes
- Use pool for postgres connections
- Add ability to require user ID to be set on API calls to the engine
- Add process-wide concurrency cap on AI access (in addition to existing
user caps)
- Allow number of workers (threads) to be specified for stirling engine
- Update env var names to reflect that the DB is not just for RAG
2026-06-11 16:31:35 +01:00
James BruntonandGitHub 606964ee52 Fix Teams and MCP settings pages (#6605)
# Description of Changes
Remove the Pro guards from the Team settings page and also fix the
styling of the MCP settings screen (the code sections were black text on
black background in light mode)
2026-06-11 16:26:02 +01:00
cf513c255b PAYG: pay-as-you-go billing — metered automation/AI/API + one-time free grant (#6589)
## Summary

Pay-as-you-go (PAYG) billing for Stirling-PDF SaaS. Manual PDF editing
stays free forever; only **automation, AI, and API** usage is metered.
Every team gets a **one-time lifetime free grant** (default 500 PDFs)
before any billing; past that, a team adds a card and pays per metered
document, with a self-set monthly spending cap.

This branch combines and supersedes the in-flight BE (#6574) and FE
(#6579) work plus the SaaS edge functions (Stirling-PDF-SaaS PR, now on
`v3`), hardened into a single reviewable feature after a pre-merge
dead-code/security review.

## Billing model

- **Always free:** manual / JWT web-tool usage is `BYPASSED` — never
metered, no matter where it's triggered.
- **Billable categories:** `AUTOMATION`, `AI`, `API`.
- **One-time lifetime free grant** (`pricing_policy.free_tier_units`,
default 500): never resets, survives subscribing. It gates unsubscribed
teams (billable API calls hard-stop with a 402 once exhausted) and
decides the free-vs-paid split of every job.
- **Subscribed:** paid documents (beyond the grant) are metered to a
Stripe Billing Meter; an optional monthly spending cap degrades billable
categories when reached.
- **Dedup:** the same file pushed through several steps within a
workflow window counts **once** (lineage join), so API/AI chaining on
one file isn't double-charged.

## What's included

**Database** — Flyway migrations `V11`→`V21` with matching Supabase
twins: pricing policy + per-team sidecar (`payg_team_extensions`:
subscription id, Stripe customer, free-grant counter), append-only
`wallet_ledger`, shadow charges, subscription-state RPCs (`V14`), audit
logs (`V15`), billing category (`V16`), one-time lifetime free grant
(`V19`), launch-grant seed (`V20`), drop of the unused
`wallet_category_summary` view (`V21`).

**Charge pipeline** — `PaygChargeInterceptor` (open/join a process,
split the free grant, write the ledger DEBIT), `JobChargeService`
(consume the grant under a row lock, restore it on a first-step refund,
meter only the paid portion on completion), `StaleJobCloser` fallback
(idempotent close → meter).

**Entitlement** — `EntitlementService` (per-team cached snapshot:
grant-gated for free teams, monthly-cap-gated for subscribed) +
`EntitlementGuard` (401 `SIGNUP_REQUIRED` / 402 `FEATURE_DEGRADED` /
`PAYG_LIMIT_REACHED`).

**Metering** — `PaygMeterReportingService` writes a durable
`payg_meter_event_log` row around every POST to the `meter-payg-units`
edge fn (pending → posted/failed); `PaygMeterReconcileScheduler` retries
unposted events under the same idempotency key inside Stripe's 24h dedup
window.

**Billing facts** — `TeamBillingService` reads the synced `stripe.*`
mirror (subscription window, per-document rate; the unsubscribed-team
estimate resolves the rate by Price `lookup_key = plan:processor`).

**Wallet API** — `PaygWalletController`: `GET /api/v1/payg/wallet`,
`PATCH /api/v1/payg/cap`.

**Frontend** — PAYG Plan page (two-card free layout + subscribed views),
`useWallet`, upgrade modal with lazy-loaded Stripe Embedded Checkout and
a shared `SpendCapControl`, customer-portal link, 402/401 interceptor
toast, en-GB i18n. (Per-member usage shows each teammate's spend; the
activity feed is behind a flag until polished.)

**SaaS edge functions** (`Stirling-PDF-SaaS` `v3`) —
`create-checkout-session`, `create-payg-team-subscription`,
`create-customer-portal-session`, `meter-payg-units`,
`payg-subscription-webhook`, `stripe-sync`, plus the stripe-sync
`migrate` + scoped-`backfill` scripts. All price lookup is DB-driven (no
`STRIPE_PAYG_PRICE_ID_*` env vars).

## Release prerequisites (prod)

1. Apply Flyway migrations (`V11`→`V21`) and the Supabase migration
twins.
2. Stripe Sync Engine: run `stripe-sync:migrate`, then a **scoped**
backfill — `product`, `price`, `customer`, `subscription` only (not
`all`, which rate-limits).
3. Register 2 PAYG webhook endpoints (each its own signing secret):
`stripe-sync` (product/price/customer/subscription `.*`) and
`payg-subscription-webhook` (`customer.subscription.created`/`.deleted`
drive state; `.updated` + `invoice.*` observed). Keep the legacy
`stripe-webhook` only if credits/self-hosted flows still run.
4. Stripe Billing Meter: `event_name = payg_doc_units`, value key
`processed_documents`.
5. Env: `PAYG_METER_ENDPOINT` + `SUPABASE_EDGE_FUNCTION_SECRET`
(backend); the webhook signing secrets (edge fns). The default pricing
policy must point at the PAYG Stripe Price(s); `V20` seeds
`free_tier_units = 500`.

## Testing

- `:saas:test` green, `:saas:spotlessCheck` clean, edge-fn Deno tests
green, FE saas typecheck clean (the remaining errors are pre-existing
`proprietary/*` + `prototypes/*`, untouched here). Cucumber shadow-mode
suite + CI workflow included.

## Pre-merge review

An independent dead-code/security pass came back **clean on security**
(team-derived authz / no IDOR, leader-only cap mutation, no
billing-category downgrade, dev/mock hooks gated to
`import.meta.env.DEV` + `/dev/`, no secrets/injection, fail-open
metering by design). The dead/unwired code it flagged has been removed
in this branch (unenforced sub-cap control, an unused JDBC DAO + its
view, dead methods).

## Follow-ups (tracked, not blocking)

- **Enforce per-member sub-caps** — the control was removed because it
read for display but never gated a request; the per-member usage display
and `cap_units` column are retained for when enforcement is wired.
- **API/AI chaining billing model + `ProcessType` enum** — confirm
same-file dedup covers API chaining; define per-tool AI charging; decide
whether the unused enum values stay.
- **Activity feed** — hidden behind a flag until the meter-event surface
is polished.

---------

Co-authored-by: Reece <reece@stirlingpdf.com>
2026-06-11 15:56:01 +01:00
Reece BrowneandGitHub 11ab762f57 feat(policies): config refinements + new-version output (post-#6598) (#6604)
Follow-up to #6598 (squash-merged into `SaaS`). These are the policy
refinements made after that merge, against the current `SaaS` tip.

## Changes
- **Simplify Security config + plain-language info buttons** — Redact
config reduced to the PII field; Sanitise has no config
(JavaScript-removal only) with a non-technical info button; per-tool
info buttons reworded to match the tool-steps style.
- **Hide 'Flatten PDF pages to images' from the watermark policy
config** — new `PolicyWatermarkConfig` wrapping the watermark settings
with the flatten checkbox gated off.
- **Flatten-to-image on by default for redact + watermark** — both
normalise `convertPDFToImage: true` on mount.
- **Self-heal a stale backing folder** — `ensurePolicyFolder` recreates
a backing folder whose `folderId` no longer resolves (preferring the
backend's stored automation), instead of hanging Edit Settings on a
permanent "Loading…".
- **Version the input file on 'new version' output mode** — completed
runs whose policy output mode is `new_version` replace the input file
with a versioned child (origin tool `automate`) rather than adding a
separate file; falls back to a new file if the input is gone.
`outputMode` is plumbed through `PolicyState`, the local-cache default,
and backend reconciliation.

## Verification
- `typecheck:proprietary` + `typecheck:core` clean
- policy + hooks vitest: 17 passing
- eslint + prettier clean on all changed files
2026-06-11 14:45:22 +01:00
James BruntonandGitHub 68e031ac55 Policies tidying (#6587)
# Description of Changes
* Improve typing of API (breaking change but unreleased, frontend also
updated in this PR)
* Add ownership concept to policies
* De-AI the comments
* Update the `task dev:saas` rule to spawn the engine as well
2026-06-11 13:20:01 +01:00
Anthony Stirling c722b9f6ad fix: MCP copy buttons read as proper buttons in dark mode
Subtle/gray compact buttons rendered as low-contrast floating text;
use the default variant (adaptive surface+border) idle, light teal when
copied.
2026-06-10 17:26:43 +01:00
Anthony Stirling 36c68fb69e fix: doubled base path in mobile-scanner QR URL
A configured frontendUrl/server_url already includes the subpath (e.g.
/bpp), but the code also applied withBasePath, producing /bpp/bpp/...
Append the route directly to a configured URL; reserve withBasePath for
the bare-origin fallback. Matches the ShareFileModal convention.
2026-06-10 17:21:42 +01:00
Anthony Stirling d3c359f923 reword MCP usage tip to reference the API and Automation 2026-06-10 16:38:16 +01:00
Anthony Stirling 4947ab12fd remove the 'What your assistant can do' tool-category badges from MCP section 2026-06-10 16:33:48 +01:00
Reece BrowneandGitHub 8dde4262ec feat(policies): backend-driven policy enforcement (frontend) (#6598)
## Summary
Adds the **Policies** feature (proprietary, behind the
`POLICIES_ENABLED` flag): backend-driven enforcement that runs a fixed
tool pipeline on documents, docked in the right tool sidebar alongside
Tools.

## Highlights
- **Policy catalog** — 5 categories; **Security** is wired (redact PII +
sanitize), the others are marked "Coming soon".
- **Backend as source of truth** — policies persist via the Policies
engine (`/api/v1/policies`), one policy per category, with a local cache
+ offline fallback.
- **Auto-run** — enabled policies run on every uploaded file: dispatch →
poll → import outputs into the workspace.
- **Security redact config** — PII preset dropdown + custom word/regex
entry + advanced options; tool params map to the backend endpoint
fields.
- **Activity feed** with retry on failures; **file badges** showing
which policies ran on a file (sidebar + files page), tinted to the
policy colour.
- Reuses the **Watched Folders** engine for each policy's backing
folder; policy-owned folders are filtered out of the Watched Folders UI.

## Notes
- Gated by `POLICIES_ENABLED` (true in proprietary, false in core) —
unreachable in the open-source build.
- Frontend-only diff; depends on the backend Policies engine and the
merged Watched Folders feature.
2026-06-10 15:57:08 +01:00
James BruntonandGitHub ebc28b0a14 Add team settings to SaaS (#6601)
# Description of Changes
Add team settings UI to SaaS, which is currently only available in
desktop. It'd be nice to refactor this so they're more shared, but
they're slightly different so needs to be done with some care. Leaving
for followup work.
2026-06-10 15:54:18 +01:00
Anthony StirlingandGitHub 56862cc1d3 Merge branch 'main' into SaaS 2026-06-10 15:51:43 +01:00
Anthony Stirling d6306f51e1 fix: no blue disc behind the sidebar profile picture
Keep the colored background only for the initials fallback; a real
photo fills the circle with a transparent backing.
2026-06-10 15:05:29 +01:00
Anthony StirlingandGitHub 9a1804ce04 Merge branch 'main' into SaaS 2026-06-10 14:58:44 +01:00
Anthony Stirling be0db3fd8a fix: show profile picture in the FileSidebar bottom bar
The home page's bottom-left settings button is FileSidebar's bottom
bar, which hardcoded an initials circle - the avatar work in
useConfigButtonIcon only affects the QuickAccessBar rail, which the
home page doesn't render. Add a layered useProfilePictureUrl hook
(core stub returns null; saas returns the auth context URL) and render
the picture inside the existing avatar circle, falling back to the
initial when absent or on image load failure.
2026-06-10 14:49:16 +01:00
Anthony Stirling 90bda6b4b4 fix: User principal was discarded by the resource server re-authentication
The saas chain authenticates bearer requests twice:
SupabaseAuthenticationFilter builds an EnhancedJwtAuthenticationToken
with the resolved User principal, but BearerTokenAuthenticationFilter
(oauth2ResourceServer) then re-authenticates the same token through the
static toAuthentication converter and overwrites the SecurityContext
with a token whose principal is the raw Jwt - so storage endpoints kept
returning 401 "Unsupported user principal" despite the principal fix.

Carry the User across in the converter: when the context already holds
an EnhancedJwtAuthenticationToken for the same subject with a User
principal, attach that User to the converter-built token. No extra DB
lookups; anonymous sessions and API-key auth unchanged. Covered by new
unit tests (carry, no-context, subject mismatch).
2026-06-10 14:37:12 +01:00
Anthony Stirling 5b412c0fed cleanup: trim oversized comments across recent SaaS fixes
Reduce multi-paragraph comment blocks to short two-line notes and drop
history-style references; no behaviour changes.
2026-06-10 14:30:29 +01:00
Anthony Stirling bf18af4708 fix: show user avatar on the home page settings button
The bottom-left settings button and the settings page both read
profilePictureUrl, but only the settings page had a fallback (initials
avatar) - the button silently fell back to a gear. The URL itself was
usually null because fetchProfilePicture raced the background OAuth
avatar sync with a fixed 500ms delay and never retried, and a missing
bucket object simply resolved to null.

- useConfigButtonIcon: fall back to the same initials avatar as the
  settings page instead of the gear when no picture URL is available.
- UseSession: fetch the profile picture when syncOAuthAvatar settles
  (init and SIGNED_IN) instead of after an arbitrary 500ms.
- fetchProfilePicture: when the bucket copy is missing, fall back to
  the OAuth provider's own photo URL so the picture shows immediately
  on first login - unless the user explicitly uploaded/removed a
  picture (metadata source 'upload'), preserving the remove flow.
2026-06-10 14:25:19 +01:00
Anthony StirlingandClaude Opus 4.8 d29059e6fb fix: storage APIs 401'd valid Supabase sessions (principal type mismatch)
FileStorageService.requireAuthenticatedUser and
FolderService.requireAuthenticatedUser authorize via
'principal instanceof User', but EnhancedJwtAuthenticationToken extends
JwtAuthenticationToken whose principal is the decoded Jwt - so every
/api/v1/storage/* request 401'd for JWT users AFTER Spring Security had
already authenticated them. This persistent 401-with-valid-session was
the trigger feeding the frontend login loop.

Attach the filter-resolved local User as the token principal for full
accounts (User implements UserDetails, matching the form-login
convention every shared instanceof check expects). Anonymous sessions
keep the raw Jwt principal, preserving their existing exclusions. All
other principal consumers verified safe: AuthenticationUtils checks
instanceof User first, extractSupabaseId/CreditController/Team
SecurityExpressions switch on the authentication type, not the
principal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:44:22 +01:00
Anthony StirlingandClaude Opus 4.8 e7bbbb4702 fix: make the 401 login redirect loop structurally impossible
Audit of every code path that can produce the login->/->login cycle
found the observed loop was one instance of a repeatable class: any
automatic API call that persistently 401s while the Supabase session is
valid triggers httpErrorHandler's hard redirect to /login, which sees
the valid session and bounces back. Close the class, not just the
instance:

- saas apiClient: a 401 that survives a refresh-and-retry means the
  backend rejected a valid token (authz bug / wrong origin), not an
  expired session - never redirect to /login for it. Also fix the stale
  publicEndpoints entry ('endpoints-enabled' matched nothing; the real
  routes are endpoints-availability and endpoint-enabled).
- httpErrorHandler: sessionStorage loop breaker - if a 401 redirect
  already fired within 10s, suppress the repeat instead of cycling.
- Guard the remaining unflagged automatic callers: /api/v1/credits
  (fires on session init and TOKEN_REFRESHED), endpoints-availability
  (fires on app load), and ui-data/login (auto-called when a stale
  stirling_jwt is present).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:44:21 +01:00
Anthony StirlingandClaude Opus 4.8 247ef6313c fix: stop login loop caused by unguarded folder-sync 401
The deployed app looped /login -> / -> /login forever: Login sees a
valid Supabase session and navigates to /, the global FolderProvider
pulls GET /api/v1/storage/folders, the backend rejects it with 401, and
the global error handler hard-redirects back to /login?from=/bpp.

fileSyncService's /api/v1/storage/files pull already opts out via
suppressErrorToast + skipAuthRedirect, so its 401 fails silently;
folderSyncService.list() passed neither flag, so its 401 fell through to
the redirect. Add the same flags - FolderContext.pullFromServer already
handles 4xx locally (flips serverReachable, suppresses the banner).

Note: the underlying 401 on /api/v1/storage/* with a valid session is a
backend/deployment issue (storage endpoints rejecting the Supabase
token); this change makes the frontend resilient so it degrades to
"folder sync unavailable" instead of an auth loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:13:20 +01:00
Anthony StirlingandClaude Opus 4.8 7f7c865888 fix: stop unauthenticated storage calls on /login + fix subpath manifest 404
Two issues seen on the hosted /bpp login screen:

1. GET /api/v1/storage/folders fired (and 401'd) on the login page. The
   global FolderProvider pulls from the server whenever
   appConfig.storageEnabled is true, with no auth gate, so it hits the
   authenticated storage API before the user has signed in. Skip the pull
   on auth routes (/login, /signup, /auth/*, /invite, /reset-password),
   mirroring the existing LicenseContext / AppConfigContext guards. Tests
   wrap FolderProvider in MemoryRouter (now uses useLocation).

2. manifest.json and modern-logo/favicon.ico 404'd from the domain root
   instead of /bpp/. vite base for RUN_SUBPATH deploys was "/bpp" with no
   trailing slash, so <base href="/bpp"> made the browser resolve relative
   links against the parent (root). Use "/bpp/"; getBasePath() strips the
   trailing slash, so BASE_PATH, routing and asset URLs are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:56:48 +01:00
Anthony StirlingandGitHub 2101b4028c Merge branch 'main' into SaaS 2026-06-10 11:42:25 +01:00
Anthony StirlingandClaude Opus 4.8 06476ea69e fix(saas): collapse duplicate Supabase client to one GoTrueClient
The console warned "Multiple GoTrueClient instances detected in the same
browser context" and storage endpoints (/api/v1/storage/folders,
/files) kept 401ing even after a successful token refresh.

Cause: the SaaS bundle instantiated TWO Supabase clients on the same
sb-<ref>-auth-token storage key. :saas/auth/supabase.ts creates the
primary client (used by UseSession + apiClient), while billing /
licensing / user-management code imports @app/services/supabaseClient,
which fell through to :proprietary/services/supabaseClient.ts and called
createClient() again. Each client runs its own autoRefreshToken timer,
so they rotate the refresh token out from under each other → "Already
Used" refresh failures and spurious 401s, plus a residual /login flash.

Add a :saas override of @app/services/supabaseClient that re-exports the
single instance from @app/auth/supabase. The path mapping
(@app/* → src/saas/* → src/proprietary/* → src/core/*) now resolves
every consumer to the same client, so the :proprietary createClient() is
never bundled in the SaaS build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:26:27 +01:00
Anthony StirlingandClaude Opus 4.8 1135bd9b63 fix(saas): stop login→logout→login bounce on cold load
On returning to the app with an expired Supabase access token, bootstrap
requests fired with the stale token and 401'd before Supabase finished
refreshing. The global 401 handler then hard-redirected to
/login?from=… (a full window.location navigation), and once the refresh
landed the app sent the user straight back in — the login/logout/login
flicker.

Two holes in the SaaS apiClient response interceptor caused it:

1. "public" endpoints (e.g. /api/v1/config/app-config) skipped the
   refresh-and-retry path. The backend 401s any expired Bearer token
   regardless of route, so those bootstrap calls 401'd and fell through
   to handleHttpError, which redirected to /login. Now public endpoints
   also refresh-and-retry, and a 401 on a public endpoint sets
   skipAuthRedirect so it can never trigger the global login redirect.

2. Concurrent 401s each called supabase.auth.refreshSession()
   independently. Supabase rotates the refresh token on first use, so
   the racing refreshes failed with "Invalid Refresh Token: Already
   Used" and bounced the app even though the session was recoverable.
   Refreshes are now de-duplicated through a single in-flight promise.

Existing apiClient unit tests (refresh-and-retry on protected 401, bare
/login redirect on genuine refresh failure) are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:13:28 +01:00
Anthony Stirling bbfe29c2ef Merge remote-tracking branch 'origin/main' into SaaS
# Conflicts:
#	frontend/editor/src/core/components/shared/AppConfigModal.tsx
2026-06-10 10:51:06 +01:00
Anthony Stirling 92376b7382 fix: prettier format on AppConfigModal 2026-06-08 21:28:13 +01:00
Anthony Stirling 1d5ce8a1d2 chore: shorten verbose block comments across SaaS branch 2026-06-08 18:38:00 +01:00
Anthony Stirling 8b2baaf0a0 Merge remote-tracking branch 'origin/saas-docker-split' into SaaS 2026-06-08 18:10:02 +01:00
Anthony Stirling d9651f7065 fix(engine): match Dockerfile layout to root Taskfile dir: engine 2026-06-08 18:02:02 +01:00
Anthony Stirling 4cd03be87a fix: send Supabase token on raw fetch in SaaS chat 2026-06-08 16:41:09 +01:00
Anthony Stirling 02d923f378 chore: remove env var debug from vite.config 2026-06-08 16:29:47 +01:00
Anthony Stirling e7d3430134 merge: pull latest main into SaaS 2026-06-08 16:28:14 +01:00
Anthony Stirling 4b2be58fab debug: fuzzy-match env var names that look like VITE_API_BASE_URL 2026-06-08 16:18:19 +01:00
Anthony Stirling 290c8c2c8b debug: enumerate VITE_/RUN_ env var names in build log 2026-06-08 16:06:56 +01:00
Anthony Stirling 90d6ecd7e1 debug: log env vars at build, write build-info.txt with masked markers 2026-06-08 15:55:40 +01:00
Anthony Stirling a0b7daca52 trigger: rebuild after VITE_API_BASE_URL scope fix 2026-06-08 13:37:30 +01:00
Anthony Stirling 0b575ed841 fix: respect BASE_PATH in AI chat fetch and pdfjs worker assets 2026-06-06 21:21:24 +01:00
Anthony Stirling 940cb2fc44 chore: trigger Cloudflare deploy 2026-06-06 19:26:08 +01:00
Anthony Stirling 9da0a0d020 fix: respect BASE_PATH in redirects, comparisons, and cookie consent paths 2026-06-06 19:20:07 +01:00
Anthony Stirling 0b944a29a7 Prefer Maven Central over jboss/shibboleth mirrors for resilience 2026-06-03 09:10:08 +01:00
Anthony Stirling 58aeba2bf7 Add backend-only and SaaS-aware frontend Dockerfiles 2026-06-03 09:03:58 +01:00
349 changed files with 25092 additions and 3930 deletions
+14
View File
@@ -18,6 +18,15 @@ version: '3'
tasks:
dev:
desc: "Start backend dev server"
cmds:
- task: dev:proprietary
vars:
PORT: '{{.PORT}}'
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
@@ -50,9 +59,14 @@ tasks:
PORT: '{{.PORT | default "8080"}}'
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
PROFILES: '{{.PROFILES | default "dev"}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
env:
SERVER_PORT: '{{.PORT}}'
STIRLING_FLAVOR: saas
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{if .AIENGINE_URL}}true{{else}}false{{end}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
cmds:
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
platforms: [windows]
+5
View File
@@ -20,6 +20,11 @@ tasks:
cmds:
- docker build -t stirling-pdf-ultra-lite -f {{.EMBEDDED_DIR}}/Dockerfile.ultra-lite .
build:backend:
desc: "Build backend-only Docker image (no embedded frontend)"
cmds:
- docker build -t stirling-pdf-backend -f docker/backend/Dockerfile .
build:frontend:
desc: "Build frontend-only Docker image"
cmds:
+33 -5
View File
@@ -37,13 +37,27 @@ tasks:
dir: src
vars:
# When PORT is provided (e.g. from dev:all), use it directly.
# When running standalone, probe for a free port starting at 5001.
# With ENGINE_PORT_PROBE=false (Docker), use the fixed STIRLING_ENGINE_PORT
# (default 5001) without probing. Otherwise probe for a free port from 5001.
PORT:
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
sh: |
if [ -n "{{.PORT}}" ]; then
echo "{{.PORT}}"
elif [ -f /.dockerenv ] || [ "${ENGINE_PORT_PROBE:-true}" = "false" ]; then
# Never probe in a container: the port is fixed and the probe script
# isn't shipped in the image. /.dockerenv auto-detects Docker; the
# ENGINE_PORT_PROBE=false flag forces fixed-port mode anywhere else.
# The probe is a dev-only convenience for avoiding local port clashes.
echo "${STIRLING_ENGINE_PORT:-5001}"
elif [ "{{OS}}" = "windows" ]; then
{{.ENGINE_FIND_FREE_PORT_PS}} 5001
else
{{.ENGINE_FIND_FREE_PORT_SH}} 5001
fi
env:
PYTHONUNBUFFERED: "1"
cmds:
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}}
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
dev:
desc: "Start engine dev server with hot reload"
@@ -52,9 +66,23 @@ tasks:
dir: src
vars:
# When PORT is provided (e.g. from dev:all), use it directly.
# When running standalone, probe for a free port starting at 5001.
# With ENGINE_PORT_PROBE=false (Docker), use the fixed STIRLING_ENGINE_PORT
# (default 5001) without probing. Otherwise probe for a free port from 5001.
PORT:
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
sh: |
if [ -n "{{.PORT}}" ]; then
echo "{{.PORT}}"
elif [ -f /.dockerenv ] || [ "${ENGINE_PORT_PROBE:-true}" = "false" ]; then
# Never probe in a container: the port is fixed and the probe script
# isn't shipped in the image. /.dockerenv auto-detects Docker; the
# ENGINE_PORT_PROBE=false flag forces fixed-port mode anywhere else.
# The probe is a dev-only convenience for avoiding local port clashes.
echo "${STIRLING_ENGINE_PORT:-5001}"
elif [ "{{OS}}" = "windows" ]; then
{{.ENGINE_FIND_FREE_PORT_PS}} 5001
else
{{.ENGINE_FIND_FREE_PORT_SH}} 5001
fi
env:
PYTHONUNBUFFERED: "1"
cmds:
+12 -16
View File
@@ -60,24 +60,20 @@ tasks:
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev:saas
vars:
PORT: '{{.BACKEND_PORT}}'
- task: frontend:dev:saas
vars:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
cmds:
- task: dev:_all
vars: { FRONTEND: saas, BACKEND: saas }
dev:all:
desc: "Start backend + frontend + engine concurrently on free ports"
cmds:
- task: dev:_all
dev:_all:
internal: true
vars:
FRONTEND: '{{.FRONTEND | default "proprietary"}}'
BACKEND: '{{.BACKEND | default "proprietary"}}'
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
@@ -87,11 +83,11 @@ tasks:
- task: engine:dev
vars:
PORT: '{{.ENGINE_PORT}}'
- task: backend:dev
- task: 'backend:dev:{{.BACKEND}}'
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
- task: frontend:dev
- task: 'frontend:dev:{{.FRONTEND}}'
vars:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
@@ -367,6 +367,15 @@ public class ApplicationProperties {
*/
private String resourceId = "";
/**
* Additional JWT audiences accepted at the MCP endpoint, on top of {@link #resourceId}.
* Empty (default) keeps strict RFC 8707 binding. Some IdPs cannot mint
* resource-specific audiences - e.g. Supabase's OAuth server always issues {@code
* aud=authenticated} - so operators list the audience their IdP actually emits here
* (env: {@code MCP_AUTH_ACCEPTEDAUDIENCES}, comma-separated).
*/
private List<String> acceptedAudiences = new ArrayList<>();
/**
* JWT claim whose value is matched against a provisioned Stirling username. Defaults to
* {@code sub}; set to {@code email} or {@code preferred_username} to match how your IdP
@@ -50,6 +50,16 @@ public class InternalApiClient {
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
/**
* Marker propagated on every internal sub-step dispatch so the saas PAYG interceptor classifies
* the call as {@code BillingCategory.AUTOMATION}. By construction every {@link
* InternalApiClient#post} caller is an automation surface (pipeline executor, AI workflow,
* policy runner) running a child tool inside a parent automation flow — see the saas {@code
* PaygChargeInterceptor.determineCategory} precedence chain, where this header dominates any
* per-tool {@code @RequiresFeature} annotation.
*/
public static final String AUTOMATION_HEADER = "X-Stirling-Automation";
private final ServletContext servletContext;
private final UserServiceInterface userService;
private final TempFileManager tempFileManager;
@@ -96,6 +106,11 @@ public class InternalApiClient {
if (apiKey != null && !apiKey.isEmpty()) {
headers.add("X-API-KEY", apiKey);
}
// Tag the sub-step as automation so PAYG bills it under AUTOMATION regardless of which
// tool-level @RequiresFeature annotation the dispatched controller carries (e.g. an AI-OCR
// step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because
// every caller of this dispatcher is an automation surface by design.
headers.add(AUTOMATION_HEADER, "true");
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
@@ -59,6 +59,53 @@ class InternalApiClientTest {
servletContext, userService, tempFileManager, environment, applicationProperties);
}
@Test
void postTagsRequestAsAutomation() throws Exception {
// Every InternalApiClient.post() caller is a parent automation flow dispatching a child
// tool (pipeline executor, AI workflow, policy runner). Tagging the sub-step here means
// the saas PaygChargeInterceptor classifies it as BillingCategory.AUTOMATION regardless of
// the dispatched controller's @RequiresFeature — so an AI-OCR step inside a policy run
// bills as AUTOMATION, not AI. The header value is the literal string "true" because the
// interceptor compares case-insensitively-trimmed against that token.
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("fileInput", namedResource("input.pdf", "data"));
Path tempPath = Files.createTempFile("internal-api-automation-test", ".tmp");
TempFile tempFile = mock(TempFile.class);
when(tempFile.getPath()).thenReturn(tempPath);
when(tempFile.getFile()).thenReturn(tempPath.toFile());
when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile);
HttpHeaders[] captured = {null};
try (var ignored =
mockConstruction(
RestTemplate.class,
(rt, ctx) -> {
when(rt.httpEntityCallback(any(), eq(Resource.class)))
.thenAnswer(
inv -> {
HttpEntity<?> entity = inv.getArgument(0);
captured[0] = entity.getHeaders();
return (RequestCallback) req -> {};
});
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
.thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
})) {
InternalApiClient mockedClient = newClient();
mockedClient.post("/api/v1/general/merge-pdfs", body);
assertNotNull(captured[0]);
assertEquals(
"true",
captured[0].getFirst(InternalApiClient.AUTOMATION_HEADER),
"Sub-step dispatch must carry the automation marker header");
} finally {
Files.deleteIfExists(tempPath);
}
}
@Test
void postDoesNotForceContentType() throws Exception {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
@@ -390,6 +390,9 @@ mcp:
jwksUri: "" # JWKS URI. Blank -> derived from issuer's /.well-known/openid-configuration.
resourceId: "" # RFC 8707 resource identifier of THIS MCP server (e.g. http://localhost:8080/mcp).
# Required: tokens must list this id in `aud` or the request is rejected.
acceptedAudiences: [] # Extra `aud` values accepted on top of resourceId. Empty = strict RFC 8707.
# For IdPs that cannot mint resource audiences (Supabase OAuth server always
# issues aud=authenticated) list that audience here, e.g. ['authenticated'].
usernameClaim: sub # JWT claim matched against a Stirling username (e.g. 'sub', 'email', 'preferred_username')
requireExistingAccount: true # Reject tokens whose subject has no enabled Stirling account (recommended)
engineCapabilityRefreshMinutes: 5 # How often to refresh the AI capabilities manifest from the engine
@@ -1,6 +1,9 @@
package stirling.software.proprietary.mcp.security;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
@@ -8,20 +11,35 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
/**
* RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id in its
* {@code aud} claim. Fails closed when the resource id is unset.
* RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id (or one
* of the explicitly accepted additional audiences) in its {@code aud} claim. The additional list
* exists for IdPs that cannot mint resource-specific audiences - e.g. Supabase's OAuth server
* always issues {@code aud=authenticated}. Fails closed when nothing is configured.
*/
public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
private final String expectedResourceId;
private final Set<String> acceptedAudiences;
public McpAudienceValidator(String expectedResourceId) {
this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId;
this(expectedResourceId, List.of());
}
public McpAudienceValidator(String expectedResourceId, Collection<String> additionalAudiences) {
Set<String> accepted = new LinkedHashSet<>();
if (expectedResourceId != null && !expectedResourceId.isBlank()) {
accepted.add(expectedResourceId);
}
if (additionalAudiences != null) {
additionalAudiences.stream()
.filter(a -> a != null && !a.isBlank())
.forEach(accepted::add);
}
this.acceptedAudiences = accepted;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt token) {
if (expectedResourceId.isBlank()) {
if (acceptedAudiences.isEmpty()) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error(
"invalid_token",
@@ -30,12 +48,13 @@ public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
null));
}
List<String> aud = token.getAudience();
if (aud == null || !aud.contains(expectedResourceId)) {
if (aud == null || aud.stream().noneMatch(acceptedAudiences::contains)) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error(
"invalid_token",
"Token audience does not include this server's resource id ("
+ expectedResourceId
"Token audience does not include this server's resource id or an"
+ " accepted audience ("
+ String.join(", ", acceptedAudiences)
+ ").",
null));
}
@@ -157,7 +157,11 @@ public class McpSecurityConfig {
throws Exception {
String metadataPath = "/.well-known/oauth-protected-resource";
applyCors(http);
http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath)
// RFC 9728 section 3.1: clients derive the metadata URL by inserting the well-known
// segment before the resource path, so /mcp is discovered at {metadataPath}/mcp. Claim
// the subpaths too; otherwise they fall through to another filter chain whose default
// Spring Security metadata filter serves a document without authorization_servers.
http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath, metadataPath + "/**")
// CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server
// authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no
// session, no form submissions; CSRF requires browser-attached ambient credentials
@@ -168,7 +172,8 @@ public class McpSecurityConfig {
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
a ->
a.requestMatchers(HttpMethod.GET, metadataPath)
a.requestMatchers(
HttpMethod.GET, metadataPath, metadataPath + "/**")
.permitAll()
.anyRequest()
.authenticated())
@@ -187,7 +192,11 @@ public class McpSecurityConfig {
.oauth2ResourceServer(
oauth2 ->
oauth2.authenticationEntryPoint(
new McpAuthenticationEntryPoint(metadataPath))
// Advertise the path-inserted form; RFC 9728 makes
// it the canonical location for a resource with a
// path component.
new McpAuthenticationEntryPoint(
metadataPath + BASE_PATH))
// RFC 9728 protected-resource metadata for OAuth discovery.
.protectedResourceMetadata(
prm ->
@@ -236,7 +245,9 @@ public class McpSecurityConfig {
JwtValidators.createDefaultWithIssuer(auth.getIssuerUri());
OAuth2TokenValidator<Jwt> combined =
new DelegatingOAuth2TokenValidator<>(
defaultValidators, new McpAudienceValidator(auth.getResourceId()));
defaultValidators,
new McpAudienceValidator(
auth.getResourceId(), auth.getAcceptedAudiences()));
decoder.setJwtValidator(combined);
return decoder;
}
@@ -13,26 +13,18 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.model.Policy;
/**
* The single authority on which filesystem locations a policy may read from or write to. Folder
* sources and sinks take a configured directory, so without this a user who can save a policy could
* point one at Stirling's own config/secrets directory and exfiltrate (or overwrite) it. Every
* folder source and sink runs its directory through {@link #requirePermitted(Path)} at save time
* and again at run time.
* Authority on which filesystem locations a policy may read/write. Checked at save time and again
* at run time, fail-closed in order:
*
* <p>Enforced fail-closed, in order:
* <ol>
* <li>denied entirely under the {@code saas} profile;
* <li>Stirling's own config dir always rejected, even if an allowed root were misconfigured to
* contain it;
* <li>must resolve within {@code policies.allowedFolderRoots}; none configured means all denied.
* </ol>
*
* <ul>
* <li><b>Disabled in SaaS</b> - folder access is never allowed when the {@code saas} profile is
* active; a tenant must not reach the host filesystem at all.
* <li><b>Protected paths</b> - Stirling's own config directory (settings, database, keys,
* backups) is always rejected, even if an allowed root were misconfigured to contain it.
* <li><b>Allowlist</b> - the directory must resolve within one of {@code
* policies.allowedFolderRoots}; with none configured, all folder access is refused.
* </ul>
*
* <p>Paths are compared after normalisation, so {@code ..} segments cannot walk out of an allowed
* root. (Symlink escape is not defended here; an operator who configures an allowed root containing
* a symlink to a sensitive location is trusted.)
* <p>Compared after normalisation so {@code ..} cannot escape a root. Symlink escape is not
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
*/
@Component
public class FolderAccessGuard {
@@ -50,13 +42,7 @@ public class FolderAccessGuard {
this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath())));
}
/**
* Check that {@code dir} is a permitted folder location, returning its normalised absolute
* form.
*
* @throws IllegalArgumentException if folder access is disabled (SaaS or no roots configured),
* the path is inside a protected directory, or it falls outside every allowed root
*/
/** Returns the normalised absolute path; throws if not permitted. */
public Path requirePermitted(Path dir) {
if (saasActive) {
throw new IllegalArgumentException(
@@ -81,7 +67,7 @@ public class FolderAccessGuard {
return normalized;
}
/** Whether this policy reads from or writes to a folder, and so is subject to these rules. */
/** Whether this policy touches a folder source/sink, and so is subject to these rules. */
public boolean usesFolderAccess(Policy policy) {
boolean readsFolder =
policy.sources().stream().anyMatch(spec -> FOLDER_TYPE.equals(spec.type()));
@@ -0,0 +1,56 @@
package stirling.software.proprietary.policy.config;
import java.util.List;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.model.Policy;
/**
* Decides who may act on a stored {@link Policy}: its owner and global admins only, with no
* separate view/edit/run capability. Enforced only when login is enabled; single-user deployments
* pass every check. The owner is assigned server-side, never from client input.
*/
@Component
@RequiredArgsConstructor
public class PolicyAccessGuard {
private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties;
/** Owner for a new policy: the current user, or {@code null} when login is disabled. */
public String ownerForNewPolicy() {
return enforced() ? userService.getCurrentUsername() : null;
}
/** Whether the current user may view, edit, delete, or run the given stored policy. */
public boolean canAccess(Policy policy) {
if (!enforced() || userService.isCurrentUserAdmin()) {
return true;
}
String current = userService.getCurrentUsername();
return current != null && current.equals(policy.owner());
}
/**
* The subset of {@code policies} the current user may see (their own; everything for an admin).
*/
public List<Policy> visible(List<Policy> policies) {
if (!enforced() || userService.isCurrentUserAdmin()) {
return policies;
}
String current = userService.getCurrentUsername();
if (current == null) {
return List.of();
}
return policies.stream().filter(policy -> current.equals(policy.owner())).toList();
}
private boolean enforced() {
return applicationProperties.getSecurity().isEnableLogin();
}
}
@@ -0,0 +1,29 @@
package stirling.software.proprietary.policy.controller;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* A supporting file paired with the asset key a pipeline step references from its {@code
* fileParameters}. The same key may appear on more than one asset to supply multiple files.
*/
@Data
@Schema(description = "A supporting file bound to the asset key a pipeline step references")
public class NamedAsset {
@NotBlank
@Schema(
description = "Asset key referenced by a step's fileParameters",
example = "company-logo")
private String key;
@NotNull
@Schema(description = "The supporting file", format = "binary")
private MultipartFile file;
}
@@ -11,17 +11,16 @@ import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -30,6 +29,8 @@ import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -39,6 +40,7 @@ import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
import stirling.software.proprietary.policy.engine.PolicyRunner;
@@ -53,17 +55,9 @@ import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* Manages policies and runs pipelines. The premium backend entry point: CRUD for stored {@code
* Policy} objects, running a stored policy by id, and running an ad-hoc pipeline (for AI/Automate
* one-offs).
*
* <p>Runs execute asynchronously and return a run id immediately. Poll {@code GET /run/{runId}} for
* status, and download outputs via the existing {@code GET /api/v1/general/files/{fileId}} using
* the file ids in the run view.
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
* GET /run/{runId}} for status, download outputs via {@code GET /api/v1/general/files/{fileId}}.
*/
@Slf4j
@RestController
@@ -79,9 +73,9 @@ public class PolicyController {
private final PolicyStore policyStore;
private final PolicyValidator policyValidator;
private final FolderAccessGuard folderAccessGuard;
private final PolicyAccessGuard policyAccessGuard;
private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final TempFileManager tempFileManager;
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -89,15 +83,16 @@ public class PolicyController {
summary = "Run a tool pipeline",
description =
"Accepts the documents to process (multipart field 'fileInput'), any supporting"
+ " files (each under a multipart field named as its asset key, e.g."
+ " 'company-logo'), and a JSON pipeline definition ('json'). Runs the"
+ " steps in order asynchronously and returns a run id. Poll the run"
+ " status endpoint and download outputs via /api/v1/general/files/{id}.")
+ " files (under 'assets[i].key' / 'assets[i].file'), and the pipeline"
+ " definition as an application/json part named 'json'. Runs the steps"
+ " in order asynchronously and returns a run id. Poll the run status"
+ " endpoint and download outputs via /api/v1/general/files/{id}.")
public ResponseEntity<JobResponse<Void>> run(
@RequestParam("json") String json, MultipartHttpServletRequest request)
@RequestPart("json") PipelineDefinition definition,
@Valid @ModelAttribute PolicyRunFiles files)
throws IOException {
PipelineDefinition definition = parseDefinition(json);
PolicyInputs inputs = collectInputs(request);
requireRunnable(definition);
PolicyInputs inputs = toInputs(files);
String runId =
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId();
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
@@ -111,18 +106,19 @@ public class PolicyController {
+ " starts and completes, then a terminal 'completed', 'failed',"
+ " 'cancelled', or 'waiting' event carrying the final run view.")
public SseEmitter runStream(
@RequestParam("json") String json, MultipartHttpServletRequest request)
@RequestPart("json") PipelineDefinition definition,
@Valid @ModelAttribute PolicyRunFiles files)
throws IOException {
PipelineDefinition definition = parseDefinition(json);
PolicyInputs inputs = collectInputs(request);
requireRunnable(definition);
PolicyInputs inputs = toInputs(files);
SseEmitter emitter =
new SseEmitter(applicationProperties.getPolicies().getStreamTimeoutMs());
emitter.onError(e -> log.warn("Policy run SSE emitter error", e));
PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter));
// Close the stream with a terminal event once the run finishes. whenComplete runs on the
// engine's worker thread after the run is done, so this never races the step events.
// whenComplete runs on the worker thread after the run finishes, so the terminal event
// never races the step events.
handle.completion()
.whenComplete(
(run, throwable) -> {
@@ -159,22 +155,52 @@ public class PolicyController {
description =
"Stores a policy (trigger config + steps + output + metadata). A blank id is"
+ " assigned; returns the stored policy with its id.")
public ResponseEntity<Policy> savePolicy(@RequestBody String json) {
Policy policy = parsePolicy(json);
requireAuthorizedForFolderAccess(policy);
public ResponseEntity<Policy> savePolicy(@RequestBody Policy policy) {
Policy owned = resolveOwnership(policy);
requireAuthorizedForFolderAccess(owned);
try {
policyValidator.validate(policy);
policyValidator.validate(owned);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
return ResponseEntity.ok(policyStore.save(policy));
return ResponseEntity.ok(policyStore.save(owned));
}
/**
* A policy that reads from or writes to a server folder grants whoever saves it access to that
* path, so restrict it to administrators on multi-user deployments. Single-user deployments
* (login disabled, e.g. desktop) trust the local operator. The {@link FolderAccessGuard} still
* enforces SaaS-off and the path allowlist during validation regardless of who saves.
* Assign the owner: create stamps the current user; update preserves the existing owner after
* an access check. So the client can neither forge ownership on create nor reassign it on
* update.
*/
private Policy resolveOwnership(Policy incoming) {
String id = incoming.id();
if (id != null && !id.isBlank()) {
Policy existing = policyStore.get(id).orElse(null);
if (existing != null) {
if (!policyAccessGuard.canAccess(existing)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No policy: " + id);
}
return withOwner(incoming, existing.owner());
}
}
return withOwner(incoming, policyAccessGuard.ownerForNewPolicy());
}
private static Policy withOwner(Policy policy, String owner) {
return new Policy(
policy.id(),
policy.name(),
owner,
policy.enabled(),
policy.trigger(),
policy.sources(),
policy.steps(),
policy.output());
}
/**
* Folder sources/outputs grant whoever saves the policy access to that path, so gate them to
* admins on multi-user deployments. Single-user (login disabled) trusts the local operator;
* {@link FolderAccessGuard} still enforces SaaS-off and the path allowlist at validation time.
*/
private void requireAuthorizedForFolderAccess(Policy policy) {
if (!folderAccessGuard.usesFolderAccess(policy)) {
@@ -191,9 +217,11 @@ public class PolicyController {
}
@GetMapping
@Operation(summary = "List policies")
@Operation(
summary = "List policies",
description = "Lists the caller's policies; admins see all.")
public List<Policy> listPolicies() {
return policyStore.all();
return policyAccessGuard.visible(policyStore.all());
}
@GetMapping("/{policyId}")
@@ -201,6 +229,7 @@ public class PolicyController {
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
return policyStore
.get(policyId)
.filter(policyAccessGuard::canAccess)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@@ -208,9 +237,12 @@ public class PolicyController {
@DeleteMapping("/{policyId}")
@Operation(summary = "Delete a policy by id")
public ResponseEntity<Void> deletePolicy(@PathVariable String policyId) {
return policyStore.delete(policyId)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
boolean accessible =
policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent();
if (accessible && policyStore.delete(policyId)) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
@PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -218,70 +250,52 @@ public class PolicyController {
summary = "Run a stored policy",
description =
"Runs the stored policy's pipeline on the supplied files (primary documents"
+ " under 'fileInput', supporting files under their asset-key fields)."
+ " Runs regardless of the policy's enabled flag, which only gates"
+ " automatic triggering. Returns a run id.")
+ " under 'fileInput', supporting files under 'assets[i].key' /"
+ " 'assets[i].file'). Runs regardless of the policy's enabled flag,"
+ " which only gates automatic triggering. Returns a run id.")
public ResponseEntity<JobResponse<Void>> runStoredPolicy(
@PathVariable String policyId, MultipartHttpServletRequest request) throws IOException {
@PathVariable String policyId, @Valid @ModelAttribute PolicyRunFiles files)
throws IOException {
Policy policy =
policyStore
.get(policyId)
.filter(policyAccessGuard::canAccess)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND, "No policy: " + policyId));
PolicyInputs inputs = collectInputs(request);
PolicyInputs inputs = toInputs(files);
String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId();
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
}
private Policy parsePolicy(String json) {
try {
return objectMapper.readValue(json, Policy.class);
} catch (JacksonException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid policy JSON");
}
}
private PipelineDefinition parseDefinition(String json) {
PipelineDefinition definition;
try {
definition = objectMapper.readValue(json, PipelineDefinition.class);
} catch (JacksonException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid pipeline definition JSON");
}
private static void requireRunnable(PipelineDefinition definition) {
if (definition.steps().isEmpty()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Pipeline definition has no steps");
}
return definition;
}
/**
* Split the multipart file parts into the primary document stream ("fileInput") and the named
* supporting-file store: every other file field becomes an asset keyed by its field name, which
* a step references from {@code fileParameters}.
* Turn the typed run files into engine {@link PolicyInputs}: the primary documents plus the
* named supporting-file store, where each asset's {@code key} is the name a step references
* from its {@code fileParameters}. Assets sharing a key are grouped, so a key may carry several
* files.
*/
private PolicyInputs collectInputs(MultipartHttpServletRequest request) throws IOException {
MultiValueMap<String, MultipartFile> fileMap = request.getMultiFileMap();
List<Resource> primary = toResources(fileMap.get("fileInput"));
private PolicyInputs toInputs(PolicyRunFiles files) throws IOException {
List<Resource> primary = toResources(files.getFileInput());
Map<String, List<Resource>> supportingFiles = new LinkedHashMap<>();
for (Map.Entry<String, List<MultipartFile>> entry : fileMap.entrySet()) {
if ("fileInput".equals(entry.getKey())) {
continue;
}
List<Resource> assets = toResources(entry.getValue());
if (!assets.isEmpty()) {
supportingFiles.put(entry.getKey(), assets);
for (NamedAsset asset : files.getAssets()) {
Resource resource = toResource(asset.getFile());
if (resource != null) {
supportingFiles
.computeIfAbsent(asset.getKey(), key -> new ArrayList<>())
.add(resource);
}
}
return new PolicyInputs(primary, supportingFiles);
}
/**
* A progress listener that forwards each step transition to the SSE stream as a "step" event.
*/
private PolicyProgressListener streamListener(SseEmitter emitter) {
return new PolicyProgressListener() {
@Override
@@ -320,8 +334,8 @@ public class PolicyController {
try {
emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON));
} catch (IOException | IllegalStateException e) {
// Client disconnected or the emitter already closed. The run continues and its results
// remain downloadable via the job endpoints; nothing useful left to stream.
// Client gone or emitter closed. The run continues and outputs stay downloadable via
// the job endpoints.
log.debug("Dropping policy SSE event '{}': {}", name, e.getMessage());
}
}
@@ -332,20 +346,27 @@ public class PolicyController {
return resources;
}
for (MultipartFile file : files) {
if (file == null || file.isEmpty()) {
continue;
Resource resource = toResource(file);
if (resource != null) {
resources.add(resource);
}
TempFile tempFile = tempFileManager.createManagedTempFile("policy-run");
file.transferTo(tempFile.getPath());
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
resources.add(
new FileSystemResource(tempFile.getFile()) {
@Override
public String getFilename() {
return originalName;
}
});
}
return resources;
}
/** Spool a single uploaded file to a managed temp file, preserving its name; null if empty. */
private Resource toResource(MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
return null;
}
TempFile tempFile = tempFileManager.createManagedTempFile("policy-run");
file.transferTo(tempFile.getPath());
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
return new FileSystemResource(tempFile.getFile()) {
@Override
public String getFilename() {
return originalName;
}
};
}
}
@@ -0,0 +1,32 @@
package stirling.software.proprietary.policy.controller;
import java.util.ArrayList;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import lombok.Data;
/**
* The files supplied to a policy run: the primary documents and any keyed supporting assets. Bound
* from the multipart request via {@code @ModelAttribute}; the pipeline definition itself travels as
* a separate typed {@code json} part.
*
* <p>Wire form: {@code fileInput} (repeated) for primaries, and {@code assets[i].key} / {@code
* assets[i].file} for each supporting asset.
*/
@Data
@Schema(description = "Files for a policy run: primary documents plus keyed supporting assets")
public class PolicyRunFiles {
@Schema(description = "Primary input documents", format = "binary")
private List<MultipartFile> fileInput = new ArrayList<>();
@Valid
@Schema(description = "Supporting files, each bound to the asset key its step references")
private List<NamedAsset> assets = new ArrayList<>();
}
@@ -8,9 +8,13 @@ import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import org.slf4j.MDC;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientResponseException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -33,31 +37,24 @@ import stirling.software.proprietary.policy.output.PolicyOutputSink;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
/**
* Runs pipelines asynchronously as tracked jobs.
* Runs pipelines asynchronously as tracked jobs. {@link #submit} returns a run id immediately; the
* pipeline runs on a virtual thread (so a step blocked on a slow tool does not hold a platform
* thread). Drives {@link PolicyExecutor} for the step loop, projects status/outputs into {@link
* TaskManager} (existing job endpoints work unchanged), and keeps live state in {@link
* PolicyRunRegistry}.
*
* <p>Each run is the unit of async work: {@link #submit} returns a run id immediately and the
* pipeline executes on a virtual thread, so a step blocking on a slow tool does not tie up a
* platform thread. The run drives {@link PolicyExecutor} for the actual step loop, registers its
* outputs and progress with {@link TaskManager} (so the existing job status/download endpoints work
* unchanged), and keeps rich state in {@link PolicyRunRegistry}.
*
* <p>The engine deliberately manages its own virtual-thread execution rather than routing through
* {@code JobExecutorService}: that path force-completes a job once its work returns, which is
* incompatible with a run that suspends in {@code WAITING_FOR_INPUT}. It still applies the shared
* {@link ResourceMonitor}/{@link JobQueue} admission control, so heavy runs queue under load
* instead of oversubscribing.
* <p>Manages its own virtual-thread execution rather than {@code JobExecutorService}, which
* force-completes a job once its work returns: incompatible with a run that suspends in {@code
* WAITING_FOR_INPUT}. Still applies the shared {@link ResourceMonitor}/{@link JobQueue} admission
* control so heavy runs queue under load.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PolicyEngine {
/**
* Resource weight of a pipeline run for admission control. A run chains many tools and holds
* intermediate files, so it is weighted as heavy work: the shared {@link ResourceMonitor}
* should let it start while the system is healthy but hold it back under memory/CPU pressure.
* See {@link ResourceMonitor#shouldQueueJob(int)} for how a weight maps to that decision.
*/
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
// files. See ResourceMonitor#shouldQueueJob(int).
private static final int RUN_RESOURCE_WEIGHT = 50;
private final PolicyExecutor stepExecutor;
@@ -72,27 +69,52 @@ public class PolicyEngine {
private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor();
/**
* Submit a pipeline to run asynchronously. The returned handle's run id scopes a job in {@link
* TaskManager}, so progress (notes), status, and result files are observable via the existing
* job endpoints as well as via {@link #getRun(String)}; its completion future resolves when the
* run reaches a terminal or paused state.
* Submit a pipeline to run asynchronously. The handle's run id scopes a {@link TaskManager} job
* (status/notes/results observable via the job endpoints); its future resolves when the run
* reaches a terminal or paused state.
*/
public PolicyRunHandle submit(
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
// Scope the run id to the current user (on this request thread) so the file-download
// ownership check passes; NoOpJobOwnershipService returns the id unchanged when security
// is off.
// Ad-hoc run (no stored policy): bill whoever kicked it off. Capture the principal on this
// (request) thread — it does not survive the hop onto the async worker.
return submitForPrincipal(currentActingPrincipal(), definition, inputs, listener);
}
/** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */
public PolicyRunHandle runPolicy(
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
// Bill the policy owner. Trigger-fired runs have no security context at all, and even an
// on-demand run executes on a background worker that doesn't inherit the caller's context —
// so the owner (a username stamped at policy creation) is the reliable billing identity.
return submitForPrincipal(policy.owner(), policy.toDefinition(), inputs, listener);
}
private PolicyRunHandle submitForPrincipal(
String actingPrincipal,
PipelineDefinition definition,
PolicyInputs inputs,
PolicyProgressListener listener) {
// Scope the run id to the current user (this request thread) so the file-download
// ownership check passes. No-op when security is off.
String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString());
taskManager.createTask(runId);
PolicyRun run = new PolicyRun(runId, definition);
registry.register(run);
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
PolicyProgressListener tracking = trackingListener(runId, run, listener);
Runnable task = () -> runToCompletion(run, inputs, tracking, completion);
// Re-establish the acting principal as the audit principal on the worker thread. Each tool
// step dispatches via InternalApiClient, which resolves the caller from
// UserService.getCurrentUsername() — that has an MDC `auditPrincipal` fallback for async
// threads. Without this the worker has no identity, tool calls fall back to the
// INTERNAL_API_USER, and PAYG charges that system account instead of the owner's team.
Runnable task =
() ->
runAsPrincipal(
actingPrincipal,
() -> runToCompletion(run, inputs, tracking, completion));
// Each run is one admission unit; steps run synchronously within it, so this gates heavy
// work under load without the pool-within-pool risk of queueing each tool call. Under
// resource pressure the run waits in the shared JobQueue; otherwise it starts immediately.
// One admission unit per run; steps run synchronously within it, so this gates heavy work
// without the pool-within-pool risk of queueing each tool call.
if (resourceMonitor.shouldQueueJob(RUN_RESOURCE_WEIGHT)) {
log.debug("Queueing policy run {} under resource pressure", runId);
jobQueue.queueJob(
@@ -110,22 +132,12 @@ public class PolicyEngine {
return new PolicyRunHandle(runId, completion);
}
/**
* Run a stored policy on demand. Builds the policy's pipeline and submits it. {@code enabled}
* gates automatic triggering, not explicit runs, so this runs regardless of that flag.
*/
public PolicyRunHandle runPolicy(
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
return submit(policy.toDefinition(), inputs, listener);
}
public PolicyRun getRun(String runId) {
return registry.get(runId);
}
/**
* Request cancellation of a run. Stage 1 marks the run cancelled in the registry if it has not
* already finished; interrupting an in-flight tool call lands in a later stage.
* Mark a run cancelled if not already finished. Does not yet interrupt an in-flight tool call.
*/
public boolean cancel(String runId) {
PolicyRun run = registry.get(runId);
@@ -139,10 +151,7 @@ public class PolicyEngine {
return cancelled;
}
/**
* Resume a run paused in {@code WAITING_FOR_INPUT}. Not yet implemented; the run shape and
* {@link WaitState} snapshot are in place so this can be added without reworking the engine.
*/
/** Resume a run paused in {@code WAITING_FOR_INPUT}. Not yet implemented. */
public String resume(String runId, List<Resource> additionalInputs) {
throw new UnsupportedOperationException("Pause/resume is not yet implemented");
}
@@ -163,8 +172,8 @@ public class PolicyEngine {
taskManager.setComplete(runId);
run.complete(outputs);
} catch (PolicyInputRequiredException e) {
// Designed-for path: suspend the run rather than fail it. Persist intermediates as
// fileIds so the run can resume after this worker thread is gone.
// Expected path: suspend rather than fail. Persist intermediates as fileIds so the run
// can resume after this worker thread is gone.
WaitState wait = suspend(e);
run.waitForInput(wait);
taskManager.addNote(runId, "Waiting for input: " + e.getMessage());
@@ -177,21 +186,41 @@ public class PolicyEngine {
e.getMessage());
run.fail(message);
taskManager.setError(runId, message);
} catch (RestClientResponseException e) {
// A downstream tool call returned an error status. When it's a structured entitlement
// response (401/402 with a JSON `error` sentinel), surface that code onto the run so
// the
// client can react — e.g. pop the usage-limit modal — instead of only seeing a generic
// failure. We don't interpret the code here (that would couple this module to the saas
// billing layer); we just pass it through for the client to map. Other statuses fall
// through to the generic failure below.
String code = extractDownstreamErrorCode(e);
if (code != null) {
log.info("Policy run {} blocked by downstream entitlement gate ({})", runId, code);
String message = "Usage limit reached";
run.failWithCode(message, code, extractDownstreamSubscribed(e));
taskManager.setError(runId, message);
} else {
String message = "Policy run failed: " + e.getMessage();
log.error("Policy run {} failed (downstream HTTP error)", runId, e);
run.fail(message);
taskManager.setError(runId, message);
}
} catch (Exception e) {
String message = "Policy run failed: " + e.getMessage();
log.error("Policy run {} failed", runId, e);
run.fail(message);
taskManager.setError(runId, message);
} finally {
// Always resolve the handle with the run's final state so stream/await callers unblock.
// Always resolve so stream/await callers unblock.
completion.complete(run);
}
}
private ResponseEntity<?> failRejectedRun(
PolicyRun run, CompletableFuture<PolicyRun> completion, Throwable ex) {
// Only reached if the run never started (e.g. the queue was full). A run that started
// always resolves its own completion in runToCompletion.
// Only reached if the run never started (e.g. queue full); a started run resolves its own
// completion in runToCompletion.
if (!completion.isDone()) {
String message = "Policy run could not be queued: " + ex.getMessage();
log.error("Policy run {} was not admitted: {}", run.getRunId(), ex.getMessage());
@@ -258,4 +287,95 @@ public class PolicyEngine {
"The %s tool did not respond within %d seconds and was aborted.",
e.getEndpointPath(), e.getReadTimeout().toSeconds());
}
/** Matches the {@code "error":"CODE"} field of a small JSON error body. */
private static final java.util.regex.Pattern ERROR_CODE_FIELD =
java.util.regex.Pattern.compile("\"error\"\\s*:\\s*\"([^\"]+)\"");
/**
* Pull the {@code error} sentinel out of a downstream 401/402 JSON body — e.g. the saas
* EntitlementGuard's {@code {"error":"PAYG_LIMIT_REACHED",...}}. Regex (not a JSON parse) on
* purpose: the body is a small, server-controlled shape and this keeps the proprietary module
* free of any billing-layer coupling. Returns null for other statuses or an unmatched body, in
* which case the caller treats it as a generic failure.
*/
private static String extractDownstreamErrorCode(RestClientResponseException e) {
int status = e.getStatusCode().value();
if (status != 401 && status != 402) {
return null;
}
String body = e.getResponseBodyAsString();
if (body == null || body.isBlank()) {
return null;
}
java.util.regex.Matcher m = ERROR_CODE_FIELD.matcher(body);
return m.find() ? m.group(1) : null;
}
/** Matches the {@code "subscribed":true|false} field of a small JSON error body. */
private static final java.util.regex.Pattern SUBSCRIBED_FIELD =
java.util.regex.Pattern.compile("\"subscribed\"\\s*:\\s*(true|false)");
/**
* Pull the {@code subscribed} flag out of a downstream 401/402 JSON body (present on the saas
* {@code PAYG_LIMIT_REACHED} response). Null when absent — the client then defaults to the
* free-limit modal. Regex for the same dependency-free reason as {@link
* #extractDownstreamErrorCode}.
*/
private static Boolean extractDownstreamSubscribed(RestClientResponseException e) {
String body = e.getResponseBodyAsString();
if (body == null || body.isBlank()) {
return null;
}
java.util.regex.Matcher m = SUBSCRIBED_FIELD.matcher(body);
return m.find() ? Boolean.valueOf(m.group(1)) : null;
}
/**
* MDC key {@code UserService.getCurrentUsername()} reads as its async fallback (stamped by the
* controller audit aspect on request threads). We reuse it to carry the billing identity onto
* the policy worker thread.
*/
private static final String AUDIT_PRINCIPAL_MDC_KEY = "auditPrincipal";
/**
* The username to bill an ad-hoc run to, captured on the submitting (request) thread. Prefers
* the audit principal the controller aspect already stamped; falls back to the security context
* name. {@code anonymousUser} (and no identity) resolve to null so we don't try to bill it.
*/
private static String currentActingPrincipal() {
String mdc = MDC.get(AUDIT_PRINCIPAL_MDC_KEY);
if (mdc != null && !mdc.isBlank()) {
return mdc;
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) {
return null;
}
String name = auth.getName();
return "anonymousUser".equals(name) ? null : name;
}
/**
* Run {@code body} with {@code principal} set as the audit principal in MDC, so async tool
* dispatch attributes (and charges) usage to that user. A null/blank principal runs as-is.
* Restores the previous MDC value afterward (defensive — worker threads aren't pooled).
*/
private static void runAsPrincipal(String principal, Runnable body) {
if (principal == null || principal.isBlank()) {
body.run();
return;
}
String previous = MDC.get(AUDIT_PRINCIPAL_MDC_KEY);
MDC.put(AUDIT_PRINCIPAL_MDC_KEY, principal);
try {
body.run();
} finally {
if (previous != null) {
MDC.put(AUDIT_PRINCIPAL_MDC_KEY, previous);
} else {
MDC.remove(AUDIT_PRINCIPAL_MDC_KEY);
}
}
}
}
@@ -7,11 +7,8 @@ import org.springframework.core.io.Resource;
import tools.jackson.databind.JsonNode;
/**
* Result of running a pipeline through {@link PolicyExecutor}.
*
* <p>{@code files} are the final output resources (temp files, not yet stored to {@code
* FileStorage}). {@code report} is the structured metadata payload captured from the last step that
* produced one (a JSON body, or an {@code X-Stirling-Tool-Report} header), with {@code reportTool}
* naming the step it came from; both are null when no step produced a report.
* Result of a {@link PolicyExecutor} run. {@code files} are final temp files (not yet stored).
* {@code report}/{@code reportTool} carry the last step's structured report and its operation, or
* null if no step produced one.
*/
public record PolicyExecutionResult(List<Resource> files, JsonNode report, String reportTool) {}
@@ -35,15 +35,11 @@ import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Runs an ordered chain of tool steps, chaining each step's output files into the next step's
* input.
* Runs an ordered chain of tool steps, feeding each step's output files into the next.
*
* <p>This is the single execution loop for the proprietary surface (AI plans now;
* manually-triggered runs and watched folders later). Each step is dispatched synchronously via
* {@link InternalApiClient} loopback HTTP: the tool runs in its own handler and returns its file
* inline. The caller decides how to run the executor itself (the AI turn loop calls it directly;
* the engine runs it on a virtual thread for async runs). Files cross step boundaries as {@link
* Resource} temp files; they are only persisted to durable storage at the run boundaries by the
* <p>Steps dispatch synchronously via {@link InternalApiClient} loopback HTTP (each tool runs in
* its own handler, returns its file inline). The caller controls threading. Files cross step
* boundaries as {@link Resource} temp files and are only persisted at the run boundaries by the
* caller.
*/
@Slf4j
@@ -58,25 +54,16 @@ public class PolicyExecutor {
private final TempFileManager tempFileManager;
private final ObjectMapper objectMapper;
/**
* Internal value-class for tool responses. {@code files} holds any result files (typically one;
* multiple for ZIP-response tools). {@code report} holds an optional structured metadata
* payload the tool chose to surface alongside (or instead of) a file.
*/
// files: result files (one, or many for ZIP-response tools). report: optional structured
// payload the tool surfaced alongside or instead of a file.
private record ToolResult(List<Resource> files, JsonNode report) {}
/**
* Execute every step in {@code definition} in order, feeding each step's output into the next.
* Supporting files supplied in {@code inputs} are bound to steps' named file fields and never
* enter the document stream.
* Run every step in order, feeding each step's output into the next. Supporting files in {@code
* inputs} bind to named file fields and never enter the document stream.
*
* @param definition the pipeline to run (must have at least one step)
* @param inputs the primary documents plus the named supporting-file store
* @param listener receives per-step progress
* @return the final output files plus the last structured report produced, if any
* @throws InternalApiTimeoutException if a tool does not respond within its read timeout
* @throws IOException if a tool returns a non-OK response, references a missing supporting
* file, or a file cannot be read
* @throws IOException on a non-OK tool response, a missing supporting file, or a read failure
*/
public PolicyExecutionResult execute(
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener)
@@ -88,7 +75,7 @@ public class PolicyExecutor {
List<Resource> currentFiles = inputs.primary();
Map<String, List<Resource>> supportingFiles = inputs.supportingFiles();
// Propagate the *last* non-null report; the terminal step defines the output.
// Last non-null report wins: the terminal step defines the output.
JsonNode lastReport = null;
String lastReportTool = null;
@@ -113,13 +100,9 @@ public class PolicyExecutor {
}
/**
* Execute a single tool step. If the endpoint accepts multiple files, all files are sent in one
* call. Otherwise, the endpoint is called once per file. ZIP responses are unpacked so each
* inner file is treated as its own result (e.g. split outputs a ZIP of pages).
*
* <p>A structured {@code report} may be returned alongside (or instead of) files; see {@link
* ToolResult}. For per-file dispatch (single-input endpoints called once per input), the first
* non-null report wins.
* Multi-input endpoints get all files in one call; others are called once per file. ZIP
* responses are unpacked so each inner file is its own result (e.g. split). For per-file
* dispatch the first non-null report wins.
*/
private ToolResult executeStep(
PipelineStep step,
@@ -146,17 +129,10 @@ public class PolicyExecutor {
}
/**
* Call an endpoint and return its result files and optional report.
*
* <ul>
* <li>JSON body (Content-Type: application/json): the entire body is the report, no files are
* returned.
* <li>File body (PDF etc.): the file is returned; if an {@link
* AiToolResponseHeaders#TOOL_REPORT} header is present, its (minified JSON) value is
* parsed as the report.
* <li>ZIP responses declared by the tool metadata service are unpacked so callers always see
* a flat list of result files.
* </ul>
* Call an endpoint, returning result files and optional report. Response handling: JSON body is
* the report with no file; a file body returns the file plus any {@link
* AiToolResponseHeaders#TOOL_REPORT} header report; ZIP responses (per tool metadata) are
* unpacked to a flat file list.
*/
private ToolResult callEndpoint(
PipelineStep step, List<Resource> files, Map<String, List<Resource>> supportingFiles)
@@ -166,8 +142,8 @@ public class PolicyExecutor {
for (Resource file : files) {
body.add("fileInput", file);
}
// Bind supporting files to their named tool fields (e.g. stampImage, overlayFiles). These
// come from the run's named asset store, not the document stream.
// Bind supporting files to named tool fields (e.g. stampImage); from the asset store, not
// the document stream.
for (Map.Entry<String, String> binding : step.fileParameters().entrySet()) {
String fieldName = binding.getKey();
String assetKey = binding.getValue();
@@ -189,9 +165,9 @@ public class PolicyExecutor {
for (Map.Entry<String, Object> entry : step.parameters().entrySet()) {
if (entry.getValue() instanceof List<?> list) {
if (containsStructuredElements(list)) {
// Endpoints binding lists of structured objects (e.g. /security/redact's
// redactions, /general/edit-text's edits) parse a single JSON string field via
// a property editor. Pre-serialize the whole list so binding succeeds.
// These endpoints (e.g. /security/redact redactions, /general/edit-text edits)
// bind a list of structured objects from a single JSON string field via a
// property editor, so pre-serialize the whole list.
body.add(entry.getKey(), objectMapper.writeValueAsString(list));
} else {
for (Object item : list) {
@@ -209,8 +185,8 @@ public class PolicyExecutor {
}
Resource resource = response.getBody();
// Filter operations return an empty body to signal the file was filtered out: drop it
// rather than forwarding a zero-byte document.
// Filter ops return an empty body to mean "filtered out": drop it rather than forward a
// zero-byte document.
if (isFilterOperation(endpointPath) && isEmpty(resource)) {
return new ToolResult(List.of(), null);
}
@@ -218,7 +194,7 @@ public class PolicyExecutor {
HttpHeaders headers = response.getHeaders();
MediaType contentType = headers.getContentType();
// JSON-only response: the whole body is the structured report, no result file.
// JSON-only response: whole body is the report, no file.
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
try (InputStream is = resource.getInputStream()) {
JsonNode report = objectMapper.readTree(is);
@@ -233,10 +209,7 @@ public class PolicyExecutor {
return new ToolResult(List.of(resource), report);
}
/**
* Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header into a {@link JsonNode},
* or return null.
*/
/** Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header, or null. */
private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) {
String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT);
if (raw == null || raw.isBlank()) {
@@ -264,8 +237,7 @@ public class PolicyExecutor {
}
/**
* Fail the run if any document in the primary stream is not a file type the step accepts. An
* endpoint that declares no specific input type accepts anything.
* Fail if any primary-stream file is a type the step rejects. No declared type means anything.
*/
private void requireAcceptedTypes(String operation, List<Resource> files) throws IOException {
List<String> accepted = toolMetadataService.getExtensionTypes(false, operation);
@@ -7,15 +7,9 @@ import org.springframework.core.io.Resource;
import lombok.Getter;
/**
* Thrown by a step to signal that the run cannot proceed without further user input, pausing the
* run in {@code WAITING_FOR_INPUT} rather than failing it.
*
* <p>Carries everything needed to resume: a human-readable reason, the 0-based index of the step to
* resume from, and the intermediate files produced so far. The engine persists those files and
* suspends the run.
*
* <p>Defined now to fix the run shape; no step throws it yet, and the resume handshake is
* implemented in a later stage.
* Thrown by a step that needs further user input, pausing the run in {@code WAITING_FOR_INPUT}
* instead of failing. Carries the resume reason, 0-based resume step index, and intermediate files;
* the engine persists those and suspends. Not yet thrown by any step.
*/
@Getter
public class PolicyInputRequiredException extends RuntimeException {
@@ -5,12 +5,9 @@ import java.util.concurrent.CompletableFuture;
import stirling.software.proprietary.policy.model.PolicyRun;
/**
* Returned by {@link PolicyEngine#submit}: the run id (for status polling and result download) plus
* a future that resolves when the run reaches a terminal or paused state.
*
* <p>The completion future lets callers react to the end of a run (e.g. an SSE endpoint sending a
* final event and closing the stream) without polling. It carries the {@link PolicyRun} whose
* status describes the outcome (completed, failed, cancelled, or waiting for input); it does not
* complete exceptionally for ordinary run failures.
* Returned by {@link PolicyEngine#submit}: the run id (status polling, result download) plus a
* future that resolves when the run reaches a terminal or paused state. The future carries the
* {@link PolicyRun} whose status describes the outcome; it does not complete exceptionally for
* ordinary run failures.
*/
public record PolicyRunHandle(String runId, CompletableFuture<PolicyRun> completion) {}
@@ -19,16 +19,12 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.model.PolicyRun;
/**
* In-memory store of live {@link PolicyRun} state, keyed by runId. Holds the authoritative run
* state machine; durable status/files for download are projected separately into {@code
* TaskManager}.
* In-memory store of live {@link PolicyRun} state, keyed by runId. Authoritative run state machine;
* durable status/files are projected separately into {@code TaskManager}.
*
* <p>Finished runs are evicted on a fixed interval once they age past {@code
* policies.runExpiryMinutes}, mirroring the job-result expiry in {@code TaskManager} so a run's
* rich in-memory state does not outlive the process. Only terminal runs are evicted; active and
* paused ({@code WAITING_FOR_INPUT}) runs are retained regardless of age. Result files are not
* touched here: a run shares its runId with a {@code TaskManager} job, which owns file-lifecycle
* cleanup, so eviction only frees this map's entry.
* <p>A scheduled sweep evicts only terminal runs aged past {@code policies.runExpiryMinutes};
* active and paused runs are kept regardless of age. Eviction frees only this map's entry: the
* shared {@code TaskManager} job owns file-lifecycle cleanup.
*/
@Slf4j
@Service
@@ -61,7 +57,7 @@ public class PolicyRunRegistry {
return runs.values();
}
/** Scheduled hook: evict terminal runs that finished before the expiry window. */
/** Scheduled sweep entry point. */
private void evictExpiredRuns() {
try {
evictExpired(Instant.now().minus(runExpiry));
@@ -71,9 +67,8 @@ public class PolicyRunRegistry {
}
/**
* Remove every terminal run last updated before {@code cutoff}; active and paused runs are kept
* regardless of age. Returns the number evicted. Package-visible so the scheduled sweep and
* tests exercise the same path with an explicit cutoff.
* Evict terminal runs last updated before {@code cutoff}, returning the count. Package-visible
* so the sweep and tests share one path with an explicit cutoff.
*/
int evictExpired(Instant cutoff) {
int removed = 0;
@@ -20,14 +20,8 @@ import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
/**
* Runs policies, and is the one place that knows how to turn a policy's configured {@link InputSpec
* sources} into actual runs. Triggers (schedule, and future webhook/folder-watch) decide
* <em>when</em> to run and call {@link #run(Policy)}; they never touch sources themselves. The
* controller uses the supplied-input and ad-hoc entry points for on-demand work.
*
* <p>This is the seam that keeps triggers and sources independent: a trigger depends on the runner,
* the runner depends on the {@link InputSource} beans, and a source depends on neither - it just
* yields {@link ResolvedInput units of work}, each carrying its own completion hook.
* Turns a policy's configured {@link InputSpec sources} into runs. Triggers decide <em>when</em>
* and call {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points.
*/
@Slf4j
@Service
@@ -38,10 +32,9 @@ public class PolicyRunner {
private final List<InputSource> inputSources;
/**
* Run a policy by pulling from every source it configures: each source yields zero or more
* units of work, and each unit becomes its own run so one failure does not affect the others. A
* policy with no sources runs once with no input files (a generator pipeline). Used by
* automatic triggers.
* Trigger entry point. Pulls every configured source; each yielded unit becomes its own run so
* one failure does not affect the others. No sources means one run with no input (generator
* pipeline).
*/
public void run(Policy policy) {
List<InputSpec> sources = policy.sources();
@@ -54,11 +47,7 @@ public class PolicyRunner {
}
}
/**
* Run a stored policy on files supplied directly by the caller (e.g. a manual run with
* uploads), bypassing its configured sources. Returns the run handle so callers can stream
* progress.
*/
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
public PolicyRunHandle runWith(
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
return policyEngine.runPolicy(policy, inputs, listener);
@@ -15,12 +15,9 @@ import stirling.software.proprietary.policy.output.PolicyOutputSink;
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
/**
* Validates a policy's trigger, sources, and output configuration by delegating each facet to the
* bean that handles its type. Called when a policy is saved so a misconfigured schedule, missing
* folder directory, or unknown type fails fast instead of silently misbehaving at run time.
*
* <p>The trigger is optional (a {@code null} trigger is a manual-only policy and needs no
* validation); every configured source is validated.
* Validates a policy at save time by delegating each facet (trigger, sources, output) to the bean
* that handles its type, so a misconfiguration fails fast rather than at run time. A null trigger
* is a manual-only policy and skips trigger validation.
*/
@Service
@RequiredArgsConstructor
@@ -31,8 +28,7 @@ public class PolicyValidator {
private final List<PolicyOutputSink> outputSinks;
/**
* @throws IllegalArgumentException if any facet's type is unknown or its configuration is
* invalid
* @throws IllegalArgumentException if any facet's type is unknown or its config is invalid
*/
public void validate(Policy policy) {
if (policy.trigger() != null) {
@@ -22,21 +22,13 @@ import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.PolicyInputs;
/**
* Reads input files from a directory. Each ready file becomes its own unit of work (one run per
* file) so a failure on one file does not affect the others.
* Reads input files from a directory; each ready file is its own unit of work so one failure does
* not affect the others.
*
* <p>Two modes via the {@code mode} option:
*
* <ul>
* <li>{@code "consume"} (default) - claim each file by moving it into {@code
* .stirling/processing}, then route it to {@code .stirling/done} or {@code .stirling/error}
* when its run finishes. Each file is processed once; right for "process new arrivals" (and
* the basis of watched folders).
* <li>{@code "snapshot"} - read the directory's current files without moving them; every run sees
* the full set again. Right for "always regenerate from a fixed input set".
* </ul>
*
* Readiness is checked first (via {@link FileReadinessChecker}) so files mid-write are skipped.
* <p>Mode option: "consume" (default) claims each file by moving it into {@code
* .stirling/processing} then routes it to {@code .stirling/done} or {@code .stirling/error}, so
* each file runs once; "snapshot" reads without moving, so every run sees the full set. Readiness
* is checked first so files mid-write are skipped.
*/
@Slf4j
@Service
@@ -44,7 +36,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
public class FolderInputSource implements InputSource {
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
// Bookkeeping lives under one hidden namespace dir so the watched folder stays tidy.
// Bookkeeping lives under one hidden dir so the watched folder stays tidy.
private static final String WORK_SUBDIR = ".stirling";
private static final String PROCESSING_SUBDIR = "processing";
private static final String DONE_SUBDIR = "done";
@@ -107,6 +99,7 @@ public class FolderInputSource implements InputSource {
return work;
}
// Atomic move into processing/: only one sweep can win the claim, the rest see the file gone.
private Path claim(Path inputDir, Path file) {
try {
Path processingDir = workDir(inputDir, PROCESSING_SUBDIR);
@@ -135,7 +128,6 @@ public class FolderInputSource implements InputSource {
}
}
/** A bookkeeping subdirectory under the watched folder's {@code .stirling} namespace. */
private static Path workDir(Path inputDir, String subdir) {
return inputDir.resolve(WORK_SUBDIR).resolve(subdir);
}
@@ -166,7 +158,6 @@ public class FolderInputSource implements InputSource {
}
}
/** The typed, validated form of a folder source's options: the directory and dedup mode. */
record FolderConfig(Path directory, boolean snapshot) {
private static final String DIRECTORY_OPTION = "directory";
@@ -7,14 +7,9 @@ import java.util.List;
import stirling.software.proprietary.policy.model.InputSpec;
/**
* Resolves one of a policy's {@link InputSpec sources} into the files to run on - answering
* <em>where</em> a run's files come from, independent of <em>when</em> it runs. The counterpart of
* {@code PolicyOutputSink}: implementations are beans selected by {@link #supports(InputSpec)}, so
* a new source kind (folder, S3) is just a new bean.
*
* <p>Driven by the {@code PolicyRunner}, which a trigger calls when a policy is due; a source is
* passive and knows nothing about what triggered the run. A manual run may instead supply files
* directly and bypass sources entirely.
* Resolves a policy {@link InputSpec} into the files to run on. Implementations are beans selected
* by {@link #supports(InputSpec)}, so a new source kind (folder, S3) is just a new bean. A manual
* run may supply files directly and bypass sources entirely.
*/
public interface InputSource {
@@ -24,24 +19,18 @@ public interface InputSource {
/** Whether this source can handle the given spec. */
boolean supports(InputSpec spec);
/**
* Check that an input spec is usable, throwing {@link IllegalArgumentException} if not. Called
* when a policy is saved so misconfiguration fails fast rather than at run time.
*/
/** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
default void validate(InputSpec spec) {}
/**
* Resolve the spec into zero or more units of work, each carrying the files for one run and a
* completion hook. Returning an empty list means there is nothing to run right now.
* Resolve the spec into zero or more units of work, each carrying one run's files and a
* completion hook. Empty list means nothing to run right now.
*/
List<ResolvedInput> resolve(InputSpec spec) throws IOException;
/**
* The local filesystem directories this source draws from, if any, for triggers that want to
* react to changes there (the folder-watch trigger) rather than poll. Advisory only: it merely
* tells a trigger <em>where</em> to watch; resolving the spec into files is still this source's
* job via {@link #resolve}. Non-filesystem sources (S3, ...) return an empty list and so are
* simply not watchable. Default: nothing to watch.
* Filesystem dirs this source draws from, for the folder-watch trigger. Advisory: resolving is
* still done by {@link #resolve}. Non-filesystem sources return empty and are not watchable.
*/
default List<Path> watchTargets(InputSpec spec) {
return List.of();
@@ -5,10 +5,9 @@ import java.util.function.Consumer;
import stirling.software.proprietary.policy.model.PolicyInputs;
/**
* One unit of work produced by an {@link InputSource}: the files to run plus a completion callback
* invoked with the run's success once it finishes (e.g. a folder source routes the input to {@code
* .stirling/done} or {@code .stirling/error}). A source may return several of these (e.g. one per
* file).
* One unit of work from an {@link InputSource}: the files to run plus a completion callback invoked
* with the run's success (e.g. a folder source routes the input to done/error). A source may return
* several of these, one per file.
*/
public record ResolvedInput(PolicyInputs inputs, Consumer<Boolean> onComplete) {
@@ -16,7 +15,7 @@ public record ResolvedInput(PolicyInputs inputs, Consumer<Boolean> onComplete) {
onComplete = onComplete == null ? success -> {} : onComplete;
}
/** A unit of work with no completion side effect. */
/** No completion side effect. */
public static ResolvedInput of(PolicyInputs inputs) {
return new ResolvedInput(inputs, success -> {});
}
@@ -3,15 +3,8 @@ package stirling.software.proprietary.policy.model;
import java.util.Map;
/**
* One source a policy's input files come from. {@code type} selects an {@code InputSource}
* ("folder", and in future "s3", ...); {@code options} carries source-specific configuration (a
* directory, a bucket, a dedup mode, ...).
*
* <p>A policy holds a list of these; a run pulls from every one. An empty list means the policy
* runs with no input files (a generator pipeline, or files supplied directly to a manual run).
*
* <p>Data-driven and parallel to {@link OutputSpec}/{@link TriggerConfig}: a new source kind is a
* new {@code type} handled by a new {@code InputSource} bean.
* One input source for a policy. {@code type} keys an {@code InputSource} bean; a run pulls from
* every source.
*/
public record InputSpec(String type, Map<String, Object> options) {
@@ -2,19 +2,13 @@ package stirling.software.proprietary.policy.model;
import java.util.Map;
/**
* Describes where a pipeline run's output files should be delivered. {@code type} selects a {@code
* PolicyOutputSink} (e.g. "inline"); {@code options} carries sink-specific configuration.
*
* <p>New destinations (folder, S3) are added as new sink beans keyed on a new {@code type} without
* changing this shape or the engine.
*/
/** Where a run's outputs are delivered. {@code type} keys a {@code PolicyOutputSink} bean. */
public record OutputSpec(String type, Map<String, Object> options) {
public OutputSpec {
options = options == null ? Map.of() : options;
}
/** The default destination: store outputs and return them to the caller for download. */
/** Default sink: store outputs and return them to the caller for download. */
public static OutputSpec inline() {
return new OutputSpec("inline", Map.of());
}
@@ -3,11 +3,10 @@ package stirling.software.proprietary.policy.model;
import java.util.List;
/**
* An ordered chain of tool steps plus where the output should go.
* An ordered chain of tool steps plus an output destination; the unit the engine executes.
*
* <p>This is the single shape executed by the policy engine, shared by AI plans, manually-triggered
* runs, and (later) watched folders. {@code output} may be null for callers that handle result
* files themselves (e.g. the AI workflow, which builds its own response payload).
* <p>{@code output} may be null for callers that handle result files themselves (e.g. the AI
* workflow, which builds its own response payload).
*/
public record PipelineDefinition(String name, List<PipelineStep> steps, OutputSpec output) {
public PipelineDefinition {
@@ -3,17 +3,13 @@ package stirling.software.proprietary.policy.model;
import java.util.Map;
/**
* A single tool invocation in a pipeline: the API endpoint path to call and the inputs to pass.
* A single tool invocation. {@code operation} is a Stirling endpoint path (e.g. {@code
* /api/v1/misc/compress-pdf}) per the {@code InternalApiClient} convention; {@code parameters} are
* scalar form fields.
*
* <p>{@code operation} is a Stirling tool endpoint path (e.g. {@code /api/v1/misc/compress-pdf}),
* matching the dispatch convention used by {@code InternalApiClient}. {@code parameters} are the
* tool-specific scalar form fields.
*
* <p>{@code fileParameters} binds a tool's named file fields (beyond the primary {@code fileInput}
* stream) to supporting files supplied with the run: it maps the form field name (e.g. {@code
* stampImage}, {@code overlayFiles}) to an asset key in the run's supporting-file store. This keeps
* supporting inputs (a stamp image, a certificate, an overlay) out of the document stream that
* flows step to step.
* <p>{@code fileParameters} maps a tool's named file field (e.g. {@code stampImage}, beyond the
* primary {@code fileInput} stream) to an asset key in the run's supporting-file store, keeping
* supporting inputs out of the document stream that flows step to step.
*/
public record PipelineStep(
String operation, Map<String, Object> parameters, Map<String, String> fileParameters) {
@@ -3,16 +3,11 @@ package stirling.software.proprietary.policy.model;
import java.util.List;
/**
* A stored automation: an ordered chain of tool steps, the sources its input files come from, and
* an output destination for the results.
* A stored automation: ordered tool steps, input sources, and an output destination.
*
* <p>Every policy can always be run on demand (manually). It may additionally carry one automatic
* {@link TriggerConfig} - usually a schedule - that fires it without a person asking; a {@code
* null} trigger means manual-only. A trigger decides <em>when</em> a run happens and a {@link
* InputSpec source} decides <em>where</em> its files come from; the two are independent, and a run
* pulls from every configured source.
*
* <p>This is the feature's central configuration object - what a user defines and the engine runs.
* <p>Always runnable on demand. An optional {@link TriggerConfig} fires it automatically; a {@code
* null} trigger means manual-only. Trigger decides when, {@link InputSpec sources} decide where
* files come from; a run pulls from every source.
*/
public record Policy(
String id,
@@ -42,7 +37,7 @@ public record Policy(
this(id, name, owner, enabled, trigger, List.of(), steps, output);
}
/** The engine-level, trigger-agnostic view of this policy's pipeline. */
/** This policy's pipeline as the engine sees it. */
public PipelineDefinition toDefinition() {
return new PipelineDefinition(name, steps, output);
}
@@ -6,17 +6,9 @@ import java.util.Map;
import org.springframework.core.io.Resource;
/**
* The files a run operates on, split into two roles:
*
* <ul>
* <li>{@code primary} - the documents that flow through the pipeline, each step's output becoming
* the next step's input.
* <li>{@code supportingFiles} - a named store of auxiliary files (a stamp image, certificate,
* overlay, attachments) that steps bind to their named file fields via {@link
* PipelineStep#fileParameters()}. These never enter the document stream.
* </ul>
*
* Asset values are lists so a single key can carry multi-file fields (e.g. attachments).
* A run's files. {@code primary} documents flow step to step; {@code supportingFiles} are auxiliary
* assets bound by key via {@link PipelineStep#fileParameters()} and never enter the document
* stream. Asset values are lists so one key can carry a multi-file field (e.g. attachments).
*/
public record PolicyInputs(List<Resource> primary, Map<String, List<Resource>> supportingFiles) {
@@ -8,12 +8,10 @@ import lombok.Getter;
import stirling.software.common.model.job.ResultFile;
/**
* Live, mutable state of a single pipeline run, held in memory by {@code PolicyRunRegistry}.
*
* <p>This carries the rich execution state (status, step cursor, wait state) that the job system's
* {@code JobResult} does not model. The run is also projected into {@code TaskManager} for
* cluster-visible status, progress notes, and file download; this object is the authoritative
* source of the state machine.
* Live, mutable state of one pipeline run, held in memory by {@code PolicyRunRegistry} and the
* authoritative source of the state machine. Carries execution state ({@code JobResult} does not
* model status/step cursor/wait state); also projected into {@code TaskManager} for cluster-visible
* status and download.
*/
@Getter
public class PolicyRun {
@@ -29,6 +27,21 @@ public class PolicyRun {
private volatile WaitState waitState;
private volatile String error;
/**
* Stable, machine-readable failure code the client can branch on — e.g. an entitlement-limit
* sentinel ({@code PAYG_LIMIT_REACHED} / {@code FEATURE_DEGRADED}) propagated from a downstream
* tool call's 402 — alongside the human-readable {@link #error}. Null unless set on failure.
*/
private volatile String errorCode;
/**
* For an entitlement-limit failure, whether the team was subscribed (over its spending cap) vs
* un-subscribed (free allowance spent) — taken from the blocking 402 body. Drives which
* usage-limit modal the client shows. Null unless {@link #errorCode} is an entitlement code.
*/
private volatile Boolean errorSubscribed;
private volatile List<ResultFile> outputs = List.of();
private volatile Instant updatedAt = Instant.now();
@@ -63,15 +76,24 @@ public class PolicyRun {
touch();
}
/**
* Fail with a stable {@code errorCode} the client can branch on (e.g. an entitlement-limit
* sentinel from a downstream 402), plus the optional {@code subscribed} flag from that
* response, in addition to the human-readable message.
*/
public synchronized void failWithCode(String message, String errorCode, Boolean subscribed) {
this.errorCode = errorCode;
this.errorSubscribed = subscribed;
fail(message);
}
public synchronized void waitForInput(WaitState wait) {
this.waitState = wait;
this.status = PolicyRunStatus.WAITING_FOR_INPUT;
touch();
}
/**
* Mark cancelled if the run has not already reached a terminal state. Returns whether it did.
*/
/** Cancels unless already terminal; returns whether it transitioned. */
public synchronized boolean cancel() {
if (status.isTerminal()) {
return false;
@@ -1,11 +1,8 @@
package stirling.software.proprietary.policy.model;
/**
* Lifecycle states of a {@link PolicyRun}.
*
* <p>{@code WAITING_FOR_INPUT} is modelled now so the engine and run shape support pausing a run
* (e.g. a step that blocks for a human decision) without holding a thread; the resume handshake is
* implemented in a later stage.
* Lifecycle states of a {@link PolicyRun}. {@code WAITING_FOR_INPUT} models a thread-free pause;
* the resume handshake lands in a later stage.
*/
public enum PolicyRunStatus {
PENDING,
@@ -5,8 +5,8 @@ import java.util.List;
import stirling.software.common.model.job.ResultFile;
/**
* Read-only view of a {@link PolicyRun} returned by the status endpoint. Output files are surfaced
* as {@link ResultFile} so the caller can download each via {@code GET /api/v1/general/files/{id}}.
* Read-only view of a {@link PolicyRun} for the status endpoint. Outputs are {@link ResultFile}s,
* downloadable via {@code GET /api/v1/general/files/{id}}.
*/
public record PolicyRunView(
String runId,
@@ -14,6 +14,8 @@ public record PolicyRunView(
int currentStep,
int stepCount,
String error,
String errorCode,
Boolean errorSubscribed,
List<ResultFile> outputs) {
public static PolicyRunView of(PolicyRun run) {
@@ -23,6 +25,8 @@ public record PolicyRunView(
run.getCurrentStep(),
run.stepCount(),
run.getError(),
run.getErrorCode(),
run.getErrorSubscribed(),
run.getOutputs());
}
}
@@ -11,16 +11,9 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
/**
* When a scheduled policy should fire, expressed as intent ("every day at 02:00") rather than a
* cron string. A frontend builds one of these from a friendly picker; the schedule trigger asks it
* for the next firing after a given moment.
*
* <p>Sealed so every cadence is an explicit, self-validating shape and adding a new one forces a
* new subtype rather than another string convention to learn. The {@code type} discriminator stays
* on the wire so the frontend can switch on it without knowing the Java hierarchy.
*
* <p>Wall-clock kinds ({@link Daily}, {@link Weekly}, {@link Monthly}) are evaluated in the zone of
* the {@code after} argument; {@link Every} is a fixed offset and ignores wall-clock time.
* A scheduled policy's firing cadence; {@code type} is the JSON discriminator. Wall-clock kinds
* ({@link Daily}, {@link Weekly}, {@link Monthly}) evaluate in the {@code after} argument's zone;
* {@link Every} is a fixed offset and ignores wall-clock time.
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@@ -42,10 +35,7 @@ public sealed interface Schedule {
DAYS
}
/**
* A fixed cadence repeating from when the policy was last seen: "every 15 minutes", "every 6
* hours". Time of day is irrelevant.
*/
/** A fixed offset from {@code after}: "every 15 minutes", "every 6 hours". No time of day. */
record Every(long count, Unit unit) implements Schedule {
public Every {
if (count <= 0) {
@@ -91,8 +81,7 @@ public sealed interface Schedule {
@Override
public ZonedDateTime nextAfter(ZonedDateTime after) {
// The soonest of the next 7 days that lands on a chosen weekday, at the configured
// time.
// Soonest of the next 7 days landing on a chosen weekday, at the configured time.
for (int i = 0; i <= 7; i++) {
ZonedDateTime candidate = after.plusDays(i).with(at);
if (candidate.isAfter(after) && days.contains(candidate.getDayOfWeek())) {
@@ -3,17 +3,9 @@ package stirling.software.proprietary.policy.model;
import java.util.Map;
/**
* A {@link Policy}'s optional automatic trigger - what fires it without a person asking. {@code
* type} selects a trigger kind ("schedule", and in future "webhook", "folder-watch", ...); {@code
* options} carries type-specific configuration (a {@link Schedule}, a webhook secret, ...).
*
* <p>Manual running is <em>not</em> a trigger kind: every policy can always be run on demand, so a
* policy with no automatic trigger simply has a {@code null} {@code TriggerConfig}. A trigger
* answers only "when"; where a run's files come from is a separate concern owned by the policy's
* {@link InputSpec sources}.
*
* <p>Data-driven and parallel to {@link OutputSpec}: new trigger kinds are new {@code type} values
* handled by a new trigger bean, with no change to the model.
* A {@link Policy}'s automatic trigger; {@code type} keys a trigger bean (e.g. "schedule"). Manual
* running is not a trigger kind: a manual-only policy carries a {@code null} {@code TriggerConfig}.
* Answers only "when"; file sources are the policy's {@link InputSpec}s.
*/
public record TriggerConfig(String type, Map<String, Object> options) {
@@ -3,14 +3,10 @@ package stirling.software.proprietary.policy.model;
import java.util.List;
/**
* Captured when a run pauses in {@link PolicyRunStatus#WAITING_FOR_INPUT}. Together with the run's
* {@link PipelineDefinition} this is the resumable snapshot: {@code resumeStepIndex} is the 0-based
* step to continue from, and {@code pendingFileIds} are the intermediate files (stored in {@code
* FileStorage}, so they survive the worker thread ending or a node restart) that become the input
* to the resumed run.
*
* <p>Stored as fileIds rather than in-memory resources by design: a paused run must be resumable
* long after its worker thread has gone.
* Resumable snapshot captured when a run pauses ({@link PolicyRunStatus#WAITING_FOR_INPUT}). {@code
* resumeStepIndex} is the 0-based step to continue from; {@code pendingFileIds} are intermediate
* files held in {@code FileStorage} (not in-memory resources) so a pause survives the worker thread
* ending or a node restart.
*/
public record WaitState(String reason, int resumeStepIndex, List<String> pendingFileIds) {
public WaitState {
@@ -22,13 +22,10 @@ import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.model.OutputSpec;
/**
* Writes a run's output files to a directory on disk. The destination is the {@code directory}
* option of the {@link OutputSpec}.
*
* <p>Files are streamed to disk (so large outputs are not buffered) and given unique names to avoid
* clobbering existing files. The returned {@link ResultFile}s describe what was written (path +
* size); they carry a synthetic id because the deliverable is the file on disk, not a {@code
* FileStorage} entry, so folder outputs are not downloadable via {@code /files/{id}}.
* Writes a run's outputs to the {@code directory} given in the {@link OutputSpec}. Files are
* streamed (not buffered) and uniquely named to avoid clobbering. Returned {@link ResultFile}s
* carry a synthetic id since the deliverable is the file on disk, not a {@code FileStorage} entry,
* so folder outputs are not downloadable via {@code /files/{id}}.
*/
@Slf4j
@Service
@@ -95,11 +92,7 @@ public class FolderOutputSink implements PolicyOutputSink {
return Path.of(directory.toString());
}
/**
* The resource's filename reduced to a bare, traversal-free name: any directory component or
* "../" is stripped so a crafted output name cannot escape {@code targetDir}. Falls back to a
* synthetic name when the filename is absent or reduces to nothing usable.
*/
// Strip any directory component / "../" so a crafted output name cannot escape targetDir.
private static String safeName(String filename, int index) {
if (filename == null || filename.isBlank()) {
return "output-" + index;
@@ -111,7 +104,7 @@ public class FolderOutputSink implements PolicyOutputSink {
return name;
}
/** Resolve a non-colliding path in {@code dir}, appending " (n)" before the extension. */
// Non-colliding path, appending " (n)" before the extension.
private static Path uniqueTarget(Path dir, String filename) {
Path candidate = dir.resolve(filename);
if (!Files.exists(candidate)) {
@@ -17,9 +17,8 @@ import stirling.software.common.service.FileStorage;
import stirling.software.proprietary.policy.model.OutputSpec;
/**
* Default output sink: stores each output file in {@code FileStorage} so it is downloadable via
* {@code GET /api/v1/general/files/{fileId}}. This is the destination for manually-triggered runs
* whose results are returned to the caller.
* Default sink: stores each output in {@code FileStorage} so it is downloadable via {@code GET
* /api/v1/general/files/{fileId}}. Used for manual runs whose results return to the caller.
*/
@Service
@RequiredArgsConstructor
@@ -9,11 +9,9 @@ import stirling.software.common.model.job.ResultFile;
import stirling.software.proprietary.policy.model.OutputSpec;
/**
* Delivers a finished run's output files to a destination, returning durable {@link ResultFile}
* descriptors (fileId + metadata) for the run record.
*
* <p>Implementations are Spring beans selected by {@link #supports(OutputSpec)}. New destinations
* (folder, S3) are added as new beans without changing the engine.
* Delivers a finished run's outputs to a destination, returning {@link ResultFile} descriptors for
* the run record. Implementations are beans selected by {@link #supports(OutputSpec)}, so a new
* destination (folder, S3) is just a new bean.
*/
public interface PolicyOutputSink {
@@ -23,19 +21,10 @@ public interface PolicyOutputSink {
/** Whether this sink can handle the given output spec. */
boolean supports(OutputSpec spec);
/**
* Check that an output spec is usable, throwing {@link IllegalArgumentException} if not. Called
* when a policy is saved so misconfiguration fails fast rather than at run time.
*/
/** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
default void validate(OutputSpec spec) {}
/**
* Persist/deliver the output files and return their descriptors.
*
* @param runId the run these outputs belong to
* @param outputs the final pipeline output resources
* @param spec the requested destination
*/
/** Persist/deliver the output files and return their descriptors. */
List<ResultFile> deliver(String runId, List<Resource> outputs, OutputSpec spec)
throws IOException;
}
@@ -1,22 +1,17 @@
package stirling.software.proprietary.policy.progress;
/**
* Receives live progress as a pipeline run executes. Implementations forward to an SSE stream,
* write job notes for polling, or both. Step indices are 1-based.
*
* <p>All methods default to no-ops so callers implement only what they surface.
* Receives live progress as a pipeline run executes (SSE stream, job notes, or both). Step indices
* are 1-based. All methods default to no-ops.
*/
public interface PolicyProgressListener {
/** A listener that ignores all progress. */
PolicyProgressListener NOOP = new PolicyProgressListener() {};
/** Called immediately before step {@code stepIndex} of {@code stepCount} begins. */
default void onStepStart(int stepIndex, int stepCount, String operation) {}
/** Called immediately after step {@code stepIndex} of {@code stepCount} completes. */
default void onStepComplete(int stepIndex, int stepCount, String operation) {}
/** Called on a keep-alive tick so downstream connections can detect disconnects promptly. */
/** Keep-alive tick so downstream connections can detect disconnects promptly. */
default void onHeartbeat() {}
}
@@ -9,9 +9,8 @@ import java.util.concurrent.ConcurrentHashMap;
import stirling.software.proprietary.policy.model.Policy;
/**
* In-memory {@link PolicyStore}. Not the runtime bean - {@link JpaPolicyStore} is the durable
* store. Kept as a lightweight, dependency-free implementation for tests and for any future no-
* database mode.
* In-memory {@link PolicyStore} for tests and any future no-database mode. {@link JpaPolicyStore}
* is the runtime bean.
*/
public class InProcessPolicyStore implements PolicyStore {
@@ -13,9 +13,8 @@ import stirling.software.proprietary.policy.model.Policy;
import tools.jackson.databind.ObjectMapper;
/**
* Durable {@link PolicyStore} backed by JPA. The runtime store whenever the proprietary module runs
* (a datasource is always present). Policies are persisted as JSON via {@link PolicyEntity}; the
* scalar columns are kept in sync for querying.
* Durable {@link PolicyStore} backed by JPA; the runtime store. Policies are persisted as JSON via
* {@link PolicyEntity}, with scalar columns kept in sync for querying.
*/
@Service
@RequiredArgsConstructor
@@ -12,13 +12,11 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}.
*
* <p>The whole policy is stored as JSON in {@code policyJson} (authoritative on read, and the same
* serialization the API uses); the scalar columns are denormalized copies for querying - notably
* {@code triggerType} + {@code enabled} so background triggers can fetch their policies. Ownership
* is a plain {@code owner} string rather than a foreign key, to stay decoupled from the security
* entities; richer team scoping can be layered on later.
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives
* as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies
* for querying, notably {@code triggerType} + {@code enabled} so background triggers can fetch
* their policies. {@code owner} is a plain string, not a foreign key, to stay decoupled from the
* security entities.
*/
@Entity
@Table(name = "policies")
@@ -5,24 +5,19 @@ import java.util.Optional;
import stirling.software.proprietary.policy.model.Policy;
/**
* Stores {@link Policy} definitions. The in-memory implementation backs simple deployments now; a
* durable (JPA) implementation can replace it behind this interface without touching callers.
*/
/** Stores {@link Policy} definitions. */
public interface PolicyStore {
/** Create or update a policy. A blank/absent id is assigned; returns the stored policy. */
/** Create or update; a blank/absent id is assigned. Returns the stored policy. */
Policy save(Policy policy);
Optional<Policy> get(String id);
List<Policy> all();
/**
* Enabled policies whose automatic trigger is of the given type (used by background triggers).
*/
/** Enabled policies with the given trigger type, for background triggers. */
List<Policy> findByTriggerType(String triggerType);
/** Remove a policy; returns whether it existed. */
/** Returns whether the policy existed. */
boolean delete(String id);
}
@@ -33,20 +33,14 @@ import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Fires policies the moment a file lands in one of their folder sources, instead of polling on a
* timer. The trigger only reads that location; turning it into files is still the source's job.
* Fires policies when a file lands in one of their folder sources, rather than polling on a timer.
*
* <p>The watcher is a latency optimisation, not a source of truth, so this pairs an event watch
* with a low-frequency <b>reconcile</b> sweep ({@code watchReconcileSeconds}). The reconcile both
* (a) re-syncs which directories are watched as policies are created/edited/deleted and folders
* appear on disk, and (b) runs every folder-watch policy once, catching files that pre-dated the
* watch, events lost to inotify-queue overflow, and changes on filesystems that do not deliver
* events at all (NFS, many container bind mounts). Both paths just call {@link PolicyRunner#run};
* the {@link InputSource} does the claiming, so a redundant run finds nothing to claim and is
* harmless.
* <p>The watch is a latency optimisation, not a source of truth: a periodic reconcile sweep ({@code
* watchReconcileSeconds}) re-syncs watched dirs and re-runs every policy, covering files that
* pre-dated the watch, dropped events, and filesystems that emit none (NFS, bind mounts). Redundant
* runs are harmless since {@link InputSource} does the claiming.
*
* <p>Like {@link ScheduleTrigger}, watch state is per-node and in memory; this assumes a single
* node and rebuilds its registrations on restart from the {@link PolicyStore}.
* <p>Watch state is in memory, so this assumes a single node and rebuilds registrations on restart.
*/
@Slf4j
@Service
@@ -65,7 +59,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
private volatile boolean running;
// Package-visible (not private) so tests can drive syncRegistrations() against a real service.
// Package-visible so tests can drive syncRegistrations() against a real service.
volatile WatchService watchService;
private volatile ScheduledExecutorService reconciler;
@@ -125,8 +119,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
}
private void watchLoop() {
// Capture the service once: stop() may null the field, and a local avoids racing that to an
// NPE (close() still wakes take()/poll() on this same instance).
// Capture once: stop() may null the field; close() still wakes take()/poll() on this local.
WatchService watcher = watchService;
if (watcher == null) {
return;
@@ -146,9 +139,8 @@ public class FolderWatchTrigger implements PolicyTrigger {
}
/**
* Collect the directories touched by {@code first} and any further events that arrive within
* the quiet period, so a burst of file-system events becomes a single set of affected
* directories. The event kinds are irrelevant: any event on a watched dir just means "go look".
* Coalesce a burst of file-system events into one set of affected directories: drain everything
* arriving within the quiet period. Event kinds are irrelevant; any event means "go look".
*/
private Set<Path> drainBurst(WatchService watcher, WatchKey first) {
long quietPeriodMs = applicationProperties.getPolicies().getWatchQuietPeriodMs();
@@ -200,7 +192,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
}
}
/** The reconcile safety net: run every folder-watch policy regardless of watch events. */
/** Reconcile safety net: run every folder-watch policy regardless of watch events. */
void runAll() {
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
try {
@@ -214,10 +206,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
}
}
/**
* Bring the set of watched directories in line with the current folder-watch policies: register
* newly required directories that exist on disk, and cancel ones no longer wanted.
*/
/** Register newly-wanted dirs that exist on disk, cancel ones no longer wanted. */
synchronized void syncRegistrations() {
if (watchService == null) {
return;
@@ -274,11 +263,8 @@ public class FolderWatchTrigger implements PolicyTrigger {
return dirs;
}
/**
* The normalised, absolute directories this policy's sources expose to watch. Normalisation
* makes registration keys and event-time matching comparable regardless of how the path was
* configured.
*/
// Absolute + normalised so registration keys and event-time matching compare regardless of how
// the path was configured.
private List<Path> watchDirsOf(Policy policy) {
List<Path> dirs = new ArrayList<>();
for (InputSpec spec : policy.sources()) {
@@ -3,36 +3,21 @@ package stirling.software.proprietary.policy.trigger;
import stirling.software.proprietary.policy.model.Policy;
/**
* An automatic trigger: the thing that decides <em>when</em> a policy runs without a person asking.
* A trigger owns a {@link #type()} (matching {@code TriggerConfig.type()}); when its condition
* fires it hands the policy to the {@code PolicyRunner}, which pulls the policy's sources and
* starts the runs. A trigger never resolves sources itself.
*
* <p>Triggers are background, configuration-driven beans (schedule, and in future webhook or
* folder-watch): on {@link #start()} they begin watching/scheduling for the policies returned by
* {@code PolicyStore.findByTriggerType(type())}, and stop on {@link #stop()}. New trigger kinds are
* new beans of this type; the runner and the {@code Policy} model do not change.
*
* <p>Manual running is not a trigger - every policy can always be run on demand via the {@code
* PolicyRunner} regardless of whether it has a trigger.
* Decides <em>when</em> a policy runs. On firing it hands the policy to {@code PolicyRunner}; it
* never resolves sources itself. New trigger kinds are just new beans of this type.
*/
public interface PolicyTrigger {
/** Stable identifier for this trigger kind, matching {@code TriggerConfig.type()}. */
/** Matches {@code TriggerConfig.type()}. */
String type();
/**
* Check that this trigger is usable for the given policy, throwing {@link
* IllegalArgumentException} if not. Called when a policy is saved so misconfiguration fails
* fast rather than at fire time. Receives the whole {@link Policy} (not just its {@code
* TriggerConfig}) so a trigger whose firing depends on the policy's sources (folder-watch) can
* assert that relationship; most triggers only inspect {@code policy.trigger()}.
* Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole
* {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that.
*/
default void validate(Policy policy) {}
/** Begin activating policies of this type (e.g. start the schedule sweep). */
default void start() {}
/** Stop activating and release any resources. */
default void stop() {}
}
@@ -8,14 +8,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Starts and stops every {@link PolicyTrigger} with the application lifecycle. Background triggers
* (schedule, and future folder/S3) begin watching on {@link #start()} and release resources on
* {@link #stop()}; request-driven triggers (manual) are no-ops.
*
* <p>This is the single activation point for triggers - a new background trigger only has to be a
* {@link PolicyTrigger} bean.
*/
/** Starts and stops every {@link PolicyTrigger} with the application lifecycle. */
@Slf4j
@Service
@RequiredArgsConstructor
@@ -24,16 +24,9 @@ import stirling.software.proprietary.policy.store.PolicyStore;
import tools.jackson.databind.ObjectMapper;
/**
* Fires policies on a {@link Schedule}. On {@link #start()} it sweeps on a fixed interval; each
* sweep finds the enabled "schedule" policies and runs any whose next firing has come due since it
* last fired.
* Fires policies on a {@link Schedule}: a fixed-interval sweep runs each due "schedule" policy.
*
* <p>The trigger only decides <em>when</em>: once a policy is due it hands it to the {@link
* PolicyRunner}, which pulls from the policy's configured sources and starts the runs. The trigger
* knows nothing about folders, buckets, or how many runs a sweep produces.
*
* <p>Caveat: last-fire times are tracked <b>in memory</b>, so this assumes a single node and resets
* on restart; cluster-wide coordination (leader election) is a follow-up.
* <p>Last-fire times are in memory, so this assumes a single node and resets on restart.
*/
@Slf4j
@Service
@@ -101,8 +94,7 @@ public class ScheduleTrigger implements PolicyTrigger {
continue;
}
// First time we see a policy, baseline its last-fire to now so it does not fire
// immediately; subsequent sweeps fire it once its next firing has passed.
// Baseline a newly-seen policy to now so it does not fire immediately.
Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now);
ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone()));
if (!next.toInstant().isAfter(now)) {
@@ -114,9 +106,8 @@ public class ScheduleTrigger implements PolicyTrigger {
}
/**
* The typed, validated form of a schedule trigger's options: the {@link Schedule} and the zone
* its wall-clock kinds are evaluated in (default UTC). Construction fails for a missing/invalid
* schedule or zone.
* Validated schedule-trigger options: the {@link Schedule} and the zone it runs in (UTC by
* default).
*/
record ScheduleConfig(Schedule schedule, ZoneId zone) {
@@ -1,11 +1,13 @@
package stirling.software.proprietary.security;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
@@ -37,6 +39,17 @@ public class InitialSecuritySetup {
private final ApplicationProperties applicationProperties;
private final DatabaseServiceInterface databaseService;
private final UserLicenseSettingsService licenseSettingsService;
private final Environment environment;
/**
* SaaS manages identity in Supabase and billing via PAYG, so the self-host bootstrap steps that
* scan/rewrite the whole user table (default-team backfill, seat-license grandfathering) don't
* apply - and against a large SaaS user table they stall startup with full-table loads +
* per-row saveAll. Per-user team assignment happens in SupabaseAuthenticationFilter instead.
*/
private boolean isSaas() {
return Arrays.asList(environment.getActiveProfiles()).contains("saas");
}
@PostConstruct
public void init() {
@@ -51,9 +64,15 @@ public class InitialSecuritySetup {
}
configureJWTSettings();
assignUsersToDefaultTeamIfMissing();
initializeInternalApiUser();
initializeUserLicenseSettings();
if (isSaas()) {
log.info(
"SaaS profile active - skipping self-host user-table bootstrap"
+ " (default-team backfill, seat-license grandfathering).");
} else {
assignUsersToDefaultTeamIfMissing();
initializeUserLicenseSettings();
}
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
log.error("Failed to initialize security setup.", e);
System.exit(1);
@@ -87,7 +87,11 @@ public class User implements UserDetails, Serializable {
private String email;
// SaaS-only: Supabase user UUID. Null in OSS / proprietary deployments.
@Column(name = "supabase_id", unique = true)
// Column is `supabase_auth_id` (canonical name from the initial Supabase remote
// schema migration). An earlier Flyway V2 (PR #6384) accidentally introduced a
// parallel `supabase_id` column that was used by Java; V17 backfilled and dropped
// it. Field name is kept as `supabaseId` to avoid a wide refactor of callers.
@Column(name = "supabase_auth_id", unique = true)
private UUID supabaseId;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
@@ -53,6 +53,30 @@ class McpAudienceValidatorTest {
assertThat(result.hasErrors()).isTrue();
}
@Test
void acceptedAudience_isAccepted_alongsideResourceId() {
// Supabase-style IdP: every token carries aud=authenticated, never the resource id.
McpAudienceValidator relaxed = new McpAudienceValidator(RESOURCE, List.of("authenticated"));
assertThat(relaxed.validate(tokenWithAudience(List.of("authenticated"))).hasErrors())
.isFalse();
assertThat(relaxed.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isFalse();
assertThat(relaxed.validate(tokenWithAudience(List.of("something-else"))).hasErrors())
.isTrue();
}
@Test
void blankAcceptedAudienceEntries_areIgnored() {
McpAudienceValidator relaxed = new McpAudienceValidator(RESOURCE, List.of("", " "));
assertThat(relaxed.validate(tokenWithAudience(List.of(""))).hasErrors()).isTrue();
assertThat(relaxed.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isFalse();
}
@Test
void blankResourceIdWithOnlyBlankAccepted_failsClosed() {
McpAudienceValidator blank = new McpAudienceValidator("", List.of(" "));
assertThat(blank.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isTrue();
}
private static Jwt tokenWithAudience(List<String> audience) {
return new Jwt(
"header.payload.signature",
@@ -116,7 +116,8 @@ class McpOAuthIntegrationTest {
assertThat(response.statusCode()).isEqualTo(401);
String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse("");
assertThat(wwwAuth).contains("resource_metadata=");
assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource");
// The advertised URL must be the RFC 9728 path-inserted form for the /mcp resource.
assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource/mcp");
}
@Test
@@ -247,6 +248,24 @@ class McpOAuthIntegrationTest {
assertThat(response.body()).contains("mcp.tools.read");
}
@Test
void pathInsertedMetadataEndpoint_servesCustomizedMetadata() throws Exception {
// RFC 9728 path-inserted form for the /mcp resource. Must be served by the MCP chain
// with authorization_servers populated; a default/uncustomized document here makes MCP
// clients fall back to treating this server as its own authorization server.
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(base() + "/.well-known/oauth-protected-resource/mcp"))
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains(RESOURCE_ID);
assertThat(response.body()).contains("authorization_servers");
assertThat(response.body()).contains(ISSUER);
assertThat(response.body()).contains("mcp.tools.read");
}
private HttpResponse<String> postMcp(String token, String body) throws Exception {
HttpRequest.Builder builder =
HttpRequest.newBuilder()
@@ -0,0 +1,87 @@
package stirling.software.proprietary.policy.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
import java.util.List;
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.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
/**
* Tests for {@link PolicyAccessGuard}: owner-or-admin access, no-op when login is disabled, and
* server-side owner assignment.
*/
@ExtendWith(MockitoExtension.class)
class PolicyAccessGuardTest {
@Mock private UserServiceInterface userService;
private PolicyAccessGuard guard(boolean loginEnabled) {
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(loginEnabled);
return new PolicyAccessGuard(userService, properties);
}
@Test
void loginDisabledAllowsEverythingAndAssignsNoOwner() {
PolicyAccessGuard guard = guard(false);
List<Policy> all = List.of(ownedBy("alice"), ownedBy("bob"));
assertTrue(guard.canAccess(ownedBy("someone-else")));
assertNull(guard.ownerForNewPolicy());
assertEquals(all, guard.visible(all));
}
@Test
void adminCanAccessAnyPolicy() {
when(userService.isCurrentUserAdmin()).thenReturn(true);
assertTrue(guard(true).canAccess(ownedBy("alice")));
}
@Test
void ownerCanAccessTheirOwnPolicy() {
when(userService.isCurrentUserAdmin()).thenReturn(false);
when(userService.getCurrentUsername()).thenReturn("alice");
assertTrue(guard(true).canAccess(ownedBy("alice")));
}
@Test
void nonOwnerNonAdminCannotAccess() {
when(userService.isCurrentUserAdmin()).thenReturn(false);
when(userService.getCurrentUsername()).thenReturn("bob");
assertFalse(guard(true).canAccess(ownedBy("alice")));
}
@Test
void visibleFiltersToOwnedPoliciesForANonAdmin() {
when(userService.isCurrentUserAdmin()).thenReturn(false);
when(userService.getCurrentUsername()).thenReturn("alice");
List<Policy> visible =
guard(true).visible(List.of(ownedBy("alice"), ownedBy("bob"), ownedBy("alice")));
assertEquals(2, visible.size());
assertTrue(visible.stream().allMatch(policy -> "alice".equals(policy.owner())));
}
@Test
void ownerForNewPolicyIsTheCurrentUserWhenEnforced() {
when(userService.getCurrentUsername()).thenReturn("alice");
assertEquals("alice", guard(true).ownerForNewPolicy());
}
private static Policy ownedBy(String owner) {
return new Policy("p1", "p", owner, true, null, List.of(), List.of(), OutputSpec.inline());
}
}
@@ -28,9 +28,13 @@ 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.slf4j.MDC;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.HttpClientErrorException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.FileStorage;
@@ -170,6 +174,35 @@ class PolicyEngineTest {
verify(taskManager, never()).setComplete(runId);
}
@Test
void runBlockedByUsageLimit_surfacesErrorCodeAndSubscribed() throws Exception {
// A downstream tool call gets a 402 entitlement block. The run fails, but its errorCode +
// subscribed are taken from the 402 body so the client can pop the right usage-limit modal
// (the policy 402 happens server-side, out of reach of the apiClient interceptor).
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
String body = "{\"error\":\"PAYG_LIMIT_REACHED\",\"subscribed\":true}";
when(internalApiClient.post(eq(ROTATE), any()))
.thenThrow(
HttpClientErrorException.create(
HttpStatus.PAYMENT_REQUIRED,
"Payment Required",
HttpHeaders.EMPTY,
body.getBytes(java.nio.charset.StandardCharsets.UTF_8),
java.nio.charset.StandardCharsets.UTF_8));
PolicyRun run =
engine.submit(
definition(new PipelineStep(ROTATE, Map.of())),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP)
.completion()
.get(10, TimeUnit.SECONDS);
assertEquals(PolicyRunStatus.FAILED, run.getStatus());
assertEquals("PAYG_LIMIT_REACHED", run.getErrorCode());
assertEquals(Boolean.TRUE, run.getErrorSubscribed());
}
@Test
void runPolicyExecutesThePolicysPipeline() throws Exception {
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
@@ -204,6 +237,86 @@ class PolicyEngineTest {
verify(internalApiClient).post(eq(ROTATE), any());
}
@Test
void runPolicyDispatchesToolCallsAsTheOwner() throws Exception {
// Billing-attribution regression: the pipeline runs on a background worker thread, but the
// policy owner must be propagated as the audit principal so InternalApiClient (and thus
// PAYG) attributes each tool call to the owner — not the INTERNAL_API_USER fallback.
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
int[] counter = {0};
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
.thenAnswer(
inv ->
new StoredFile(
"file-" + ++counter[0],
((InputStream) inv.getArgument(0)).readAllBytes().length));
String[] principalAtDispatch = {"<none>"};
when(internalApiClient.post(eq(ROTATE), any()))
.thenAnswer(
inv -> {
principalAtDispatch[0] = MDC.get("auditPrincipal");
return ResponseEntity.ok(pdf("rotated", "rotated.pdf"));
});
Policy policy =
new Policy(
"p1",
"rotate",
"alice",
true,
null,
List.of(new PipelineStep(ROTATE, Map.of())),
OutputSpec.inline());
engine.runPolicy(
policy,
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP)
.completion()
.get(10, TimeUnit.SECONDS);
assertEquals("alice", principalAtDispatch[0]);
}
@Test
void adHocRunDispatchesToolCallsAsTheSubmittingUser() throws Exception {
// Ad-hoc runs (no stored policy) bill whoever kicked them off; the principal is captured on
// the request thread (here simulated via MDC) and re-established on the worker thread.
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
int[] counter = {0};
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
.thenAnswer(
inv ->
new StoredFile(
"file-" + ++counter[0],
((InputStream) inv.getArgument(0)).readAllBytes().length));
String[] principalAtDispatch = {"<none>"};
when(internalApiClient.post(eq(ROTATE), any()))
.thenAnswer(
inv -> {
principalAtDispatch[0] = MDC.get("auditPrincipal");
return ResponseEntity.ok(pdf("rotated", "rotated.pdf"));
});
MDC.put("auditPrincipal", "bob"); // the request thread's audit principal
try {
engine.submit(
definition(new PipelineStep(ROTATE, Map.of())),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP)
.completion()
.get(10, TimeUnit.SECONDS);
} finally {
MDC.remove("auditPrincipal");
}
assertEquals("bob", principalAtDispatch[0]);
}
@Test
void runIsQueuedUnderResourcePressure() {
when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(true);
@@ -38,11 +38,19 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.ai.model.AiCreateSession;
import stirling.software.saas.ai.repository.AiCreateSessionRepository;
import stirling.software.saas.ai.service.AiCreateProxyService;
import stirling.software.saas.ai.service.AiCreateSessionService;
import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.charge.ChargeContext;
import stirling.software.saas.payg.charge.JobChargeService;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ProcessType;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
@@ -54,6 +62,7 @@ import stirling.software.saas.util.CreditHeaderUtils;
@Tag(name = "AI")
@Hidden
@RequiredArgsConstructor
@RequiresFeature(FeatureGate.AI_SUPPORT)
@Slf4j
public class AiCreateController {
@@ -64,6 +73,7 @@ public class AiCreateController {
private final TeamCreditService teamCreditService;
private final UserRepository userRepository;
private final CreditHeaderUtils creditHeaderUtils;
private final JobChargeService jobChargeService;
@PostMapping("/sessions")
public ResponseEntity<CreateSessionResponse> createSession(
@@ -84,9 +94,45 @@ public class AiCreateController {
session.getUserId(),
session.getDocType(),
session.getTemplateId());
chargeForCreate(session);
return ResponseEntity.ok(new CreateSessionResponse(session.getSessionId()));
}
/**
* Bill one document for a new AI Create session — creating a document is the charge point;
* follow-up edits on the same session (outline / reprompt / draft / template / stream) carry no
* charge. AI usage is billable, so a JWT (web) session counts the same as an API-key one.
*
* <p>Best-effort: a charge failure must not block the user's session. Entitlement is already
* enforced upstream — this controller is {@code @RequiresFeature(AI_SUPPORT)}, so the
* EntitlementGuard 402s a team with no AI allowance before we ever get here; this call only
* does the accounting (free-grant draw + Stripe meter).
*/
private void chargeForCreate(AiCreateSession session) {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
if (user == null || user.getTeam() == null) {
return;
}
JobSource source =
auth instanceof ApiKeyAuthenticationToken ? JobSource.API : JobSource.WEB;
ChargeContext ctx =
new ChargeContext(
user.getId(),
user.getTeam().getId(),
source,
ProcessType.SINGLE_TOOL,
BillingCategory.AI);
jobChargeService.chargeStandalone(ctx, 1);
} catch (RuntimeException e) {
log.warn(
"AI create session {} charge failed; session proceeds unbilled: {}",
session.getSessionId(),
e.getMessage());
}
}
@DeleteMapping("/sessions/{sessionId}")
public ResponseEntity<Void> deleteSession(@PathVariable String sessionId) {
sessionService.deleteSessionForCurrentUser(sessionId);
@@ -26,6 +26,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.ai.model.AiCreateSession;
import stirling.software.saas.ai.model.AiCreateSessionStatus;
import stirling.software.saas.ai.service.AiCreateSessionService;
import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.model.FeatureGate;
@RestController
@Profile("saas")
@@ -33,6 +35,7 @@ import stirling.software.saas.ai.service.AiCreateSessionService;
@Tag(name = "AI")
@Hidden
@RequiredArgsConstructor
@RequiresFeature(FeatureGate.AI_SUPPORT)
@Slf4j
public class AiCreateInternalController {
@@ -28,6 +28,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.ai.service.AiProxyService;
import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
@@ -36,6 +38,7 @@ import stirling.software.saas.util.CreditHeaderUtils;
@RestController
@Profile("saas")
@RequestMapping("/api/v1/ai")
@RequiresFeature(FeatureGate.AI_SUPPORT)
@Tag(name = "AI")
@Hidden
@Slf4j
@@ -9,8 +9,10 @@ import lombok.RequiredArgsConstructor;
import stirling.software.saas.interceptor.UnifiedCreditInterceptor;
// Legacy credit-billing path. Disabled in saas-PAYG by default — activate the legacy-credits
// profile explicitly (`--spring.profiles.active=saas,dev,legacy-credits`) if you need it back.
@Configuration
@Profile("saas")
@Profile("saas & legacy-credits")
@RequiredArgsConstructor
public class CreditInterceptorConfig implements WebMvcConfigurer {
@@ -25,8 +25,9 @@ import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.CreditService.CreditSummary;
import stirling.software.saas.util.LogRedactionUtils;
// Legacy credit-billing endpoints. PAYG replaces this — gated behind legacy-credits profile.
@RestController
@Profile("saas")
@Profile("saas & legacy-credits")
@RequestMapping("/api/v1/credits")
@Tag(name = "Credit Management", description = "Endpoints for managing user API credits")
@RequiredArgsConstructor
@@ -39,8 +39,10 @@ import stirling.software.saas.util.CreditHeaderUtils;
* Scoped to controllers annotated with {@link AutoJobPostMapping} so it doesn't hijack the global
* exception flow.
*/
// Legacy credit-billing error advice. PAYG handles its own error semantics via
// PaygChargeInterceptor — disabled by default in saas, activate legacy-credits profile if needed.
@RestControllerAdvice(annotations = AutoJobPostMapping.class)
@Profile("saas")
@Profile("saas & legacy-credits")
@Slf4j
@Order(1)
public class CreditErrorAdvice {
@@ -27,8 +27,10 @@ import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
// Legacy credit-billing success advice. PAYG writes its own ledger entries via
// JobChargeService — disabled by default in saas, activate legacy-credits profile if needed.
@RestControllerAdvice
@Profile("saas")
@Profile("saas & legacy-credits")
@Slf4j
public class CreditSuccessAdvice implements ResponseBodyAdvice<Object> {
@@ -32,8 +32,10 @@ import stirling.software.saas.service.SaasUserExtensionService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
// Legacy credit-billing interceptor. PAYG replaces this with PaygChargeInterceptor — disabled
// by default in saas, activate legacy-credits profile to bring it back.
@Component
@Profile("saas")
@Profile("saas & legacy-credits")
@Slf4j
public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor {
@@ -0,0 +1,56 @@
package stirling.software.saas.payg.api;
/**
* Cap conversion between the dollar amount the leader edits in the UI and the unit count stored on
* {@code wallet_policy.cap_units}.
*
* <p>The cap is application-layer only — Stripe stays on a flat-priced single meter, so the
* conversion rate here doesn't need to match any Stripe price. It only needs to be stable: a leader
* who set "$25" should read back "$25" on the next page load.
*
* <p>V1 rate: {@value #UNITS_PER_USD} units = $1. This anchors the in-app cap representation to the
* same unit count the ledger writes, so the "X of Y units used" widget in the FE lines up against
* the cap. A future iteration can read this from {@code pricing_policy} once the per-policy money
* conversion lands.
*
* <p>Both directions floor: $24.50 → 2450 units, 2450 units → $24. The FE only sends whole-dollar
* inputs (the cap-edit field is an integer text box) so the floor on the read path is the only
* place rounding ever shows up, and only when an admin set a non-multiple via SQL.
*/
public final class CapMoneyUnits {
/**
* Doc-units per USD. {@code 100} = "1 cent per unit" at the in-app display layer. Tied to the
* unit-count meter the engine writes to the ledger; not tied to Stripe pricing.
*/
public static final int UNITS_PER_USD = 100;
/** Smallest currency unit per USD (always 100 cents in USD; explicit for clarity). */
public static final int CENTS_PER_USD = 100;
private CapMoneyUnits() {}
/** Convert a dollar cap entered by the leader to doc-units for {@code cap_units}. */
public static long usdToUnits(int capUsd) {
if (capUsd < 0) {
throw new IllegalArgumentException("capUsd must be >= 0");
}
return (long) capUsd * UNITS_PER_USD;
}
/** Convert {@code cap_units} back to dollars for the response payload. Floor on read. */
public static int unitsToUsd(long capUnits) {
if (capUnits < 0L) {
return 0;
}
return (int) (capUnits / UNITS_PER_USD);
}
/** Convert a dollar cap to smallest-currency-unit cents for {@code cap_source_money}. */
public static long usdToCents(int capUsd) {
if (capUsd < 0) {
throw new IllegalArgumentException("capUsd must be >= 0");
}
return (long) capUsd * CENTS_PER_USD;
}
}
@@ -0,0 +1,424 @@
package stirling.software.saas.payg.api;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.payg.api.WalletSnapshotResponse.ActivityRow;
import stirling.software.saas.payg.api.WalletSnapshotResponse.CategoryBreakdown;
import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.entitlement.EntitlementService;
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.LedgerEntryType;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
import stirling.software.saas.payg.repository.PaygShadowChargeRepository;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.repository.WalletLedgerRepository;
import stirling.software.saas.payg.repository.WalletPolicyRepository;
import stirling.software.saas.payg.wallet.WalletLedgerEntry;
import stirling.software.saas.payg.wallet.WalletPolicy;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Read + cap-mutation surface backing the FE PAYG Plan page.
*
* <p>{@code GET /api/v1/payg/wallet} is the single fetch the {@code useWallet} hook calls. Returns
* a fully-populated {@link WalletSnapshotResponse} — derived from {@link EntitlementService} (for
* spend / cap / period), {@link PaygTeamExtensions} (for subscription state), and {@link
* WalletLedgerRepository} (for the per-category breakdown widget). Leader callers also get a roster
* of team members + their per-member usage; member callers see an empty roster.
*
* <p>{@code PATCH /api/v1/payg/cap} updates {@code wallet_policy.cap_units} (no Stripe call — the
* cap is enforced application-side via the entitlement guard) and invalidates the team's snapshot
* cache so the next read reflects the change immediately. Only leaders may call this; the team is
* derived from the caller, so we authorise inside the method rather than via {@code @PreAuthorize}
* — the team id never appears on the path or query string.
*
* <p>Subscription state is sourced from {@code payg_team_extensions.payg_subscription_id} (added in
* V14): {@code stripeSubscriptionId} echoes it via {@link TeamBillingService}, and a team reads as
* {@link #STATUS_SUBSCRIBED} once {@code billing.subscribed()} is true — i.e. it has a subscription
* id, or a Stripe customer id as the pre-webhook bridge for a just-completed checkout whose
* subscription-created webhook hasn't landed yet (see {@code TeamBillingService.compute}).
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/payg")
@Profile("saas")
public class PaygWalletController {
static final String STATUS_FREE = "free";
static final String STATUS_SUBSCRIBED = "subscribed";
static final String ROLE_LEADER = "leader";
static final String ROLE_MEMBER = "member";
/**
* Placeholder ceiling for the team-less empty snapshot only (authenticated caller without a
* membership — shouldn't happen post-migration). Teams always get the live {@code
* pricing_policy.free_tier_units} grant via {@link TeamBillingService}.
*/
private static final int FREE_TIER_LIMIT_UNITS_FALLBACK = 500;
private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_LOCAL_DATE;
private final EntitlementService entitlementService;
private final TeamBillingService billingService;
private final TeamMembershipRepository memberRepo;
private final PaygTeamExtensionsRepository extRepo;
private final WalletPolicyRepository policyRepo;
private final WalletLedgerRepository ledgerRepo;
private final PaygShadowChargeRepository shadowRepo;
private final UserRepository userRepository;
public PaygWalletController(
EntitlementService entitlementService,
TeamBillingService billingService,
TeamMembershipRepository memberRepo,
PaygTeamExtensionsRepository extRepo,
WalletPolicyRepository policyRepo,
WalletLedgerRepository ledgerRepo,
PaygShadowChargeRepository shadowRepo,
UserRepository userRepository) {
this.entitlementService = Objects.requireNonNull(entitlementService, "entitlementService");
this.billingService = Objects.requireNonNull(billingService, "billingService");
this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
this.extRepo = Objects.requireNonNull(extRepo, "extRepo");
this.policyRepo = Objects.requireNonNull(policyRepo, "policyRepo");
this.ledgerRepo = Objects.requireNonNull(ledgerRepo, "ledgerRepo");
this.shadowRepo = Objects.requireNonNull(shadowRepo, "shadowRepo");
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
}
// ---------------------------------------------------------------------------------------
// GET /wallet — the single FE fetch
// ---------------------------------------------------------------------------------------
@GetMapping("/wallet")
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public ResponseEntity<WalletSnapshotResponse> getWallet(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
// SecurityException maps to 401 per the existing controller convention.
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Optional<TeamMembership> primary = primaryMembership(user.getId());
if (primary.isEmpty()) {
// Authenticated user without a team — shouldn't happen post-migration, but we don't
// want to 500. Return a free-tier-shaped empty snapshot so the FE renders the gated UI
// rather than blowing up on a null body.
return ResponseEntity.ok(emptySnapshot());
}
TeamMembership membership = primary.get();
Long teamId = membership.getTeam().getId();
boolean isLeader = membership.getRole() == TeamRole.LEADER;
// Billing facts (window, free allowance, per-doc rate, doc cap) and the entitlement
// snapshot (period spend over that window) share the same composition service, so what
// the customer sees here is exactly what the 402 guard enforces.
TeamBillingContext billing = billingService.forTeam(teamId);
EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
String status = billing.subscribed() ? STATUS_SUBSCRIBED : STATUS_FREE;
boolean noCap = billing.subscribed() && billing.capMoneyMinor() == null;
Integer capMajor =
billing.capMoneyMinor() != null
? Math.toIntExact(billing.capMoneyMinor() / 100)
: null;
// Per-state by construction (see EntitlementService.computeSnapshot): free team → spend is
// lifetime free used, cap is the grant size; subscribed → spend is this month's net
// billable
// docs, cap is the monthly paid-doc ceiling (null = uncapped).
int spend = clampToInt(snap.periodSpendUnits());
Integer limit = snap.periodCapUnits() != null ? clampToInt(snap.periodCapUnits()) : null;
CategoryBreakdown breakdown = buildBreakdown(teamId, snap.periodStart(), snap.periodEnd());
// Estimated bill = paid (Stripe-metered) docs this period × rate — the free portion was
// already netted out at charge time, so this is the metered total, not spend − grant.
long periodPaid = shadowRepo.sumPaidUnits(teamId, snap.periodStart(), snap.periodEnd());
Long estimatedBill = billingService.estimateBillMinor(billing, periodPaid).orElse(null);
List<MemberRow> members =
isLeader
? buildMemberRows(teamId, snap.periodStart(), snap.periodEnd())
: List.of();
WalletSnapshotResponse body =
new WalletSnapshotResponse(
teamId,
status,
isLeader ? ROLE_LEADER : ROLE_MEMBER,
ISO_DATE.format(snap.periodStart().toLocalDate()),
ISO_DATE.format(snap.periodEnd().toLocalDate()),
spend,
limit,
clampToInt(billing.freeGrantUnits()),
clampToInt(billing.freeRemainingUnits()),
billing.perDocMinor(),
billing.currency(),
estimatedBill,
capMajor,
noCap,
billing.subscriptionId(),
spend,
breakdown,
members,
buildActivity(teamId));
return ResponseEntity.ok(body);
}
private CategoryBreakdown buildBreakdown(
Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) {
Map<BillingCategory, Long> byCategory = new HashMap<>();
for (Object[] row :
ledgerRepo.sumPeriodAmountByCategory(
teamId, LedgerEntryType.DEBIT, periodStart, periodEnd)) {
if (row.length >= 2
&& row[0] instanceof BillingCategory cat
&& row[1] instanceof Number n) {
byCategory.put(cat, n.longValue());
}
}
return new CategoryBreakdown(
clampToInt(byCategory.getOrDefault(BillingCategory.API, 0L)),
clampToInt(byCategory.getOrDefault(BillingCategory.AI, 0L)),
clampToInt(byCategory.getOrDefault(BillingCategory.AUTOMATION, 0L)));
}
/**
* Latest ledger entries shaped for the FE activity feed. DEBITs read as usage, REFUNDs as
* credits-back; system entries without a category render as {@code other}.
*/
private List<ActivityRow> buildActivity(Long teamId) {
List<ActivityRow> out = new ArrayList<>();
for (WalletLedgerEntry e : ledgerRepo.findTop20ByTeamIdOrderByIdDesc(teamId)) {
BillingCategory category = e.getBillingCategory();
String kind = category != null ? category.name().toLowerCase(Locale.ROOT) : "other";
String categoryLabel = category != null ? categoryDisplayName(category) : "Document";
String label =
e.getEntryType() == LedgerEntryType.REFUND
? "Refund — " + categoryLabel
: categoryLabel + " usage";
int docUnits = e.getAmountUnits() == null ? 0 : Math.abs(e.getAmountUnits());
out.add(
new ActivityRow(
e.getId(),
kind,
label,
e.getOccurredAt() != null ? e.getOccurredAt().toString() : "",
docUnits));
}
return out;
}
private static String categoryDisplayName(BillingCategory category) {
return switch (category) {
case API -> "API";
case AI -> "AI";
case AUTOMATION -> "Automation";
case BYPASSED -> "Manual";
};
}
// ---------------------------------------------------------------------------------------
// PATCH /cap — leader-only, cap is application-layer, no Stripe call
// ---------------------------------------------------------------------------------------
@PatchMapping("/cap")
@PreAuthorize("isAuthenticated()")
@Transactional
public ResponseEntity<Void> updateCap(
@Valid @RequestBody UpdateCapRequest req, Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Optional<TeamMembership> primary = primaryMembership(user.getId());
if (primary.isEmpty()) {
// No team → can't have a wallet to cap.
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
TeamMembership membership = primary.get();
if (membership.getRole() != TeamRole.LEADER) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Long teamId = membership.getTeam().getId();
WalletPolicy policy =
policyRepo
.findByTeamId(teamId)
.orElseGet(
() -> {
WalletPolicy created = new WalletPolicy();
created.setTeamId(teamId);
return created;
});
if (req.noCap()) {
policy.setCapUnits(null);
policy.setCapSourceMoney(null);
} else {
long capMinor = CapMoneyUnits.usdToCents(req.capUsd());
policy.setCapSourceMoney(capMinor);
// Derived document allowance (design §10: store both the money intent and the unit
// translation). The live snapshot recomputes from cap_source_money + current rate;
// this stored value is the enforcement fallback when the rate is unreachable.
TeamBillingContext billing = billingService.forTeam(teamId);
Optional<Long> docCap = billingService.docCapForMoney(billing, capMinor);
if (docCap.isPresent()) {
policy.setCapUnits(docCap.get());
} else {
// Rate unknown (price-info fn unconfigured / Stripe blip): keep the legacy
// money-as-units conversion so the cap still binds rather than silently lifting.
log.warn(
"Per-document rate unavailable for team {}; storing legacy cap_units"
+ " conversion.",
teamId);
policy.setCapUnits(CapMoneyUnits.usdToUnits(req.capUsd()));
}
}
policyRepo.save(policy);
entitlementService.invalidate(teamId);
return ResponseEntity.noContent().build();
}
/** Request body for {@link #updateCap}. */
public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {}
// ---------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------
private Optional<TeamMembership> primaryMembership(Long userId) {
List<TeamMembership> rows = memberRepo.findPrimaryMembership(userId);
return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
}
private List<MemberRow> buildMemberRows(
Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) {
List<TeamMembership> all = memberRepo.findByTeamId(teamId);
if (all.isEmpty()) {
return List.of();
}
LocalDateTime[] window = {periodStart, periodEnd};
List<MemberRow> out = new ArrayList<>(all.size());
for (TeamMembership tm : all) {
User u = tm.getUser();
if (u == null) {
continue;
}
// We could batch these; team sizes are small (FE design assumes ≤ ~20 members per
// team on the Plan page) so a per-member sum is fine. If teams grow we'd switch to
// a single GROUP BY actor_user_id query.
long spend = 0L; // sumPeriodAmountForMember stores signed debits (negative); negate.
try {
spend = -memberSpend(teamId, u.getId(), window[0], window[1]);
} catch (RuntimeException e) {
log.warn(
"buildMemberRows: per-member spend lookup failed for user {}",
u.getId(),
e);
}
String displayName =
Optional.ofNullable(u.getUsername())
.orElse(Optional.ofNullable(u.getEmail()).orElse(""));
out.add(
new MemberRow(
Long.toString(u.getId()),
displayName,
Optional.ofNullable(u.getEmail()).orElse(""),
clampToInt(spend)));
}
return out;
}
/**
* Per-member period spend in signed ledger units (debits are negative). Helper so the test
* slice can override without standing up a real database, and so the controller doesn't inline
* the negation arithmetic at every call site.
*/
long memberSpend(Long teamId, Long userId, LocalDateTime start, LocalDateTime end) {
return ledgerRepo.sumPeriodAmountForMember(
teamId, userId, LedgerEntryType.DEBIT, start, end);
}
private static LocalDateTime[] currentMonthWindow() {
java.time.YearMonth ym = java.time.YearMonth.now();
LocalDateTime start = ym.atDay(1).atStartOfDay();
LocalDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay();
return new LocalDateTime[] {start, end};
}
private static int clampToInt(long v) {
if (v <= 0) return 0;
if (v >= Integer.MAX_VALUE) return Integer.MAX_VALUE;
return (int) v;
}
private WalletSnapshotResponse emptySnapshot() {
LocalDateTime[] window = currentMonthWindow();
return new WalletSnapshotResponse(
null, // teamId — unknown when the caller has no team membership
STATUS_FREE,
ROLE_MEMBER,
ISO_DATE.format(window[0].toLocalDate()),
ISO_DATE.format(window[1].toLocalDate()),
0,
FREE_TIER_LIMIT_UNITS_FALLBACK,
FREE_TIER_LIMIT_UNITS_FALLBACK,
FREE_TIER_LIMIT_UNITS_FALLBACK,
null,
null,
null,
null,
false,
null,
0,
new CategoryBreakdown(0, 0, 0),
List.of(),
Collections.emptyList());
}
}
@@ -0,0 +1,101 @@
package stirling.software.saas.payg.api;
import java.math.BigDecimal;
import java.util.List;
/**
* JSON payload returned by {@code GET /api/v1/payg/wallet}. Mirrors the {@code Wallet} type the
* frontend {@code useWallet} hook consumes, plus the leader-only fields ({@code members},
* breakdowns, recent activity) used by the PAYG Plan page.
*
* <p>Every number is real: the billing window is the Stripe subscription's current period (via Sync
* Engine) for subscribed teams, the one-time free grant size comes from {@code
* pricing_policy.free_tier_units} (live balance from {@code
* payg_team_extensions.free_units_remaining}), and the per-document rate comes from the
* subscription's Stripe Price. Fields that can't be resolved are {@code null} and the FE renders
* "unknown" — never a substituted default.
*
* @param teamId the caller's primary team_id. Needed by the frontend so it can pass it to the
* Supabase edge functions that create Stripe Checkout / portal sessions — those run outside
* Spring Security and have no other way to resolve the caller's team.
* @param status {@code "free"} when the team has no Stripe subscription; {@code "subscribed"} once
* a card is on file and the engine bills meter events.
* @param role the current caller's role within their team — {@code "leader"} or {@code "member"}.
* Controls which UI variant the frontend renders.
* @param billingPeriodStart inclusive ISO date (yyyy-MM-dd) for the current cycle — the Stripe
* subscription period when subscribed, the calendar month otherwise.
* @param billingPeriodEnd exclusive ISO date (yyyy-MM-dd) for the current cycle.
* @param billableUsed alias of {@code spendUnitsThisPeriod} kept for clarity in the FE. For a free
* team this is the lifetime free documents used so far ({@code freeAllowance − freeRemaining});
* for a subscribed team it's this month's net billable documents.
* @param billableLimit the team's document ceiling for the matching window: the one-time free grant
* ({@code freeAllowance}) for free teams; {@code floor(cap / perDocRate)} paid docs/month for
* capped subscribed teams; {@code null} when subscribed with no cap (uncapped).
* @param freeAllowance the team's one-time free document grant size (the "N" in "X of N free").
* Never resets; survives subscribing. Applies to billable categories only.
* @param freeRemaining one-time free documents still available to the team ({@code
* payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
* @param pricePerDocMinor paid per-document rate in minor units of {@code currency} (may be
* fractional — Stripe supports sub-cent rates); {@code null} when the rate can't be resolved.
* @param currency lower-case ISO 4217 currency of the subscription's Stripe Price; {@code null}
* when unknown (free teams, unresolved rate).
* @param estimatedBillMinor estimated charges so far this period in minor units of {@code
* currency}: paid (Stripe-metered) documents this period × {@code pricePerDocMinor}. The free
* portion was already netted out at charge time. Informational — the Stripe invoice is
* authoritative. {@code null} when the rate is unknown.
* @param capUsd the leader's monthly spending cap in major currency units; {@code null} when free
* or when the leader has opted into no-cap. (Field name predates multi-currency; the FE pairs
* it with {@code currency} for the symbol.)
* @param noCap {@code true} when the leader has explicitly disabled the cap. Only meaningful when
* subscribed.
* @param stripeSubscriptionId Stripe subscription id from {@code
* payg_team_extensions.payg_subscription_id}; {@code null} when status is free.
* @param spendUnitsThisPeriod documents debited this cycle across billable categories.
* @param categoryBreakdown per-category spend slice over the same billing window.
* @param members leader-only roster of team members + their per-member sub-caps. Empty for member
* callers.
* @param recent latest wallet-ledger entries (newest first) for the activity feed.
*/
public record WalletSnapshotResponse(
Long teamId,
String status,
String role,
String billingPeriodStart,
String billingPeriodEnd,
int billableUsed,
Integer billableLimit,
int freeAllowance,
int freeRemaining,
BigDecimal pricePerDocMinor,
String currency,
Long estimatedBillMinor,
Integer capUsd,
boolean noCap,
String stripeSubscriptionId,
int spendUnitsThisPeriod,
CategoryBreakdown categoryBreakdown,
List<MemberRow> members,
List<ActivityRow> recent) {
/** Per-category breakdown of {@code spendUnitsThisPeriod} for the in-app analytics widget. */
public record CategoryBreakdown(int api, int ai, int automation) {}
/**
* One row of the team-members table on the leader's Plan page — display-only per-member usage.
* (Per-member sub-caps aren't enforced yet; see the follow-ups note. When they ship, a cap
* field returns here.)
*/
public record MemberRow(String userId, String name, String email, int spendUnits) {}
/**
* One wallet-ledger entry shaped for the FE activity feed.
*
* @param id ledger entry id (stable React key)
* @param kind lower-case billing category ({@code api} / {@code ai} / {@code automation}) or
* {@code other} for system entries
* @param label human line, e.g. {@code "API usage"} or {@code "Refund — API"}
* @param ts ISO-8601 local timestamp of the entry
* @param docUnits absolute document count of the entry
*/
public record ActivityRow(long id, String kind, String label, String ts, int docUnits) {}
}
@@ -0,0 +1,47 @@
package stirling.software.saas.payg.billing;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* One team's billing facts, composed by {@link TeamBillingService}. Two independent meters live
* here and must not be conflated:
*
* <ul>
* <li>the <b>one-time lifetime free grant</b> ({@link #freeGrantUnits} total, {@link
* #freeRemainingUnits} left) — gates an un-subscribed team and decides the free-vs-paid split
* of every job; never resets, survives subscribing;
* <li>the <b>monthly billing window</b> ({@link #periodStart}/{@link #periodEnd}) and the
* optional monthly spending cap ({@link #monthlyCapDocUnits}) — govern the subscribed invoice
* + cap only.
* </ul>
*
* @param subscribed team has a live PAYG subscription — i.e. {@code payg_subscription_id} is set.
* Cleared by {@code payg_unlink_subscription} on cancellation, so a cancelled team reads false.
* @param subscriptionId {@code payg_team_extensions.payg_subscription_id}; null when free
* @param periodStart inclusive start of the monthly billing window — the Stripe subscription's
* current period when subscribed, calendar month otherwise
* @param periodEnd exclusive end of the monthly billing window
* @param freeGrantUnits the team's one-time free grant size (policy {@code free_tier_units}); the
* denominator for "used X of N free". Never resets.
* @param freeRemainingUnits one-time free documents still available ({@code
* payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
* @param perDocMinor paid per-document rate in minor units of {@link #currency()}; null when the
* rate can't be resolved (free team, price row unsynced) — display "unknown", never substitute
* @param currency lower-case ISO 4217 of the subscription's Price; null when unknown
* @param capMoneyMinor leader-set monthly spending cap in minor units ({@code
* wallet_policy.cap_source_money}); null = no cap configured
* @param monthlyCapDocUnits the subscribed monthly paid-document ceiling — {@code floor(capMoney /
* perDocRate)}; null = uncapped, or the team is not subscribed
*/
public record TeamBillingContext(
boolean subscribed,
String subscriptionId,
LocalDateTime periodStart,
LocalDateTime periodEnd,
long freeGrantUnits,
long freeRemainingUnits,
BigDecimal perDocMinor,
String currency,
Long capMoneyMinor,
Long monthlyCapDocUnits) {}
@@ -0,0 +1,272 @@
package stirling.software.saas.payg.billing;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
import stirling.software.saas.payg.policy.PricingPolicy;
import stirling.software.saas.payg.policy.PricingPolicyService;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.repository.WalletPolicyRepository;
import stirling.software.saas.payg.stripe.StripeSubscriptionDao;
import stirling.software.saas.payg.stripe.StripeSubscriptionDao.PriceRate;
import stirling.software.saas.payg.stripe.StripeSubscriptionDao.SubscriptionBilling;
import stirling.software.saas.payg.wallet.WalletPolicy;
/**
* Single composition point for "what does billing look like for this team right now." Both the
* entitlement hot path and the wallet endpoint read from here, so what the customer sees is what
* the guard enforces.
*
* <p>Two independent meters (design 2026-06-11 — the free allowance is a one-time lifetime grant):
*
* <ul>
* <li><b>Free grant</b> — one-time, per team. Size from {@code pricing_policy.free_tier_units};
* live balance from the {@code payg_team_extensions.free_units_remaining} counter (maintained
* by the charge pipeline). Never resets, survives subscribing. Gates un-subscribed teams and
* drives the free-vs-paid split.
* <li><b>Monthly window + cap</b> — the Stripe subscription period (calendar month otherwise) and
* the optional money cap. Govern the subscribed invoice + spending cap only. The per-document
* rate is the synced {@code stripe.prices.unit_amount} (PAYG prices are plain per-unit).
* </ul>
*
* <p>Cached per team for {@value #CACHE_TTL_SECONDS}s. {@code EntitlementService.invalidate}
* cascades into {@link #invalidate(Long)} so both caches drop together on cap edits / webhooks.
* Note the cached context's {@code freeRemainingUnits} is a 30s-stale read of the counter — the
* authoritative decrement happens in {@code JobChargeService} against the row directly; this cache
* is for display + the entitlement gate, where 30s staleness is the accepted cap-evaluation floor.
*/
@Slf4j
@Service
@Profile("saas")
public class TeamBillingService {
static final int CACHE_TTL_SECONDS = 30;
private static final int CACHE_MAX_SIZE = 10_000;
/**
* In-app display/estimate currency. The app prices in dollars; Stripe handles real currency
* selection at checkout. Used to pick the right Price for un-subscribed teams.
*/
private static final String DISPLAY_CURRENCY = "usd";
/**
* Stripe Price {@code lookup_key} for the PAYG per-document price. The stable handle we resolve
* an un-subscribed team's rate from (the default policy carries no price ids in the seed).
*/
private static final String PAYG_LOOKUP_KEY = "plan:processor";
private final PaygTeamExtensionsRepository extensionsRepository;
private final WalletPolicyRepository walletPolicyRepository;
private final PricingPolicyService pricingPolicyService;
private final StripeSubscriptionDao subscriptionDao;
private final Cache<Long, TeamBillingContext> cache;
public TeamBillingService(
PaygTeamExtensionsRepository extensionsRepository,
WalletPolicyRepository walletPolicyRepository,
PricingPolicyService pricingPolicyService,
StripeSubscriptionDao subscriptionDao) {
this.extensionsRepository =
Objects.requireNonNull(extensionsRepository, "extensionsRepository");
this.walletPolicyRepository =
Objects.requireNonNull(walletPolicyRepository, "walletPolicyRepository");
this.pricingPolicyService =
Objects.requireNonNull(pricingPolicyService, "pricingPolicyService");
this.subscriptionDao = Objects.requireNonNull(subscriptionDao, "subscriptionDao");
this.cache =
Caffeine.newBuilder()
.maximumSize(CACHE_MAX_SIZE)
.expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS))
.build();
}
public TeamBillingContext forTeam(Long teamId) {
Objects.requireNonNull(teamId, "teamId");
return cache.get(teamId, this::compute);
}
/** Drop {@code teamId}'s entry after cap edits / subscription webhooks / grant consumption. */
public void invalidate(Long teamId) {
if (teamId != null) {
cache.invalidate(teamId);
}
}
private TeamBillingContext compute(Long teamId) {
Optional<PaygTeamExtensions> extOpt = extensionsRepository.findById(teamId);
Optional<WalletPolicy> walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId);
String subscriptionId = extOpt.map(PaygTeamExtensions::getPaygSubscriptionId).orElse(null);
// payg_subscription_id is the single subscription switch. payg_link_subscription sets it
// (alongside stripe_customer_id, in the same write) on customer.subscription.created;
// payg_unlink_subscription nulls it on customer.subscription.deleted while deliberately
// keeping stripe_customer_id so a future re-subscribe can reuse the Stripe customer. So a
// cancelled team has a null subscription id and must read as free again.
//
// We deliberately do NOT fall back to stripe_customer_id presence. payg_link_subscription
// is the only writer of that column and it writes it together with the subscription id, so
// it can never be set "before the webhook lands" — there is no gap for it to bridge. A
// customer-id fallback would instead keep every team that ever subscribed pinned to
// subscribed forever (the customer outlives the subscription), which is the cancelled-team
// bug this guards against.
boolean subscribed = subscriptionId != null;
long freeGrant = resolveGrant(teamId);
long freeRemaining =
extOpt.map(PaygTeamExtensions::getFreeUnitsRemaining)
.map(Long::longValue)
.orElse(0L);
Optional<SubscriptionBilling> billing =
subscriptionId != null
? subscriptionDao.findBilling(subscriptionId)
: Optional.empty();
LocalDateTime[] window =
billing.map(b -> new LocalDateTime[] {b.periodStart(), b.periodEnd()})
.orElseGet(TeamBillingService::calendarMonthWindow);
BigDecimal perDocMinor = billing.map(SubscriptionBilling::perDocMinor).orElse(null);
String currency = billing.map(SubscriptionBilling::currency).orElse(null);
// Un-subscribed teams have no Stripe subscription to read a rate from, but the cap
// estimate (the upgrade flow's "≈ N paid PDFs/month") still needs one. Resolve it from
// the default policy's USD Price — Stripe hasn't assigned the team a currency yet, and
// the whole app prices in dollars. Display-only: resolveMonthlyCap stays gated on
// `subscribed`, so this never starts enforcing a cap on a free team.
if (!subscribed && perDocMinor == null) {
Optional<PriceRate> rate =
subscriptionDao.findRateByLookupKey(PAYG_LOOKUP_KEY, DISPLAY_CURRENCY);
if (rate.isPresent()) {
perDocMinor = rate.get().perDocMinor();
currency = rate.get().currency();
}
}
Long capMoneyMinor = walletPolicyOpt.map(WalletPolicy::getCapSourceMoney).orElse(null);
Long legacyCapUnits = walletPolicyOpt.map(WalletPolicy::getCapUnits).orElse(null);
Long monthlyCapDocUnits =
resolveMonthlyCap(subscribed, capMoneyMinor, legacyCapUnits, perDocMinor);
return new TeamBillingContext(
subscribed,
subscriptionId,
window[0],
window[1],
freeGrant,
freeRemaining,
perDocMinor,
currency,
capMoneyMinor,
monthlyCapDocUnits);
}
/** The policy grant size — the "N" denominator for display; the counter is the live balance. */
private long resolveGrant(Long teamId) {
try {
PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId);
Long grant = policy.getFreeTierUnits();
return grant == null ? 0L : grant;
} catch (RuntimeException e) {
log.warn("No effective pricing policy for team {}: {}", teamId, e.getMessage());
return 0L;
}
}
/**
* The subscribed monthly paid-document ceiling; {@code null} = uncapped or not subscribed. The
* one-time free grant is NOT added here — it's a separate lifetime pool consumed at charge
* time. The cap purely limits how many paid documents the team will fund per billing period.
*
* <ul>
* <li>not subscribed → null (the free grant, not a money cap, is what bounds them);
* <li>subscribed, no money cap → uncapped (null), unless an admin set raw {@code cap_units};
* <li>subscribed, money cap + known rate → {@code floor(capMoney / perDocRate)};
* <li>subscribed, money cap but rate unknown → stored {@code cap_units} fallback (WARN).
* </ul>
*/
private Long resolveMonthlyCap(
boolean subscribed, Long capMoneyMinor, Long legacyCapUnits, BigDecimal perDocMinor) {
if (!subscribed) {
return null;
}
if (capMoneyMinor == null) {
return legacyCapUnits; // admin-set unit cap (source money null) still applies
}
if (perDocMinor != null && perDocMinor.signum() > 0) {
return BigDecimal.valueOf(capMoneyMinor)
.divide(perDocMinor, 0, RoundingMode.FLOOR)
.longValue();
}
log.warn(
"Per-document rate unavailable; enforcing stored cap_units fallback ({}).",
legacyCapUnits);
return legacyCapUnits;
}
/**
* Estimated charges for the current period in minor units of {@link
* TeamBillingContext#currency()}: the paid (metered) documents this period at the per-document
* rate. Informational — the Stripe invoice is authoritative. Empty when the rate is unknown.
*
* @param paidUnitsThisPeriod metered documents this period ({@code payg_units −
* free_units_consumed} summed over the period's charged jobs)
*/
public Optional<Long> estimateBillMinor(TeamBillingContext ctx, long paidUnitsThisPeriod) {
if (ctx.perDocMinor() == null) {
return Optional.empty();
}
long paid = Math.max(0, paidUnitsThisPeriod);
BigDecimal bill =
ctx.perDocMinor()
.multiply(BigDecimal.valueOf(paid))
.setScale(0, RoundingMode.HALF_UP);
return Optional.of(bill.longValue());
}
/**
* Documents a hypothetical monthly money cap would buy: {@code floor(capMinor / rate)}. Used by
* the cap editor's live preview and the {@code PATCH /cap} derived write. The free grant is NOT
* added — it's a separate one-time pool. Empty when the rate is unknown.
*/
public Optional<Long> docCapForMoney(TeamBillingContext ctx, long capMinor) {
if (ctx.perDocMinor() == null || ctx.perDocMinor().signum() <= 0) {
return Optional.empty();
}
return Optional.of(
BigDecimal.valueOf(capMinor)
.divide(ctx.perDocMinor(), 0, RoundingMode.FLOOR)
.longValue());
}
/**
* Inclusive-start / exclusive-end window for the calendar month — the monthly billing window
* used when there's no Stripe subscription period to anchor on.
*/
static LocalDateTime[] calendarMonthWindow() {
return calendarMonthWindow(LocalDateTime.now());
}
/** Test seam — accepts a clock value so tests don't race the calendar boundary. */
static LocalDateTime[] calendarMonthWindow(LocalDateTime now) {
java.time.YearMonth ym = java.time.YearMonth.from(now);
return new LocalDateTime[] {
ym.atDay(1).atStartOfDay(), ym.plusMonths(1).atDay(1).atStartOfDay()
};
}
}
@@ -0,0 +1,42 @@
package stirling.software.saas.payg.cap;
import org.springframework.web.servlet.HandlerMapping;
import jakarta.servlet.http.HttpServletRequest;
/**
* Central definition of the AI document-tool route namespace ({@code /api/v1/ai/tools/**}).
*
* <p>These tools (e.g. {@code PdfCommentAgentController}, {@code MathAuditorAgentController}) live
* in the {@code proprietary} module, which does not depend on {@code saas} and therefore cannot
* carry the saas-only {@link RequiresFeature} annotation. Rather than weaken the layering, the PAYG
* hot-path components recognise the path prefix instead:
*
* <ul>
* <li>{@code PaygChargeInterceptor} brings these routes into scope and bills them as {@code
* BillingCategory.AI} on a direct call (an orchestrator-dispatched call still resolves to
* AUTOMATION first, via the {@code X-Stirling-Automation} header);
* <li>{@code EntitlementGuard} gates them on {@link
* stirling.software.saas.payg.model.FeatureGate#AI_SUPPORT}.
* </ul>
*
* <p>Kept as a single source of truth so the interceptor and the guard can never drift on what
* counts as an AI tool.
*/
public final class AiToolRoutes {
/** Trailing slash so it matches the tool sub-paths, not a bare {@code /api/v1/ai/tools}. */
public static final String PREFIX = "/api/v1/ai/tools/";
private AiToolRoutes() {}
/**
* True when the request resolved to an AI document-tool endpoint. Prefers the matched route
* pattern (context-path independent, set by Spring MVC) and falls back to the raw request URI.
*/
public static boolean matches(HttpServletRequest request) {
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
String path = pattern instanceof String s ? s : request.getRequestURI();
return path != null && path.startsWith(PREFIX);
}
}
@@ -0,0 +1,104 @@
package stirling.software.saas.payg.cap;
import java.util.List;
import stirling.software.saas.payg.model.EntitlementState;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.FeatureSet;
/**
* Pure-compute cap evaluation. Given a team's (or member's) spend, cap, and warn / degrade
* thresholds, returns the {@link EntitlementState}, the {@link FeatureSet} that should be in
* effect, and the corresponding enabled {@link FeatureGate}s. No DB access, no caches — the caller
* (entitlement service) supplies the inputs.
*
* <p>State transitions (matching {@code notes/PAYG_DESIGN.md} §3.6):
*
* <ul>
* <li>{@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} unconditionally.
* <li>{@code spend / cap &lt; warnPct} → {@code FULL}.
* <li><b>MINIMAL semantics:</b> under DEGRADED+MINIMAL manual server-side tools (gated by {@link
* FeatureGate#OFFSITE_PROCESSING}) and client-side tools still work; only {@link
* FeatureGate#AUTOMATION} and {@link FeatureGate#AI_SUPPORT} are blocked.
* <li>{@code warnPct ≤ spend / cap &lt; degradePct} → {@code WARNED}; feature set still {@link
* FeatureSet#FULL} — the warn band is a notification trigger, not a degradation.
* <li>{@code spend / cap ≥ degradePct} → {@code DEGRADED}; feature set drops to the policy's
* configured {@code degradedFeatureSet} (default {@link FeatureSet#MINIMAL}).
* </ul>
*
* <p>The percentage compare is integer math. We multiply spend by 100 before dividing — this keeps
* the precision and avoids floating-point on the hot path. Spend × 100 can overflow long at 9.2e16
* units, which is not a realistic value (would represent quintillions of charged documents); we
* don't guard against it.
*/
public final class CapEvaluator {
private CapEvaluator() {}
/**
* Snapshot of one cap evaluation. The caller persists this into the appropriate {@code
* wallet_entitlement_snapshot} row (team-wide or per-member).
*/
public record Evaluation(
EntitlementState state, FeatureSet featureSet, List<FeatureGate> enabledGates) {}
public static Evaluation evaluate(
long spendUnits,
Long capUnits,
int warnAtPct,
int degradeAtPct,
FeatureSet degradedFeatureSet) {
if (capUnits == null || capUnits <= 0) {
return full();
}
if (warnAtPct < 0 || degradeAtPct <= 0 || degradeAtPct < warnAtPct) {
// Defensive: misconfigured thresholds → treat as no-cap-effect to avoid surprise
// degradation. The admin endpoints that set the policy should validate; this
// protects the hot path from a bad row sneaking through.
return full();
}
// pct = floor((spend * 100) / cap). Integer arithmetic on the hot path.
long pct = (spendUnits * 100L) / capUnits;
if (pct >= degradeAtPct) {
FeatureSet effective =
degradedFeatureSet != null ? degradedFeatureSet : FeatureSet.MINIMAL;
return new Evaluation(EntitlementState.DEGRADED, effective, gatesFor(effective));
}
if (pct >= warnAtPct) {
// Warn band: still FULL feature set, but state flag is set so the FE can show a
// banner / send a notification. The wallet service emits a
// WalletEntitlementChanged event when state transitions; subscribers (email
// reminder, SSE to FE) act on that.
return new Evaluation(
EntitlementState.WARNED, FeatureSet.FULL, gatesFor(FeatureSet.FULL));
}
return full();
}
/**
* Default enabled gates for a given feature set. Kept in sync with the design doc §3.7 mapping
* table.
*/
public static List<FeatureGate> gatesFor(FeatureSet set) {
if (set == null) {
return List.of();
}
return switch (set) {
case FULL ->
List.of(
FeatureGate.OFFSITE_PROCESSING,
FeatureGate.AUTOMATION,
FeatureGate.AI_SUPPORT,
FeatureGate.CLIENT_SIDE);
case MINIMAL -> List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
case CLIENT_ONLY -> List.of(FeatureGate.CLIENT_SIDE);
};
}
private static Evaluation full() {
return new Evaluation(EntitlementState.FULL, FeatureSet.FULL, gatesFor(FeatureSet.FULL));
}
}
@@ -0,0 +1,46 @@
package stirling.software.saas.payg.cap;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import stirling.software.saas.payg.model.FeatureGate;
/**
* Declares which {@link FeatureGate}(s) a controller method requires. Read at request time by
* {@code EntitlementGuard}; if any required gate is not in the team's currently-enabled gates the
* request is rejected with HTTP 402.
*
* <p>The annotation is <em>not required</em> on every endpoint. The guard's default rule is:
*
* <ul>
* <li>{@code @RequiresFeature} present → use exactly those gates.
* <li>No annotation, but the method has {@code @AutoJobPostMapping} → assume {@link
* FeatureGate#OFFSITE_PROCESSING}.
* <li>Neither → skip (admin endpoints, info, config — these don't accrue charges and shouldn't
* degrade).
* </ul>
*
* <p>So the only endpoints that need this annotation explicitly are those whose gate is
* <em>different</em> from the default {@code OFFSITE_PROCESSING} — chiefly {@code
* PipelineController} ({@link FeatureGate#AUTOMATION}) and the AI proxy layer ({@link
* FeatureGate#AI_SUPPORT}). Per-tool proliferation of the annotation is intentional non-goal.
*
* <p>Multiple gates declared = ALL must be enabled (AND, not OR). Realistic usage is single-gate;
* the array form is here for future combinations (e.g. an AI workflow inside a pipeline that needs
* both {@code AUTOMATION} and {@code AI_SUPPORT}).
*
* <pre>{@code
* @RequiresFeature(FeatureGate.AUTOMATION)
* @AutoJobPostMapping("/pipeline")
* public ResponseEntity<...> runPipeline(@ModelAttribute PipelineRequest req) { ... }
* }</pre>
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresFeature {
/** One or more gates that must all be enabled for the request to proceed. */
FeatureGate[] value();
}
@@ -1,5 +1,6 @@
package stirling.software.saas.payg.charge;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ProcessType;
@@ -8,9 +9,18 @@ import stirling.software.saas.payg.model.ProcessType;
* kind of process this is. Does NOT carry policy fields — the charge service resolves the effective
* policy from {@code PricingPolicyService} so a stale snapshot from the caller can't desync from
* the live policy.
*
* <p>{@code billingCategory} is the analytics axis for ledger + shadow rows and is determined by
* the interceptor before this context is built. Manual UI tools never reach {@code openProcess}
* (they short-circuit on {@link BillingCategory#BYPASSED}); any context constructed here therefore
* carries one of {@code API}, {@code AI}, or {@code AUTOMATION}.
*/
public record ChargeContext(
Long ownerUserId, Long ownerTeamId, JobSource source, ProcessType processType) {
Long ownerUserId,
Long ownerTeamId,
JobSource source,
ProcessType processType,
BillingCategory billingCategory) {
public ChargeContext {
if (ownerUserId == null) {
@@ -22,5 +32,8 @@ public record ChargeContext(
if (processType == null) {
throw new IllegalArgumentException("processType is required");
}
if (billingCategory == null) {
throw new IllegalArgumentException("billingCategory is required");
}
}
}
@@ -11,6 +11,8 @@ import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
@@ -21,14 +23,23 @@ import stirling.software.saas.payg.job.JobContext;
import stirling.software.saas.payg.job.JobService;
import stirling.software.saas.payg.job.JoinOrOpenResult;
import stirling.software.saas.payg.job.ProcessingJob;
import stirling.software.saas.payg.meter.PaygMeterReportingService;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.JobStatus;
import stirling.software.saas.payg.model.LedgerBucket;
import stirling.software.saas.payg.model.LedgerEntryType;
import stirling.software.saas.payg.model.ReferenceType;
import stirling.software.saas.payg.model.ShadowChargeStatus;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
import stirling.software.saas.payg.policy.PricingPolicy;
import stirling.software.saas.payg.policy.PricingPolicyService;
import stirling.software.saas.payg.repository.PaygShadowChargeRepository;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.repository.ProcessingJobRepository;
import stirling.software.saas.payg.repository.WalletLedgerRepository;
import stirling.software.saas.payg.shadow.PaygShadowCharge;
import stirling.software.saas.payg.wallet.WalletLedgerEntry;
/**
* Orchestrates a tool call's open-process decision: look up the team's effective policy, resolve
@@ -55,18 +66,29 @@ public class JobChargeService {
private final DocumentClassifier classifier;
private final PaygShadowChargeRepository shadowRepository;
private final ProcessingJobRepository jobRepository;
private final PaygTeamExtensionsRepository teamExtensionsRepository;
private final PaygMeterReportingService meterReportingService;
private final WalletLedgerRepository ledgerRepository;
public JobChargeService(
JobService jobService,
PricingPolicyService policyService,
DocumentClassifier classifier,
PaygShadowChargeRepository shadowRepository,
ProcessingJobRepository jobRepository) {
ProcessingJobRepository jobRepository,
PaygTeamExtensionsRepository teamExtensionsRepository,
PaygMeterReportingService meterReportingService,
WalletLedgerRepository ledgerRepository) {
this.jobService = Objects.requireNonNull(jobService, "jobService");
this.policyService = Objects.requireNonNull(policyService, "policyService");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.shadowRepository = Objects.requireNonNull(shadowRepository, "shadowRepository");
this.jobRepository = Objects.requireNonNull(jobRepository, "jobRepository");
this.teamExtensionsRepository =
Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
this.meterReportingService =
Objects.requireNonNull(meterReportingService, "meterReportingService");
this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository");
}
/**
@@ -104,11 +126,115 @@ public class JobChargeService {
int units = computeUnits(inputs, policy);
result.job().setDocUnits(units);
recordShadowRow(ctx, result.job().getId(), policy.getId(), units);
int freeUsed = consumeFreeGrant(ctx, units);
recordShadowRow(ctx, result.job().getId(), policy.getId(), units, freeUsed);
recordLedgerDebit(ctx, result.job().getId(), policy.getId(), units);
return new ChargeOutcome(result.job().getId(), units, ChargeOutcome.Disposition.OPENED);
}
/**
* Charge a fixed number of units for a billable action that isn't file/lineage-driven — e.g. an
* AI Create session, billed once per document at session creation. Opens a standalone
* bookkeeping job (no lineage inputs, so follow-up calls never lineage-join it), draws the
* free-grant split, and writes the shadow + ledger rows exactly as {@link #openProcess} does,
* then closes the job so the paid portion meters to Stripe via the same {@code afterCommit}
* path and idempotency key ({@code process:<jobId>:close}).
*
* <p>Each call is independent: there is no join/dedup, so two sessions charge twice (correct —
* each is a distinct document). The caller passes the unit count; the policy {@code
* minChargeUnits} floor still applies. Must not be called for {@link BillingCategory#BYPASSED}.
*
* @return the bookkeeping job id (mostly useful for tests / tracing)
*/
@Transactional
public UUID chargeStandalone(ChargeContext ctx, int units) {
Objects.requireNonNull(ctx, "ctx");
if (ctx.billingCategory() == BillingCategory.BYPASSED) {
throw new IllegalArgumentException("chargeStandalone must not be called for BYPASSED");
}
PricingPolicy policy = policyService.getEffectivePolicy(ctx.ownerTeamId());
int chargeUnits = Math.max(units, policy.getMinChargeUnits());
int stepLimit = resolveStepLimit(policy, ctx.source());
JobContext jobCtx =
new JobContext(
ctx.ownerUserId(),
ctx.ownerTeamId(),
ctx.source(),
ctx.processType(),
policy.getId(),
stepLimit);
ProcessingJob job = jobService.open(jobCtx, chargeUnits);
int freeUsed = consumeFreeGrant(ctx, chargeUnits);
recordShadowRow(ctx, job.getId(), policy.getId(), chargeUnits, freeUsed);
recordLedgerDebit(ctx, job.getId(), policy.getId(), chargeUnits);
// Close immediately — nothing will lineage-join a standalone job — so the paid portion
// meters via the same afterCommit hook + idempotency key as a normal process completion.
close(job.getId());
return job.getId();
}
/**
* Draw this job's free portion from the team's one-time lifetime grant, atomically, and return
* the units taken (0..{@code units}); the remainder is the paid portion that will be metered to
* Stripe. Runs inside {@code openProcess}'s transaction with a pessimistic row lock so
* concurrent same-team charges split the grant exactly — no two jobs can both claim the last
* free unit. The grant is a soft floor: it never goes below 0, and the single job that crosses
* the boundary takes whatever's left (its remaining units bill). Skipped for non-billable /
* team-less calls (BYPASSED never reaches openProcess; guarded defensively).
*/
private int consumeFreeGrant(ChargeContext ctx, int units) {
BillingCategory category = ctx.billingCategory();
if (category == null || category == BillingCategory.BYPASSED || ctx.ownerTeamId() == null) {
return 0;
}
Optional<PaygTeamExtensions> extOpt =
teamExtensionsRepository.findByIdForUpdate(ctx.ownerTeamId());
if (extOpt.isEmpty()) {
return 0;
}
PaygTeamExtensions ext = extOpt.get();
long remaining = ext.getFreeUnitsRemaining() == null ? 0L : ext.getFreeUnitsRemaining();
int freeUsed = (int) Math.min(units, Math.max(0L, remaining));
if (freeUsed > 0) {
ext.setFreeUnitsRemaining(remaining - freeUsed);
teamExtensionsRepository.save(ext);
}
return freeUsed;
}
/**
* The live spend record. Everything the customer-facing side reads — the wallet endpoint's
* {@code spendUnitsThisPeriod}, the per-category breakdown ({@code wallet_category_summary}
* view), and the cap evaluator's period sum — derives from {@code wallet_ledger} DEBITs. Shadow
* rows are the comparison audit trail; this row is what actually counts.
*
* <p>Sign convention: debits are stored NEGATIVE (the entitlement snapshot negates the sum).
* Skipped for {@code BYPASSED} / uncategorised calls — manual UI work is never billed.
*/
private void recordLedgerDebit(
ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) {
BillingCategory category = ctx.billingCategory();
if (category == null || category == BillingCategory.BYPASSED) {
return;
}
WalletLedgerEntry entry = new WalletLedgerEntry();
entry.setTeamId(ctx.ownerTeamId());
entry.setActorUserId(ctx.ownerUserId());
entry.setEntryType(LedgerEntryType.DEBIT);
entry.setBucket(LedgerBucket.CYCLE);
entry.setAmountUnits(-units);
entry.setReferenceType(ReferenceType.JOB);
entry.setReferenceId(jobId.toString());
entry.setPolicyId(policyId);
entry.setBillingCategory(category);
ledgerRepository.save(entry);
}
private int resolveStepLimit(PricingPolicy policy, JobSource source) {
Integer fromPolicy =
policy.getStepLimits() == null ? null : policy.getStepLimits().get(source);
@@ -142,17 +268,28 @@ public class JobChargeService {
}
private void recordShadowRow(
ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) {
ChargeContext ctx,
java.util.UUID jobId,
Long policyId,
int units,
int freeUnitsConsumed) {
PaygShadowCharge row = new PaygShadowCharge();
row.setTeamId(ctx.ownerTeamId());
row.setJobId(jobId);
row.setPolicyId(policyId);
row.setPaygUnits(units);
// Free-vs-paid split fixed at charge time: paid (metered) = paygUnits - freeUnitsConsumed,
// and a refund restores freeUnitsConsumed to the team's grant.
row.setFreeUnitsConsumed(freeUnitsConsumed);
// No legacy comparison yet — wired when the shadow path is connected to the legacy
// CreditService in the follow-up PR. Until then, diff stays at 0.
row.setLegacyCreditsCharged(0);
row.setDiffPct(0);
row.setStatus(ShadowChargeStatus.CHARGED);
// PAYG analytics axis + caller surface — copied from ctx so the row stays self-describing
// after processing_job is pruned. Never affects what Stripe meters (single flat meter).
row.setBillingCategory(ctx.billingCategory());
row.setJobSource(ctx.source());
shadowRepository.save(row);
}
@@ -182,6 +319,31 @@ public class JobChargeService {
row.setRefundedAt(now);
row.setRefundReason(trimReason(refundReason));
shadowRepository.save(row);
// Compensate the live ledger DEBIT written at openProcess so the period spend
// nets to zero for the failed work. Positive amount mirrors the negative debit;
// same JOB reference ties the pair together. The idempotency guard above (only
// on the CHARGED→REFUNDED transition) prevents double-credits on re-invocation.
BillingCategory category = row.getBillingCategory();
if (category != null && category != BillingCategory.BYPASSED) {
WalletLedgerEntry refund = new WalletLedgerEntry();
refund.setTeamId(row.getTeamId());
refund.setEntryType(LedgerEntryType.REFUND);
refund.setBucket(LedgerBucket.CYCLE);
refund.setAmountUnits(row.getPaygUnits());
refund.setReferenceType(ReferenceType.JOB);
refund.setReferenceId(jobId.toString());
refund.setPolicyId(row.getPolicyId());
refund.setBillingCategory(category);
ledgerRepository.save(refund);
// Hand back the free units this job consumed (first-step failures are
// pre-meter, so nothing was billed to Stripe — only the grant moved). Exactly
// what was taken at charge time, so the counter can't drift above the grant.
int freeConsumed =
row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed();
if (freeConsumed > 0 && row.getTeamId() != null) {
teamExtensionsRepository.restoreFreeUnits(row.getTeamId(), freeConsumed);
}
}
}
}
@@ -197,6 +359,142 @@ public class JobChargeService {
}
}
/**
* Closes a process and — as a fallback — meters its usage. The primary meter trigger is the
* charge interceptor's {@code afterCompletion} on a successful request (see {@link
* #meterJobUsage(UUID)}); this close-time meter exists to catch processes that were never
* cleanly completed (request thread died before {@code afterCompletion}) and are swept up later
* by {@code StaleJobCloser}. The deterministic idempotency key means a job already metered at
* completion is deduped here at Stripe, so the two paths never double-bill.
*
* <p>Idempotent w.r.t. process state (delegates to {@link JobService#close(UUID)}, which
* silently no-ops on an already-closed row). The meter POST runs in an {@code afterCommit} hook
* so a failed POST does not roll back the close; the reconciliation backfill (separate chunk)
* is the durability mechanism.
*/
@Transactional
public ProcessingJob close(UUID jobId) {
Objects.requireNonNull(jobId, "jobId");
ProcessingJob closed = jobService.close(jobId);
// The afterCommit hook only fires if there's an active transaction (Spring's
// @Transactional ensures that). If we're called outside one — e.g. a test using the raw
// bean — fall through with a debug log: the close() above already happened in a
// sub-transaction created by JobService, but the surrounding scope has no synchronization.
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
log.debug("close({}): no active synchronization; skipping meter POST", jobId);
return closed;
}
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
try {
meterJobUsage(jobId);
} catch (RuntimeException e) {
// PaygMeterReportingService should already swallow; defence in depth so
// a thrown exception out of afterCommit doesn't leak past the
// synchronization boundary and bubble into the caller.
log.warn(
"afterCommit meter post for job {} threw unexpectedly: {}",
jobId,
e.getMessage());
}
}
});
return closed;
}
/**
* Post this job's billable usage to Stripe. The primary caller is the charge interceptor's
* {@code afterCompletion} on a successful OPENED request — i.e. the moment the work finishes —
* so the meter moves promptly. {@link #close(UUID)} also calls this from its {@code
* afterCommit} hook as the fallback for processes that were never cleanly completed (e.g. the
* request thread died); the deterministic idempotency key ({@code process:<id>:close}) makes
* the two paths dedup at Stripe, so a job metered at completion isn't billed again when it's
* later stale-closed.
*
* <p>Safe to call outside a transaction: it only reads (the job's openProcess DEBIT is already
* committed by the time either caller runs) and the POST is best-effort. Never throws — see
* {@link PaygMeterReportingService}.
*
* <p>Skips: no shadow row (not PAYG-tracked), REFUNDED row (first-step failure — never billed),
* BYPASSED/uncategorised, zero units, free-tier team (no Stripe customer), or usage still
* within the app-side free allowance.
*/
public void meterJobUsage(UUID jobId) {
Optional<PaygShadowCharge> rowOpt = shadowRepository.findFirstByJobIdOrderByIdAsc(jobId);
if (rowOpt.isEmpty()) {
// No shadow row → not a PAYG-tracked job; nothing to meter.
return;
}
PaygShadowCharge row = rowOpt.get();
if (row.getStatus() == ShadowChargeStatus.REFUNDED) {
// Refunded rows are zero-net charges; do not emit a meter event.
return;
}
BillingCategory category = row.getBillingCategory();
if (category == null || category == BillingCategory.BYPASSED) {
// Defensive: BYPASSED rows shouldn't exist (interceptor short-circuits before
// openProcess), but tolerate if a future caller writes one.
log.debug("close({}): shadow row category={} → no meter event", jobId, category);
return;
}
Integer units = row.getPaygUnits();
if (units == null || units <= 0) {
return;
}
Long teamId = row.getTeamId();
if (teamId == null) {
return;
}
PaygTeamExtensions ext = teamExtensionsRepository.findById(teamId).orElse(null);
if (ext == null) {
return;
}
// payg_subscription_id is the single switch that says "this team is billed" (see
// PaygTeamExtensions). Gate on it directly now that V14 ships the column: a team with a
// Stripe customer but no live subscription — e.g. the brief window after checkout but
// before the subscription-created webhook lands — must not post meter events against a
// subscription that doesn't exist. A job finishing in that window is still metered later
// via the stale-close fallback, once the subscription has landed (same idempotency key).
String subscriptionId = ext.getPaygSubscriptionId();
if (subscriptionId == null || subscriptionId.isBlank()) {
log.debug(
"close({}): team {} has no active subscription → no meter event",
jobId,
teamId);
return;
}
String stripeCustomerId = ext.getStripeCustomerId();
if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
// Subscribed but no customer id is a data inconsistency — we can't address the event.
log.warn(
"close({}): team {} has a subscription but no stripeCustomerId → cannot meter",
jobId,
teamId);
return;
}
// Paid portion = units beyond the team's one-time free grant, fixed at charge time. The
// free grant is app-side only (Stripe's Prices are plain per-unit, no free tier), so the
// free units were already withheld when this row's free_units_consumed was set.
int freeConsumed = row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed();
int paidUnits = units - freeConsumed;
if (paidUnits <= 0) {
log.debug(
"close({}): all {} units came from the free grant → no meter event",
jobId,
units);
return;
}
String idempotencyKey = "process:" + jobId + ":close";
meterReportingService.recordUsage(
teamId, stripeCustomerId, paidUnits, category, idempotencyKey, jobId);
}
/**
* Mid-chain 5xx on a JOINED step: return the step slot. The {@code lastStepAt} timestamp stays
* advanced (workflow window intentionally remains active for the next retry). No shadow-row
@@ -0,0 +1,357 @@
package stirling.software.saas.payg.entitlement;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.payg.cap.AiToolRoutes;
import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Hot-path entitlement check. Runs after {@code PaygChargeInterceptor} in the MVC chain and short-
* circuits the request before any handler work happens when the team's snapshot is missing one of
* the gates the route declared via {@link RequiresFeature}.
*
* <p>Scope: routes whose handler method (or bean type) carries either {@link AutoJobPostMapping}
* (multipart tool POSTs) or {@link RequiresFeature} (AI controllers, future non-multipart gated
* routes). Admin / info / config endpoints are excluded by the path-pattern in {@code
* PaygWebMvcConfig} and are additionally skipped here when they carry neither annotation, so non-
* billable infra never trips the guard.
*
* <p>Decision matrix:
*
* <table>
* <tr><th>auth</th><th>required gates</th><th>snapshot enabled?</th><th>outcome</th></tr>
* <tr><td>anonymous</td><td>AUTOMATION or AI_SUPPORT</td><td>n/a</td><td>401 SIGNUP_REQUIRED</td></tr>
* <tr><td>anonymous</td><td>OFFSITE_PROCESSING / CLIENT_SIDE</td><td>n/a</td><td>200 (pass through)</td></tr>
* <tr><td>authenticated</td><td>required ⊆ enabled</td><td>yes</td><td>200</td></tr>
* <tr><td>authenticated</td><td>required ⊄ enabled</td><td>no</td><td>402 FEATURE_DEGRADED</td></tr>
* </table>
*
* <p>Fail-open: any unexpected exception is logged at WARN and the request passes through. The cap
* pipeline must never block a customer because the guard tripped on a transient DB error.
*/
@Slf4j
@Component
@Profile("saas")
public class EntitlementGuard implements HandlerInterceptor {
private static final FeatureGate[] DEFAULT_REQUIRED_GATES = {FeatureGate.OFFSITE_PROCESSING};
private final EntitlementService entitlementService;
private final UserRepository userRepository;
private final ObjectMapper objectMapper;
private final Counter passCounter;
private final Counter deniedDegradedCounter;
private final Counter deniedPaygLimitCounter;
private final Counter deniedSignupRequiredCounter;
private final Counter errorsCounter;
private final Counter skippedNoAnnotationCounter;
public EntitlementGuard(
EntitlementService entitlementService,
UserRepository userRepository,
MeterRegistry meterRegistry) {
this.entitlementService = entitlementService;
this.userRepository = userRepository;
this.objectMapper = new ObjectMapper();
this.passCounter =
Counter.builder("payg.entitlement.guard")
.tag("outcome", "pass")
.register(meterRegistry);
this.deniedDegradedCounter =
Counter.builder("payg.entitlement.guard")
.tag("outcome", "denied_degraded")
.register(meterRegistry);
this.deniedPaygLimitCounter =
Counter.builder("payg.entitlement.guard")
.tag("outcome", "denied_payg_limit")
.register(meterRegistry);
this.deniedSignupRequiredCounter =
Counter.builder("payg.entitlement.guard")
.tag("outcome", "denied_signup_required")
.register(meterRegistry);
this.skippedNoAnnotationCounter =
Counter.builder("payg.entitlement.guard")
.tag("outcome", "skipped")
.register(meterRegistry);
this.errorsCounter =
Counter.builder("payg.entitlement.guard.errors")
.description("EntitlementGuard internal failures (fail-open)")
.register(meterRegistry);
}
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod hm)) {
return true;
}
// Scope: AutoJobPostMapping routes (multipart tool POSTs) OR routes that explicitly
// declare @RequiresFeature (e.g. AI controllers — JSON-bodied, no AutoJobPostMapping).
// Admin / info / config endpoints carry neither annotation and never trip the guard.
boolean hasAutoJobPostMapping =
AnnotationUtils.findAnnotation(hm.getMethod(), AutoJobPostMapping.class) != null
|| AnnotationUtils.findAnnotation(
hm.getBeanType(), AutoJobPostMapping.class)
!= null;
boolean hasRequiresFeature =
AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class) != null
|| AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class)
!= null;
// AI document tools (/api/v1/ai/tools/**) live in the proprietary module and can't carry
// @RequiresFeature; recognise them by path so they're gated on AI_SUPPORT — see
// AiToolRoutes and PaygChargeInterceptor, which classify the same routes as AI.
boolean aiToolRoute = AiToolRoutes.matches(request);
if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute) {
skippedNoAnnotationCounter.increment();
return true;
}
FeatureGate[] required =
aiToolRoute ? new FeatureGate[] {FeatureGate.AI_SUPPORT} : resolveRequiredGates(hm);
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean anonymous = isAnonymous(auth);
boolean billable = isBillable(required);
if (anonymous) {
if (billable) {
return write401SignupRequired(response, required);
}
// Anonymous user calling a manual / OFFSITE-only tool — let it through; PAYG only
// charges authenticated requests.
passCounter.increment();
return true;
}
Long teamId;
try {
teamId = resolveTeamId(auth);
} catch (RuntimeException e) {
log.warn("EntitlementGuard resolveTeamId failed; passing through", e);
errorsCounter.increment();
return true;
}
if (teamId == null) {
// Defensive: authenticated principal with no team — shouldn't happen post-migration,
// but we don't want to lock those users out. PaygChargeInterceptor short-circuits the
// same shape upstream.
passCounter.increment();
return true;
}
EntitlementSnapshot snapshot;
try {
snapshot = entitlementService.getSnapshot(teamId);
} catch (RuntimeException e) {
log.warn("EntitlementGuard getSnapshot failed for team {}; passing through", teamId, e);
errorsCounter.increment();
return true;
}
// API-key calls are always billable usage (BillingCategory.API) — there is no "free
// manual" path for a programmatic client the way there is for a JWT/web user, whose
// everyday tool calls are BYPASSED and never reach a gate. So once the team is over its
// free allowance / spending cap (DEGRADED), every API-key call hard-stops, regardless of
// which gate the route declares. The gate loop below would otherwise wave through an API
// call to a plain server tool (it needs only OFFSITE_PROCESSING, which survives DEGRADED),
// letting an unsubscribed team keep consuming the API for free past its allowance.
if (auth instanceof ApiKeyAuthenticationToken && snapshot.isDegraded()) {
return write402PaygLimitReached(response, snapshot);
}
List<FeatureGate> enabled = snapshot.enabledGates();
for (FeatureGate gate : required) {
if (enabled == null || !enabled.contains(gate)) {
return write402FeatureDegraded(response, required, snapshot);
}
}
passCounter.increment();
return true;
}
static FeatureGate[] resolveRequiredGates(HandlerMethod hm) {
RequiresFeature ann = AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class);
if (ann == null) {
ann = AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class);
}
if (ann != null && ann.value().length > 0) {
return ann.value();
}
return DEFAULT_REQUIRED_GATES;
}
private static boolean isAnonymous(Authentication auth) {
if (auth == null || !auth.isAuthenticated()) {
return true;
}
// Spring's anonymous filter installs a token whose name is "anonymousUser".
return "anonymousUser".equals(auth.getName());
}
private static boolean isBillable(FeatureGate[] required) {
for (FeatureGate g : required) {
if (g == FeatureGate.AUTOMATION || g == FeatureGate.AI_SUPPORT) {
return true;
}
}
return false;
}
private Long resolveTeamId(Authentication auth) {
if (auth instanceof ApiKeyAuthenticationToken
&& auth.getPrincipal() instanceof User apiUser) {
return apiUser.getTeam() == null ? null : apiUser.getTeam().getId();
}
String supabaseId = AuthenticationUtils.extractSupabaseId(auth);
if (supabaseId == null) {
return null;
}
UUID supabaseUuid;
try {
supabaseUuid = UUID.fromString(supabaseId);
} catch (IllegalArgumentException e) {
// Username-style principals (legacy local accounts) — no Supabase ID to look up. Skip.
return null;
}
return userRepository
.findBySupabaseId(supabaseUuid)
.map(u -> u.getTeam() == null ? null : u.getTeam().getId())
.orElse(null);
}
private boolean write401SignupRequired(HttpServletResponse response, FeatureGate[] required) {
deniedSignupRequiredCounter.increment();
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", "SIGNUP_REQUIRED");
body.put("category", inferCategory(required));
writeJson(response, HttpStatus.UNAUTHORIZED, body);
return false;
}
/**
* 402 for a billable API-key call once the team is over its allowance / cap. The message is
* tailored by subscription state: an un-subscribed team is told to subscribe (their free
* allowance is spent); a subscribed team is told it hit its own spending cap. Programmatic
* clients get a stable {@code error} code plus the spend/cap numbers so they can surface
* something actionable.
*/
private boolean write402PaygLimitReached(
HttpServletResponse response, EntitlementSnapshot snapshot) {
deniedPaygLimitCounter.increment();
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", "PAYG_LIMIT_REACHED");
body.put("subscribed", snapshot.subscribed());
body.put(
"message",
snapshot.subscribed()
? "Your team has reached its monthly spending cap. Raise the cap to"
+ " continue, or wait for it to reset next billing period."
: "Your team has used its free document allowance."
+ " Subscribe to continue using the API.");
body.put("state", snapshot.state().name());
body.put("spendUnits", snapshot.periodSpendUnits());
body.put("capUnits", snapshot.periodCapUnits());
body.put(
"periodEnd",
Optional.ofNullable(snapshot.periodEnd()).map(Object::toString).orElse(null));
writeJson(response, HttpStatus.PAYMENT_REQUIRED, body);
return false;
}
private boolean write402FeatureDegraded(
HttpServletResponse response, FeatureGate[] required, EntitlementSnapshot snapshot) {
deniedDegradedCounter.increment();
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", "FEATURE_DEGRADED");
// subscribed tells the client which usage-limit modal to show: a subscribed team is over
// its spending cap; an un-subscribed one has spent its free allowance. (PAYG_LIMIT_REACHED
// already carries this; mirror it here so the JWT/web path can pick the right modal too.)
body.put("subscribed", snapshot.subscribed());
body.put("missingGates", missingGates(required, snapshot.enabledGates()));
body.put("state", snapshot.state().name());
body.put(
"periodEnd",
Optional.ofNullable(snapshot.periodEnd()).map(Object::toString).orElse(null));
body.put("capUnits", snapshot.periodCapUnits());
body.put("spendUnits", snapshot.periodSpendUnits());
writeJson(response, HttpStatus.PAYMENT_REQUIRED, body);
return false;
}
private static List<String> missingGates(FeatureGate[] required, List<FeatureGate> enabled) {
List<FeatureGate> enabledOrEmpty = enabled == null ? Collections.emptyList() : enabled;
return Arrays.stream(required)
.filter(g -> !enabledOrEmpty.contains(g))
.map(Enum::name)
.toList();
}
private static String inferCategory(FeatureGate[] required) {
// Mirrors PaygChargeInterceptor.determineCategory precedence: AUTOMATION dominates AI.
for (FeatureGate g : required) {
if (g == FeatureGate.AUTOMATION) {
return "AUTOMATION";
}
}
for (FeatureGate g : required) {
if (g == FeatureGate.AI_SUPPORT) {
return "AI";
}
}
return "OFFSITE_PROCESSING";
}
private void writeJson(
HttpServletResponse response, HttpStatus status, Map<String, Object> body) {
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
try {
byte[] payload = objectMapper.writeValueAsBytes(body);
response.setHeader(HttpHeaders.CONTENT_LENGTH, Integer.toString(payload.length));
response.getOutputStream().write(payload);
response.getOutputStream().flush();
} catch (IOException e) {
// Container will fall back to its default error page — we did set the status code,
// so the client still sees the right HTTP code even if the body fails to write.
log.warn("EntitlementGuard write response body failed", e);
errorsCounter.increment();
}
}
}
@@ -0,0 +1,182 @@
package stirling.software.saas.payg.entitlement;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.cap.CapEvaluator;
import stirling.software.saas.payg.cap.CapEvaluator.Evaluation;
import stirling.software.saas.payg.model.EntitlementState;
import stirling.software.saas.payg.model.FeatureSet;
import stirling.software.saas.payg.repository.WalletLedgerRepository;
import stirling.software.saas.payg.repository.WalletPolicyRepository;
import stirling.software.saas.payg.wallet.WalletPolicy;
/**
* Hot-path entitlement lookup. Returns the {@link EntitlementSnapshot} for a team: the billing
* facts (window, free allowance, document cap) come from {@link TeamBillingService}; this service
* layers the period spend (ledger SUM over that window) and the warn/degrade evaluation on top.
*
* <p>Backed by a per-team Caffeine cache with {@value #CACHE_TTL_SECONDS}s TTL and {@value
* #CACHE_MAX_SIZE}-entry cap. The TTL is the correctness floor — a cap change becomes visible on
* every instance within that window without coordination. Mutators (wallet policy admin updates,
* subscription webhook handlers) call {@link #invalidate(Long)} to drop a single team's entry
* immediately on the originating instance.
*/
@Slf4j
@Service
@Profile("saas")
public class EntitlementService {
static final int CACHE_TTL_SECONDS = 30;
private static final int CACHE_MAX_SIZE = 10_000;
private static final int WARN_AT_PCT = 80;
private static final int DEGRADE_AT_PCT = 100;
private final TeamBillingService teamBillingService;
private final WalletPolicyRepository walletPolicyRepository;
private final WalletLedgerRepository ledgerRepository;
private final Cache<Long, EntitlementSnapshot> snapshotCache;
public EntitlementService(
TeamBillingService teamBillingService,
WalletPolicyRepository walletPolicyRepository,
WalletLedgerRepository ledgerRepository) {
this.teamBillingService = Objects.requireNonNull(teamBillingService, "teamBillingService");
this.walletPolicyRepository =
Objects.requireNonNull(walletPolicyRepository, "walletPolicyRepository");
this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository");
this.snapshotCache =
Caffeine.newBuilder()
.maximumSize(CACHE_MAX_SIZE)
.expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS))
.recordStats()
.build();
}
/**
* Returns the entitlement snapshot for {@code teamId}. Caches per-team for {@value
* #CACHE_TTL_SECONDS}s — burst requests share a single SUM query against the ledger.
*
* <p>{@code null} teamId throws — the guard short-circuits team-less requests upstream so a
* null reach here is a programming error.
*/
public EntitlementSnapshot getSnapshot(Long teamId) {
Objects.requireNonNull(teamId, "teamId");
return snapshotCache.get(teamId, this::computeSnapshot);
}
/**
* Drops {@code teamId}'s cache entry. Call after subscription state changes (webhook handlers),
* cap edits, or manual ledger adjustments so the next read recomputes immediately rather than
* waiting out the TTL. Also drops the underlying billing context so window/cap facts recompute
* together with the spend.
*/
public void invalidate(Long teamId) {
if (teamId != null) {
snapshotCache.invalidate(teamId);
teamBillingService.invalidate(teamId);
}
}
/** Visible for tests. */
long cacheSize() {
return snapshotCache.estimatedSize();
}
@Transactional(readOnly = true)
EntitlementSnapshot computeSnapshot(Long teamId) {
TeamBillingContext billing = teamBillingService.forTeam(teamId);
Optional<WalletPolicy> walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId);
FeatureSet degradedSet =
walletPolicyOpt.map(WalletPolicy::getDegradedFeatureSet).orElse(FeatureSet.MINIMAL);
int warnAtPct =
walletPolicyOpt
.map(WalletPolicy::getWarnAtPct)
.filter(Objects::nonNull)
.orElse(WARN_AT_PCT);
int degradeAtPct =
walletPolicyOpt
.map(WalletPolicy::getDegradeAtPct)
.filter(Objects::nonNull)
.orElse(DEGRADE_AT_PCT);
// Subscription-anchored window when subscribed; calendar month otherwise. Used for the
// subscribed monthly cap + the displayed billing period.
LocalDateTime periodStart = billing.periodStart();
LocalDateTime periodEnd = billing.periodEnd();
Evaluation eval;
long snapshotSpend;
Long snapshotCap;
if (billing.subscribed()) {
// Subscribed: gate on the monthly spending cap. Spend = this period's net billable
// documents (DEBIT minus REFUND so a refunded job doesn't read as spent). The one-time
// free grant doesn't gate a paying team — it only reduced what they were metered.
long signedNet = ledgerRepository.sumPeriodNetBillable(teamId, periodStart, periodEnd);
long periodSpend = signedNet < 0 ? -signedNet : 0L;
Long cap = billing.monthlyCapDocUnits();
eval = CapEvaluator.evaluate(periodSpend, cap, warnAtPct, degradeAtPct, degradedSet);
snapshotSpend = periodSpend;
snapshotCap = cap;
} else {
// Unsubscribed: gate on the one-time lifetime free grant. Exhausted (remaining ≤ 0, or
// no grant configured) → DEGRADED so billable categories hard-stop; otherwise evaluate
// the warn/degrade band on used-of-grant.
long grant = billing.freeGrantUnits();
long remaining = billing.freeRemainingUnits();
long used = Math.max(0L, grant - remaining);
if (remaining <= 0L) {
eval =
new Evaluation(
EntitlementState.DEGRADED,
degradedSet,
CapEvaluator.gatesFor(degradedSet));
} else {
eval = CapEvaluator.evaluate(used, grant, warnAtPct, degradeAtPct, degradedSet);
}
snapshotSpend = used;
snapshotCap = grant;
}
return new EntitlementSnapshot(
eval.state(),
eval.featureSet(),
List.copyOf(eval.enabledGates()),
snapshotSpend,
snapshotCap,
periodStart,
periodEnd,
billing.subscribed());
}
/**
* Inclusive-start / exclusive-end window for the calendar-month period. Test seam — takes a
* clock value so tests don't race the calendar boundary. The live snapshot window comes from
* {@link TeamBillingService}; this remains for the forthcoming {@code BILLING_CYCLE} work.
*/
static LocalDateTime[] currentMonthWindow(LocalDateTime now) {
YearMonth ym = YearMonth.from(now);
LocalDateTime start = ym.atDay(1).atStartOfDay();
LocalDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay();
return new LocalDateTime[] {start, end};
}
}
@@ -0,0 +1,46 @@
package stirling.software.saas.payg.entitlement;
import java.time.LocalDateTime;
import java.util.List;
import stirling.software.saas.payg.model.EntitlementState;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.FeatureSet;
/**
* Immutable snapshot of a team's entitlement state as of a single point in time. Returned by {@link
* EntitlementService#getSnapshot(Long)} and consumed by {@code EntitlementGuard}.
*
* <p>Contrast with {@link WalletEntitlementSnapshot}: the JPA entity is the <em>persisted</em>
* snapshot that the recompute path writes (one row per team, optionally per member). This record is
* the <em>computed-now</em> view the hot-path guard reads — backed by a 30s Caffeine cache so a
* request burst doesn't hammer the ledger SUM.
*
* @param state aggregate state — FULL, WARNED, or DEGRADED.
* @param featureSet bundle name in effect (FULL on no-cap / warn band; degraded set on DEGRADED).
* @param enabledGates the gates the guard checks against — request proceeds only if every required
* gate is in this list.
* @param periodSpendUnits sum of debited units in {@code [periodStart, periodEnd)}, in canonical
* doc-units (positive).
* @param periodCapUnits the cap applied — free-tier units for un-subscribed teams, {@code
* wallet_policy.cap_units} for subscribed teams. {@code null} means uncapped.
* @param periodStart inclusive start of the current cap period.
* @param periodEnd exclusive end of the current cap period.
* @param subscribed whether the team has an active PAYG subscription. Drives the messaging when a
* billable call is hard-stopped: an un-subscribed team is told to subscribe; a subscribed team
* that hit its self-set spending cap is told to raise it.
*/
public record EntitlementSnapshot(
EntitlementState state,
FeatureSet featureSet,
List<FeatureGate> enabledGates,
long periodSpendUnits,
Long periodCapUnits,
LocalDateTime periodStart,
LocalDateTime periodEnd,
boolean subscribed) {
public boolean isDegraded() {
return state == EntitlementState.DEGRADED;
}
}
@@ -12,6 +12,7 @@ import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@@ -37,11 +38,15 @@ import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.payg.cap.AiToolRoutes;
import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.charge.ChargeContext;
import stirling.software.saas.payg.charge.ChargeOutcome;
import stirling.software.saas.payg.charge.JobChargeService;
import stirling.software.saas.payg.charge.JobInput;
import stirling.software.saas.payg.job.JobService;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.JobStepStatus;
import stirling.software.saas.payg.model.ProcessType;
@@ -52,10 +57,13 @@ import stirling.software.saas.util.AuthenticationUtils;
* after it in {@code PaygWebMvcConfig} so legacy credit-rejection short-circuits before we waste
* work hashing inputs.
*
* <p>{@code preHandle}: gates on {@code @AutoJobPostMapping}, reads the parsed multipart parts,
* materialises each input to a {@code TempFile}, and asks {@link JobChargeService#openProcess} to
* open (or join) a process. The resulting {@link ChargeOutcome} plus input temp-files are stashed
* as request attributes for {@code afterCompletion}.
* <p>{@code preHandle}: gates on {@code @AutoJobPostMapping} OR {@code @RequiresFeature} (the
* latter lets AI controllers — JSON-bodied, no AutoJobPostMapping — bill correctly), reads the
* parsed multipart parts, materialises each input to a {@code TempFile}, and asks {@link
* JobChargeService#openProcess} to open (or join) a process. The resulting {@link ChargeOutcome}
* plus input temp-files are stashed as request attributes for {@code afterCompletion}. Routes
* without multipart inputs short-circuit inside {@code doPreHandle} without touching the charge
* service.
*
* <p>{@code afterCompletion}: branches on HTTP status — 2xx hashes the response body for OUTPUT
* lineage; 4xx records a step append for audit; 5xx triggers refund-and-close (OPENED) or
@@ -105,6 +113,7 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
private final Counter callsOpened;
private final Counter callsJoined;
private final Counter callsShortCircuit;
private final Counter callsBypassed;
private final Counter refundsCounter;
/** preHandle wall-clock per request. Separate from afterCompletion — different populations. */
@@ -144,6 +153,11 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
Counter.builder("payg.filter.calls")
.tag("disposition", "SHORT_CIRCUIT")
.register(meterRegistry);
this.callsBypassed =
Counter.builder("payg.filter.bypassed")
.description(
"Manual UI tool calls that skipped openProcess (BillingCategory.BYPASSED)")
.register(meterRegistry);
this.refundsCounter =
Counter.builder("payg.filter.refunds")
.description("First-step 5xx refunds applied to shadow rows")
@@ -168,13 +182,42 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
if (!properties.isEnabled()) {
return true;
}
if (!(handler instanceof HandlerMethod hm)
|| hm.getMethodAnnotation(AutoJobPostMapping.class) == null) {
if (!(handler instanceof HandlerMethod hm)) {
callsShortCircuit.increment();
return true;
}
// In-scope when the handler carries @AutoJobPostMapping (multipart tool POSTs) OR
// @RequiresFeature (AI controllers, future non-multipart gated routes). Without one of
// these the interceptor short-circuits — admin / info / static routes never run
// determineCategory.
boolean hasAutoJobPostMapping =
AnnotationUtils.findAnnotation(hm.getMethod(), AutoJobPostMapping.class) != null
|| AnnotationUtils.findAnnotation(
hm.getBeanType(), AutoJobPostMapping.class)
!= null;
boolean hasRequiresFeature =
AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class) != null
|| AnnotationUtils.findAnnotation(
hm.getBeanType(), RequiresFeature.class)
!= null;
// AI document tools (/api/v1/ai/tools/**) live in the proprietary module and can't
// carry @RequiresFeature, so they're recognised by path — see AiToolRoutes.
boolean aiToolRoute = AiToolRoutes.matches(request);
if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute) {
callsShortCircuit.increment();
return true;
}
// Bypass fast-path: determine the BillingCategory BEFORE any multipart
// materialisation or openProcess call. Manual UI tool calls (BYPASSED) skip the
// entire ledger/shadow pipeline — no temp files, no DB writes.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
BillingCategory category = determineCategory(hm, request, auth);
if (category == BillingCategory.BYPASSED) {
callsBypassed.increment();
return true;
}
try {
doPreHandle(request);
doPreHandle(request, auth, category);
} catch (RuntimeException e) {
log.warn("PAYG preHandle failed; passing through unbilled", e);
errorsCounter.increment();
@@ -187,8 +230,8 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
}
}
private void doPreHandle(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
private void doPreHandle(
HttpServletRequest request, Authentication auth, BillingCategory category) {
User currentUser = resolveUser(auth);
if (currentUser == null) {
callsShortCircuit.increment();
@@ -248,7 +291,8 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
currentUser.getId(),
currentUser.getTeam() == null ? null : currentUser.getTeam().getId(),
determineSource(request, auth),
ProcessType.SINGLE_TOOL);
ProcessType.SINGLE_TOOL,
category);
ChargeOutcome outcome;
try {
@@ -331,12 +375,38 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
}
if (status >= 400) {
// 4xx: customer paid for the attempt. No OUTPUT recording, no refund.
// Still a successful-from-billing-standpoint OPENED process — meter it below.
meterIfOpened(jobId, disposition);
return;
}
// Success: this is the moment the billable work finished, so this is when we tell Stripe.
// Only the OPENED request meters — JOINED follow-up steps (chained tools on the same
// document) added no units and must not re-meter. The process stays OPEN for further
// lineage joins; StaleJobCloser closing it later is a no-op at Stripe thanks to the shared
// idempotency key. metering is best-effort and must never break the response teardown.
meterIfOpened(jobId, disposition);
recordOutputs(request, response, jobId);
}
/**
* Fire the Stripe meter for a just-finished process, but only when this request OPENED it. Runs
* on the request-teardown thread (the response is already flushed to the client); {@code
* meterJobUsage} is best-effort and swallows its own failures, but we still guard here so a
* meter hiccup can't disturb lineage/cleanup that follows.
*/
private void meterIfOpened(UUID jobId, ChargeOutcome.Disposition disposition) {
if (disposition != ChargeOutcome.Disposition.OPENED) {
return;
}
try {
chargeService.meterJobUsage(jobId);
} catch (RuntimeException e) {
log.warn("Meter-on-completion failed for job {}: {}", jobId, e.getMessage());
errorsCounter.increment();
}
}
private void recordOutputs(
HttpServletRequest request, HttpServletResponse response, UUID jobId) {
PaygResponseBodyWrapper wrapper =
@@ -445,6 +515,54 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
return JobSource.WEB;
}
/**
* Resolve the {@link BillingCategory} for this request. Precedence: {@code
* X-Stirling-Automation: true} or {@code @RequiresFeature(AUTOMATION)} → AUTOMATION;
* {@code @RequiresFeature(AI_SUPPORT)} → AI; an AI document-tool route ({@link AiToolRoutes}) →
* AI; API-key auth → API; otherwise BYPASSED (manual UI tool — short-circuited in {@link
* #preHandle}).
*
* <p>Method-level {@code @RequiresFeature} wins over class-level. Multiple gates: AUTOMATION
* dominates AI within a single annotation. The AI-tool path check sits below the automation
* header on purpose: an AI tool dispatched inside a policy / AI workflow bills as AUTOMATION,
* while a direct call to it bills as AI.
*/
private static BillingCategory determineCategory(
HandlerMethod handler, HttpServletRequest request, Authentication auth) {
String automationHeader = request.getHeader(AUTOMATION_HEADER);
if (automationHeader != null && "true".equalsIgnoreCase(automationHeader.trim())) {
return BillingCategory.AUTOMATION;
}
RequiresFeature ann =
AnnotationUtils.findAnnotation(handler.getMethod(), RequiresFeature.class);
if (ann == null) {
ann = AnnotationUtils.findAnnotation(handler.getBeanType(), RequiresFeature.class);
}
if (ann != null) {
boolean ai = false;
for (FeatureGate gate : ann.value()) {
if (gate == FeatureGate.AUTOMATION) {
return BillingCategory.AUTOMATION;
}
if (gate == FeatureGate.AI_SUPPORT) {
ai = true;
}
}
if (ai) {
return BillingCategory.AI;
}
}
// AI document tools (proprietary module, recognised by path). A direct call bills as AI; an
// orchestrator-dispatched call already returned AUTOMATION above via the automation header.
if (AiToolRoutes.matches(request)) {
return BillingCategory.AI;
}
if (auth instanceof ApiKeyAuthenticationToken) {
return BillingCategory.API;
}
return BillingCategory.BYPASSED;
}
/**
* Resolves the {@code tool_id} value stored on {@code processing_job_step}. Prefers the route
* pattern (e.g. {@code /api/v1/security/add-password}) over the raw URI so audit rollups
@@ -9,6 +9,8 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
import stirling.software.saas.payg.entitlement.EntitlementGuard;
/**
* Wires the PAYG filter + interceptor into Spring MVC. Two registrations:
*
@@ -28,6 +30,7 @@ import lombok.RequiredArgsConstructor;
public class PaygWebMvcConfig implements WebMvcConfigurer {
private final PaygChargeInterceptor paygChargeInterceptor;
private final EntitlementGuard entitlementGuard;
@Bean
public FilterRegistrationBean<PaygResponseBodyWrapperFilter>
@@ -39,13 +42,27 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
}
/**
* Interceptor ordering: the legacy {@code UnifiedCreditInterceptor} (registered with default
* order = 0 in {@code CreditInterceptorConfig}) must run BEFORE this one so credit rejections
* short-circuit before we hash inputs. Explicit positive order guarantees this regardless of
* {@code WebMvcConfigurer} bean discovery order.
* The {@code PaygChargeInterceptor} runs after the {@link #ENTITLEMENT_GUARD_ORDER guard} (and
* after the legacy {@code UnifiedCreditInterceptor}, default order 0), so {@code openProcess}
* only fires for requests the guard has admitted. See {@link #ENTITLEMENT_GUARD_ORDER} for the
* full ordering rationale.
*/
public static final int INTERCEPTOR_ORDER = 1000;
/**
* The {@code EntitlementGuard} runs BEFORE the charge interceptor. Spring runs interceptors in
* ascending order on the way in and skips a later interceptor's {@code preHandle} (and its
* {@code afterCompletion}) entirely once an earlier one returns {@code false} — so a request the
* guard refuses (over its free allowance / spending cap, or with no subscription to bill)
* short-circuits with its 402 before the charge interceptor ever runs. A blocked request
* therefore never opens a process, materialises inputs, or writes a charge: a refused operation
* must not bill, and running the guard first guarantees that structurally rather than by
* compensating after the fact. Stays above the legacy {@code UnifiedCreditInterceptor} (default
* order 0, only registered under the {@code legacy-credits} profile) so a legacy rejection still
* wins.
*/
public static final int ENTITLEMENT_GUARD_ORDER = 900;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(paygChargeInterceptor)
@@ -56,5 +73,14 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
"/api/v1/info/**",
"/api/v1/admin/**")
.order(INTERCEPTOR_ORDER);
registry.addInterceptor(entitlementGuard)
.addPathPatterns("/api/**")
.excludePathPatterns(
"/api/v1/credits/**",
"/api/v1/config/**",
"/api/v1/info/**",
"/api/v1/admin/**")
.order(ENTITLEMENT_GUARD_ORDER);
}
}
@@ -229,6 +229,20 @@ public class JobService {
return new JoinOrOpenResult(saved, JoinOrOpenResult.Disposition.JOINED);
}
/**
* Open a standalone process with no lineage inputs, for a billable action that isn't
* file/lineage-driven (e.g. an AI Create session). Because no input signatures are recorded,
* nothing downstream can lineage-join it — each such charge stands alone. {@code docUnits} is
* persisted so the charge service's shadow + ledger rows agree with the job.
*/
@Transactional
public ProcessingJob open(JobContext ctx, int docUnits) {
Objects.requireNonNull(ctx, "ctx");
ProcessingJob job = openFresh(ctx, Map.of()).job();
job.setDocUnits(docUnits);
return jobRepository.save(job);
}
private JoinOrOpenResult openFresh(
JobContext ctx, Map<Path, Set<LineageSignature>> signaturesByInput) {
ProcessingJob fresh = new ProcessingJob();
@@ -1,34 +1,69 @@
package stirling.software.saas.payg.job;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.charge.JobChargeService;
/**
* Auto-closes {@code OPEN} jobs whose {@code last_step_at} is older than the workflow window. Runs
* every minute. API users never have to call {@code close()} explicitly — this scheduler is the
* safety net.
* safety net, and (for metered teams) the point at which the Stripe meter event is posted.
*
* <p>Each stale job is closed individually through {@link JobChargeService#close(java.util.UUID)}
* rather than {@code JobService.closeStale()} (a bulk status flip). That routing matters: {@code
* JobChargeService.close} registers the {@code afterCommit} hook that posts the billable usage to
* Stripe via {@code PaygMeterReportingService}. A bulk flip would close the rows but never meter
* them — usage would accrue in the wallet ledger yet never reach the customer's invoice.
*
* <p>Per-job transactions + failure isolation: each {@code chargeService.close(id)} runs in its own
* transaction (cross-bean proxied call from this non-transactional scheduled method), so the
* afterCommit meter POST fires once per job and one job's failure can't abort the rest of the
* sweep. The meter event's idempotency key ({@code process:<id>:close}) makes a re-run on the next
* tick safe even if a close half-completed.
*
* <p>Single-fire only at V1: not {@code @SchedulerLock}'d, consistent with the other
* {@code @Scheduled} tasks in {@code :saas} (none of them are guarded against multi-pod
* double-fires today either). Multi-pod cluster-correctness for all schedulers is tracked in design
* § 9 as a separate cleanup. The underlying {@code closeStale()} call is idempotent — duplicate
* firings read an empty stale set on the second pod, no data corruption risk.
* {@code @Scheduled} tasks in {@code :saas}. Multi-pod cluster-correctness for all schedulers is
* tracked in design § 9 as a separate cleanup; the per-job close + meter idempotency key mean a
* double-fire across pods reads a shrinking stale set and never double-bills.
*/
@Component
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class StaleJobCloser {
private final JobService jobService;
private final JobChargeService chargeService;
public StaleJobCloser(JobService jobService, JobChargeService chargeService) {
this.jobService = jobService;
this.chargeService = chargeService;
}
@Scheduled(fixedRateString = "${payg.job.stale-close-interval-ms:60000}")
public void closeStale() {
int closed = jobService.closeStale();
List<ProcessingJob> stale = jobService.findStale();
if (stale.isEmpty()) {
return;
}
int closed = 0;
for (ProcessingJob job : stale) {
try {
// Routes through the charge service so the afterCommit meter hook fires for
// metered teams. Idempotent: a job already closed by a racing tick no-ops.
chargeService.close(job.getId());
closed++;
} catch (RuntimeException e) {
// Isolate per job — a single bad row (or a transient meter-path issue) must not
// strand the rest of the stale set open. Next tick retries.
log.warn("StaleJobCloser failed to close job {}: {}", job.getId(), e.getMessage());
}
}
if (closed > 0) {
log.info("StaleJobCloser closed {} job(s) idle past the workflow window.", closed);
}
@@ -0,0 +1,69 @@
package stirling.software.saas.payg.meter;
import java.time.LocalDateTime;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Backend-side audit row for one Stripe meter-event POST attempt ({@code payg_meter_event_log},
* V15). A row is written <em>pending</em> ({@code posted_to_stripe_at} NULL) just before the POST
* and stamped on success; a failed POST leaves it unposted with the Stripe error captured. Rows
* still unposted after a short delay are retried by {@link PaygMeterReconcileScheduler} — this is
* the durability mechanism behind the fail-open meter path, so a Stripe blip never silently
* under-bills.
*
* <p>{@code idempotency_key} is UNIQUE and identical to the key sent to Stripe ({@code
* process:<jobId>:close}); the unique constraint gives safe at-least-once semantics across the dual
* meter triggers (completion + stale-close) and reconcile retries.
*/
@Entity
@Table(name = "payg_meter_event_log")
@Getter
@Setter
@NoArgsConstructor
public class PaygMeterEventLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "event_id")
private Long eventId;
@Column(name = "team_id", nullable = false)
private Long teamId;
@Column(name = "job_id")
private UUID jobId;
@Column(name = "idempotency_key", nullable = false, unique = true, length = 128)
private String idempotencyKey;
@Column(name = "units", nullable = false)
private Integer units;
/**
* Insert time; the DB column defaults to {@code CURRENT_TIMESTAMP} (set by {@code
* insertPending}).
*/
@Column(name = "occurred_at", nullable = false, insertable = false, updatable = false)
private LocalDateTime occurredAt;
/** NULL while pending; stamped when the meter-payg-units edge fn returns success. */
@Column(name = "posted_to_stripe_at")
private LocalDateTime postedToStripeAt;
@Column(name = "stripe_error_code", length = 64)
private String stripeErrorCode;
@Column(name = "stripe_error_body", columnDefinition = "text")
private String stripeErrorBody;
}
@@ -0,0 +1,139 @@
package stirling.software.saas.payg.meter;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.data.domain.PageRequest;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
import stirling.software.saas.payg.repository.PaygMeterEventLogRepository;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
/**
* Retries PAYG meter events that were logged but never confirmed posted to Stripe — the durability
* half of the fail-open meter path. {@link PaygMeterReportingService} writes a pending {@code
* payg_meter_event_log} row before each POST and stamps it on success; anything left unposted
* (Stripe blip, pod crash between POST and stamp, edge-fn outage) is picked up here and re-sent
* under the <em>same</em> idempotency key, so Stripe dedups rather than double-charging.
*
* <p>Only retries rows inside Stripe's 24h idempotency window — past that a same-key retry is no
* longer guaranteed to dedup, so stuck rows are logged for manual reconciliation rather than
* risking a double charge. Skips teams that have since unsubscribed (nothing to bill). Like {@link
* stirling.software.saas.payg.lineage.LineagePruneScheduler} it is not {@code @SchedulerLock}'d: a
* duplicate firing on a multi-pod deploy re-sends the same keys, which dedup at Stripe —
* idempotent, wasted IO at worst.
*/
@Component
@Profile("saas")
@Slf4j
public class PaygMeterReconcileScheduler {
/** Stripe's meter-event idempotency window — a same-key retry past this may double-charge. */
private static final Duration STRIPE_IDEMPOTENCY_WINDOW = Duration.ofHours(24);
private final PaygMeterEventLogRepository eventLogRepository;
private final PaygTeamExtensionsRepository teamExtensionsRepository;
private final PaygMeterReportingService meterReportingService;
private final boolean enabled;
private final Duration retryDelay;
private final int batchSize;
private final Counter retriedCounter;
public PaygMeterReconcileScheduler(
PaygMeterEventLogRepository eventLogRepository,
PaygTeamExtensionsRepository teamExtensionsRepository,
PaygMeterReportingService meterReportingService,
@Value("${payg.meter.reconcile.enabled:true}") boolean enabled,
@Value("${payg.meter.reconcile.retry-delay:PT5M}") Duration retryDelay,
@Value("${payg.meter.reconcile.batch-size:100}") int batchSize,
MeterRegistry meterRegistry) {
this.eventLogRepository = Objects.requireNonNull(eventLogRepository, "eventLogRepository");
this.teamExtensionsRepository =
Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
this.meterReportingService =
Objects.requireNonNull(meterReportingService, "meterReportingService");
this.enabled = enabled;
this.retryDelay = Objects.requireNonNull(retryDelay, "retryDelay");
this.batchSize = batchSize > 0 ? batchSize : 100;
this.retriedCounter =
Counter.builder("payg.meter.reconcile.retried")
.description("PAYG meter events re-posted to Stripe by the reconcile job")
.register(meterRegistry);
}
@Scheduled(cron = "${payg.meter.reconcile-cron:0 */15 * * * *}", zone = "UTC")
public void reconcile() {
if (!enabled) {
return;
}
LocalDateTime now = LocalDateTime.now();
// Give the live POST a moment to land before retrying; stay inside the 24h dedup window.
LocalDateTime cutoff = now.minus(retryDelay);
LocalDateTime floor = now.minus(STRIPE_IDEMPOTENCY_WINDOW);
List<PaygMeterEventLog> retryable =
eventLogRepository.findRetryable(cutoff, floor, PageRequest.of(0, batchSize));
// Batch-fetch this page's team extensions in one query (keyed by team id) rather than a
// findById per row — avoids an N+1 when the page spans several teams.
List<Long> teamIds =
retryable.stream().map(PaygMeterEventLog::getTeamId).distinct().toList();
Map<Long, PaygTeamExtensions> extById =
teamExtensionsRepository.findAllById(teamIds).stream()
.collect(Collectors.toMap(PaygTeamExtensions::getTeamId, ext -> ext));
int retried = 0;
for (PaygMeterEventLog row : retryable) {
PaygTeamExtensions ext = extById.get(row.getTeamId());
if (ext == null) {
continue;
}
String subscriptionId = ext.getPaygSubscriptionId();
String stripeCustomerId = ext.getStripeCustomerId();
if (subscriptionId == null
|| subscriptionId.isBlank()
|| stripeCustomerId == null
|| stripeCustomerId.isBlank()) {
// Team unsubscribed since the event was logged — nothing to bill; leave the row.
continue;
}
// Same idempotency key → Stripe dedups if the original actually landed. recordUsage
// re-inserts pending as a no-op, re-POSTs, and stamps the row on success. Category is
// not re-derived (analytics metadata only); units + key are what bill.
meterReportingService.recordUsage(
row.getTeamId(),
stripeCustomerId,
row.getUnits() == null ? 0 : row.getUnits(),
null,
row.getIdempotencyKey(),
row.getJobId());
retried++;
}
if (retried > 0) {
retriedCounter.increment(retried);
log.info("PaygMeterReconcileScheduler retried {} unposted meter event(s).", retried);
}
long stuck = eventLogRepository.countStuck(floor);
if (stuck > 0) {
log.warn(
"{} PAYG meter event(s) stuck unposted past Stripe's {}h idempotency window —"
+ " manual reconciliation needed.",
stuck,
STRIPE_IDEMPOTENCY_WINDOW.toHours());
}
}
}
@@ -0,0 +1,221 @@
package stirling.software.saas.payg.meter;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.repository.PaygMeterEventLogRepository;
/**
* POSTs PAYG billable usage to the Supabase {@code meter-payg-units} edge function. Called from
* {@code JobChargeService.close()} in an {@code afterCommit} hook, so the wallet ledger DEBIT (the
* customer's authoritative bill) is already durable before we tell Stripe about it.
*
* <p>Stripe is on a single flat-priced meter forever. {@link BillingCategory} ships as metadata for
* analytics — pricing never reads it. Free-tier teams (no Stripe subscription) skip this call
* entirely; the ledger entry is the only record needed.
*
* <p>Failure mode: we owe Stripe an event but the customer's bill via the ledger is correct. Log
* WARN, bump {@code payg.meter.errors}, and swallow — durability comes from the {@code
* payg_meter_event_log} row written around every attempt (pending → posted/failed) and {@link
* PaygMeterReconcileScheduler}, which retries unposted rows, not from retries here. Caller's {@code
* close()} must not roll back because Stripe wobbled.
*
* <p>Both config keys default to empty so unit tests / local dev never crash on missing env. When
* blank, this service no-ops at WARN-debug level — useful for SaaS smoke tests that don't want to
* touch the real edge function.
*/
@Service
@Profile("saas")
@Slf4j
public class PaygMeterReportingService {
private final String endpoint;
private final String authToken;
private final RestTemplate restTemplate;
private final PaygMeterEventLogRepository eventLogRepository;
private final Counter errorsCounter;
/** Stripe error bodies can be large; the column is TEXT but we cap to keep rows sane. */
private static final int MAX_ERROR_BODY = 4000;
public PaygMeterReportingService(
@Value("${payg.meter.endpoint:}") String endpoint,
@Value("${payg.meter.auth-token:}") String authToken,
RestTemplate saasRestTemplate,
PaygMeterEventLogRepository eventLogRepository,
MeterRegistry meterRegistry) {
this.endpoint = endpoint;
this.authToken = authToken;
this.restTemplate = saasRestTemplate;
this.eventLogRepository = eventLogRepository;
this.errorsCounter =
Counter.builder("payg.meter.errors")
.description("Failures POSTing PAYG meter events to Supabase edge function")
.register(meterRegistry);
}
/**
* Best-effort POST of a single billable event, wrapped in a durable audit row. Idempotency on
* the Supabase side is keyed on {@code idempotency_key} — supply a deterministic value (e.g.
* {@code "process:<uuid>:close"}) so a retry, a reconcile replay, or a double-fire from two
* pods never charges twice.
*
* <p>Flow: write a pending {@code payg_meter_event_log} row (idempotent), POST to the edge fn,
* then stamp the row posted or record the Stripe error. The row is what {@link
* PaygMeterReconcileScheduler} retries, so a failed POST is recoverable rather than silently
* dropped.
*
* <p>Never throws. The wallet ledger entry is the source of truth for what the customer is
* billed; if the POST fails the only loss is that Stripe doesn't see this event until reconcile
* retries it.
*/
public void recordUsage(
Long teamId,
String stripeCustomerId,
int units,
BillingCategory category,
String idempotencyKey,
UUID jobId) {
if (endpoint == null || endpoint.isBlank()) {
log.debug(
"payg.meter.endpoint not configured; skipping meter event for team {} key {}",
teamId,
idempotencyKey);
return;
}
if (units <= 0) {
// Zero-unit events would inflate event count without changing the bill — defensive.
log.debug(
"Skipping meter event with units={} for team {} key {}",
units,
teamId,
idempotencyKey);
return;
}
// Durable pending row before the POST so a failure leaves a record the reconcile scheduler
// can retry. Idempotent insert (ON CONFLICT DO NOTHING) — the completion + stale-close
// triggers and reconcile retries all share the key. Best-effort: a logging failure must
// never stop us from actually metering.
try {
eventLogRepository.insertPending(teamId, jobId, idempotencyKey, units);
} catch (Exception e) {
log.warn(
"payg_meter_event_log pending insert failed for key {} (still metering): {}",
idempotencyKey,
e.getMessage());
}
PostOutcome outcome =
postToStripe(teamId, stripeCustomerId, units, category, idempotencyKey);
try {
if (outcome.success()) {
eventLogRepository.markPosted(idempotencyKey);
} else {
eventLogRepository.markFailed(
idempotencyKey, outcome.errorCode(), outcome.errorBody());
}
} catch (Exception e) {
log.warn(
"payg_meter_event_log result update failed for key {}: {}",
idempotencyKey,
e.getMessage());
}
}
/**
* POST the event to the edge fn. Never throws; returns success / the captured Stripe error.
* Increments {@code payg.meter.errors} on any non-2xx or exception (unchanged metric contract).
*/
private PostOutcome postToStripe(
Long teamId,
String stripeCustomerId,
int units,
BillingCategory category,
String idempotencyKey) {
try {
HttpHeaders headers = new HttpHeaders();
if (authToken != null && !authToken.isBlank()) {
headers.setBearerAuth(authToken);
}
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body =
Map.of(
"team_id",
// JSON number — the edge fn type-checks and ignores strings.
teamId == null ? -1L : teamId,
"stripe_customer_id",
stripeCustomerId == null ? "" : stripeCustomerId,
"units",
units,
"idempotency_key",
idempotencyKey,
"metadata",
Map.of("category", category == null ? "UNKNOWN" : category.name()));
ResponseEntity<String> response =
restTemplate.exchange(
endpoint,
HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);
if (response.getStatusCode().is2xxSuccessful()) {
return PostOutcome.ok();
}
log.warn(
"Meter event POST returned {} for team {} key {}: {}",
response.getStatusCode(),
teamId,
idempotencyKey,
response.getBody());
errorsCounter.increment();
return PostOutcome.error(
String.valueOf(response.getStatusCode().value()), response.getBody());
} catch (Exception e) {
// Catch-all by design: this method MUST NOT propagate. The customer's bill via the
// ledger is correct; we just owe Stripe an event the reconcile scheduler will retry.
log.warn(
"Meter event POST failed for team {} key {}: {}",
teamId,
idempotencyKey,
e.getMessage());
errorsCounter.increment();
return PostOutcome.error("exception", e.getMessage());
}
}
/** Outcome of one edge-fn POST attempt. */
private record PostOutcome(boolean success, String errorCode, String errorBody) {
static PostOutcome ok() {
return new PostOutcome(true, null, null);
}
static PostOutcome error(String code, String body) {
String trimmedCode = code != null && code.length() > 64 ? code.substring(0, 64) : code;
String trimmedBody =
body != null && body.length() > MAX_ERROR_BODY
? body.substring(0, MAX_ERROR_BODY)
: body;
return new PostOutcome(false, trimmedCode, trimmedBody);
}
}
}
@@ -0,0 +1,18 @@
package stirling.software.saas.payg.model;
/**
* Analytics / in-app breakdown axis stamped on every billable ledger entry and shadow charge. PAYG
* stays on a single flat-priced Stripe meter — category is metadata only, never affects pricing.
*
* <p>Precedence at translation time (interceptor): AUTOMATION → AI → API → BYPASSED. {@link
* #BYPASSED} is the default for manual UI tool calls that never hit a billable code path.
*
* <p>Listing order matters only as the default sentinel ({@link #BYPASSED} first); no downstream
* relies on {@code ordinal()}.
*/
public enum BillingCategory {
BYPASSED,
API,
AI,
AUTOMATION
}
@@ -71,6 +71,16 @@ public class PaygTeamExtensions implements Serializable {
@Column(name = "payg_subscription_id", unique = true, length = 128)
private String paygSubscriptionId;
/**
* Remaining one-time free documents for this team (the lifetime grant). Seeded from the
* effective pricing policy's {@code free_tier_units} when this row is created (V14 trigger,
* updated in V19); decremented by the charge pipeline when a billable charge is written and
* restored on a first-step refund. Never replenishes; survives subscribing. This counter — not
* the wallet ledger — is the source of truth for the grant, so old ledger rows can be pruned.
*/
@Column(name = "free_units_remaining", nullable = false)
private Long freeUnitsRemaining = 0L;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@@ -74,18 +74,14 @@ public class PricingPolicy implements Serializable {
private Integer fileUnitCap = 1000;
/**
* Free-tier allowance — doc units a team on this policy can consume per cycle before they must
* add a card. {@code 0} (default) means no free tier; the team is blocked at 402 from their
* very first tool call until they pay. New-signup defaults will set this to a sensible positive
* value; the special launch policy used by the day-1 legacy migration script can override with
* a different size if product wants the original 10 customers to feel special.
*
* <p>Enforced by {@code PaygTeamUsageService} (PR-SB-4) on every tool call, gated on {@code
* payg_team_extensions.payg_subscription_id IS NULL} — teams with an active subscription bypass
* this check entirely.
* One-time lifetime free document grant handed to a team on creation. {@code 0} (default) means
* no free grant. NOT per-cycle: it never replenishes and a team keeps any unused portion after
* subscribing. The value is copied into {@code payg_team_extensions.free_units_remaining} when
* the team's sidecar row is created (V14 trigger, updated in V19); from then on the per-team
* counter is authoritative and this column is only the seed for new teams.
*/
@Column(name = "free_tier_units_per_cycle", nullable = false)
private Long freeTierUnitsPerCycle = 0L;
@Column(name = "free_tier_units", nullable = false)
private Long freeTierUnits = 0L;
/**
* Max tool steps allowed in one process before it splits, keyed by the caller's {@link
@@ -0,0 +1,81 @@
package stirling.software.saas.payg.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import stirling.software.saas.payg.meter.PaygMeterEventLog;
@Repository
public interface PaygMeterEventLogRepository extends JpaRepository<PaygMeterEventLog, Long> {
/**
* Idempotent pending insert. The two meter triggers (charge-completion + stale-close) share the
* idempotency key, and a reconcile retry re-runs the same path, so {@code ON CONFLICT DO
* NOTHING} keeps the audit row at exactly one per key. {@code occurred_at} defaults to {@code
* CURRENT_TIMESTAMP} at the DB; {@code posted_to_stripe_at} stays NULL until success.
* Transactional because it's called from the non-transactional meter-reporting path.
*/
@Transactional
@Modifying
@Query(
value =
"INSERT INTO payg_meter_event_log (team_id, job_id, idempotency_key, units)"
+ " VALUES (:teamId, :jobId, :key, :units)"
+ " ON CONFLICT (idempotency_key) DO NOTHING",
nativeQuery = true)
void insertPending(
@Param("teamId") Long teamId,
@Param("jobId") UUID jobId,
@Param("key") String key,
@Param("units") int units);
/** Stamp an event posted; clears any prior error. No-op if already posted. */
@Transactional
@Modifying
@Query(
"UPDATE PaygMeterEventLog e"
+ " SET e.postedToStripeAt = CURRENT_TIMESTAMP,"
+ " e.stripeErrorCode = NULL, e.stripeErrorBody = NULL"
+ " WHERE e.idempotencyKey = :key AND e.postedToStripeAt IS NULL")
int markPosted(@Param("key") String key);
/** Record the latest Stripe error against a still-pending event. */
@Transactional
@Modifying
@Query(
"UPDATE PaygMeterEventLog e"
+ " SET e.stripeErrorCode = :code, e.stripeErrorBody = :body"
+ " WHERE e.idempotencyKey = :key AND e.postedToStripeAt IS NULL")
int markFailed(
@Param("key") String key, @Param("code") String code, @Param("body") String body);
/**
* Events still unposted whose attempt is older than {@code cutoff} (give the live POST time to
* land first) but within {@code floor} — Stripe's 24h idempotency window — so a retry under the
* same key safely dedups rather than risking a double charge. Oldest first.
*/
@Query(
"SELECT e FROM PaygMeterEventLog e"
+ " WHERE e.postedToStripeAt IS NULL"
+ " AND e.occurredAt < :cutoff AND e.occurredAt >= :floor"
+ " ORDER BY e.occurredAt ASC")
List<PaygMeterEventLog> findRetryable(
@Param("cutoff") LocalDateTime cutoff,
@Param("floor") LocalDateTime floor,
Pageable pageable);
/** Events stuck unposted past the safe retry window — surfaced for manual reconciliation. */
@Query(
"SELECT COUNT(e) FROM PaygMeterEventLog e"
+ " WHERE e.postedToStripeAt IS NULL AND e.occurredAt < :floor")
long countStuck(@Param("floor") LocalDateTime floor);
}
@@ -29,4 +29,20 @@ public interface PaygShadowChargeRepository extends JpaRepository<PaygShadowChar
* defensively if a duplicate ever appears.
*/
Optional<PaygShadowCharge> findFirstByJobIdOrderByIdAsc(UUID jobId);
/**
* Paid (Stripe-metered) documents for a team in a period: {@code SUM(payg_units −
* free_units_consumed)} over CHARGED rows. This is exactly what was reported to Stripe in the
* window, so the wallet's "estimated bill so far" is the metered total × rate. REFUNDED rows
* are excluded.
*/
@Query(
"SELECT COALESCE(SUM(s.paygUnits - s.freeUnitsConsumed), 0) FROM PaygShadowCharge s"
+ " WHERE s.teamId = :teamId"
+ " AND s.status = stirling.software.saas.payg.model.ShadowChargeStatus.CHARGED"
+ " AND s.occurredAt >= :from AND s.occurredAt < :to")
long sumPaidUnits(
@Param("teamId") Long teamId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
}
@@ -3,12 +3,40 @@ package stirling.software.saas.payg.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import jakarta.persistence.LockModeType;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
@Repository
public interface PaygTeamExtensionsRepository extends JpaRepository<PaygTeamExtensions, Long> {
Optional<PaygTeamExtensions> findByStripeCustomerId(String stripeCustomerId);
/**
* Pessimistic-write load of the sidecar row, used by the charge pipeline to deduct the one-time
* free grant atomically. The lock serialises concurrent charges <em>for the same team</em> so
* the per-job {@code free_units_consumed} split (and therefore the metered paid portion) is
* exact — two simultaneous jobs can't both believe they drew from the same remaining unit.
* Different teams never contend; the lock is held only for the {@code openProcess} transaction.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM PaygTeamExtensions e WHERE e.teamId = :teamId")
Optional<PaygTeamExtensions> findByIdForUpdate(@Param("teamId") Long teamId);
/**
* Atomically returns {@code freeUnitsConsumed} to the team's grant on a refund. Increment is
* commutative so no lock is needed; the amount restored is exactly what the job consumed, so it
* can never exceed the original grant.
*/
@Modifying
@Query(
"UPDATE PaygTeamExtensions e SET e.freeUnitsRemaining = e.freeUnitsRemaining + :units"
+ " WHERE e.teamId = :teamId")
int restoreFreeUnits(@Param("teamId") Long teamId, @Param("units") long units);
}
@@ -14,7 +14,30 @@ import stirling.software.saas.payg.wallet.WalletLedgerEntry;
@Repository
public interface WalletLedgerRepository extends JpaRepository<WalletLedgerEntry, Long> {
List<WalletLedgerEntry> findByTeamIdOrderByOccurredAtDesc(Long teamId);
/** Most recent entries for the Plan page activity feed. */
List<WalletLedgerEntry> findTop20ByTeamIdOrderByIdDesc(Long teamId);
/**
* Per-category debit totals over an arbitrary window, as positive units. Replaces the
* calendar-month {@code wallet_category_summary} view on the wallet endpoint — subscribed
* teams' billing windows are anchored to the Stripe subscription period, not month starts. Rows
* with {@code NULL} category (system entries) are excluded; BYPASSED never reaches the ledger
* by construction.
*/
@Query(
"SELECT e.billingCategory AS category, COALESCE(SUM(-e.amountUnits), 0) AS units"
+ " FROM WalletLedgerEntry e"
+ " WHERE e.teamId = :teamId"
+ " AND e.entryType = :entryType"
+ " AND e.billingCategory IS NOT NULL"
+ " AND e.occurredAt >= :periodStart"
+ " AND e.occurredAt < :periodEnd"
+ " GROUP BY e.billingCategory")
List<Object[]> sumPeriodAmountByCategory(
@Param("teamId") Long teamId,
@Param("entryType") LedgerEntryType entryType,
@Param("periodStart") LocalDateTime periodStart,
@Param("periodEnd") LocalDateTime periodEnd);
/** Sum of signed amounts over a team's entries — the wallet's current balance in units. */
@Query(
@@ -34,6 +57,24 @@ public interface WalletLedgerRepository extends JpaRepository<WalletLedgerEntry,
@Param("periodStart") LocalDateTime periodStart,
@Param("periodEnd") LocalDateTime periodEnd);
/**
* Net signed period balance over billable entries (DEBIT negative + REFUND positive). Negate
* for positive spend. Unlike {@link #sumPeriodAmount} (DEBIT only) this nets refunds, so a
* refunded job no longer reads as spent — the headline period-spend figure for the subscribed
* monthly bill + cap.
*/
@Query(
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e"
+ " WHERE e.teamId = :teamId"
+ " AND e.entryType IN (stirling.software.saas.payg.model.LedgerEntryType.DEBIT,"
+ " stirling.software.saas.payg.model.LedgerEntryType.REFUND)"
+ " AND e.occurredAt >= :periodStart"
+ " AND e.occurredAt < :periodEnd")
long sumPeriodNetBillable(
@Param("teamId") Long teamId,
@Param("periodStart") LocalDateTime periodStart,
@Param("periodEnd") LocalDateTime periodEnd);
/** Per-member period spend (only when the member has a sub-cap configured). */
@Query(
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e"
@@ -19,6 +19,8 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ShadowChargeStatus;
/**
@@ -56,6 +58,15 @@ public class PaygShadowCharge implements Serializable {
@Column(name = "payg_units", nullable = false)
private Integer paygUnits;
/**
* How many of {@link #paygUnits} were drawn from the team's one-time free grant at charge time.
* The paid (Stripe-metered) portion is {@code paygUnits - freeUnitsConsumed}; a refund restores
* this many units to {@code payg_team_extensions.free_units_remaining}. {@code 0} for pre-V19
* rows and for jobs that consumed no free units (team's grant already exhausted).
*/
@Column(name = "free_units_consumed", nullable = false)
private Integer freeUnitsConsumed = 0;
@Column(name = "legacy_credits_charged", nullable = false)
private Integer legacyCreditsCharged;
@@ -75,6 +86,24 @@ public class PaygShadowCharge implements Serializable {
@Column(name = "refund_reason", length = 128)
private String refundReason;
/**
* PAYG analytics axis copied from the request that produced this shadow row. {@code null} for
* pre-V16 rows; populated by the charge interceptor going forward. Never affects what Stripe
* would meter — the flat-meter assumption holds, this is breakdown metadata.
*/
@Enumerated(EnumType.STRING)
@Column(name = "billing_category", length = 16)
private BillingCategory billingCategory;
/**
* Caller surface that originated the job (copied from {@code processing_job.source} at write
* time so the row stays self-describing after the job table is pruned). {@code null} for
* pre-V16 rows backfilled when {@code processing_job} is no longer present.
*/
@Enumerated(EnumType.STRING)
@Column(name = "job_source", length = 32)
private JobSource jobSource;
@CreationTimestamp
@Column(name = "occurred_at", nullable = false, updatable = false)
private LocalDateTime occurredAt;
@@ -0,0 +1,215 @@
package stirling.software.saas.payg.stripe;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import lombok.extern.slf4j.Slf4j;
/**
* Read-only accessor for the Stripe Sync Engine schema ({@code stripe.*}). Gives the PAYG layer the
* team's real billing window and the per-document rate of the Price its subscription bills against
* — both live in Stripe, mirrored into Postgres by the sync engine, and are NOT duplicated in
* {@code stirling_pdf} (design §10: money lives in Stripe).
*
* <p>PAYG prices are plain {@code per_unit} metered prices, so {@code stripe.prices.unit_amount}
* carries the rate directly. The free grant is deliberately NOT in Stripe — it's the one-time
* {@code pricing_policy.free_tier_units} pool, applied app-side (free units are never metered),
* because un-subscribed teams get the same grant and have no Stripe Price at all.
*
* <p>Defensive by construction: the {@code stripe} schema only exists where the sync engine has run
* (dev + prod Supabase, not unit-test H2). Any {@link DataAccessException} — missing schema,
* missing row, connectivity blip — degrades to {@link Optional#empty()} with a WARN so callers fall
* back to calendar-month windows rather than 500ing the wallet endpoint.
*/
@Slf4j
@Repository
@Profile("saas")
public class StripeSubscriptionDao {
/**
* Billing window + per-document rate of one subscription. Epoch seconds come back from sync
* engine as INTEGER columns; converted to {@link LocalDateTime} in the system zone so they
* compare cleanly against {@code wallet_ledger.occurred_at} (written by
* {@code @CreationTimestamp} with JVM-local semantics).
*
* @param priceId the Stripe Price the subscription's (sole) item bills against; null if the
* item row hasn't synced yet
* @param currency lower-case ISO 4217 of that Price; null when the price row is missing
* @param perDocMinor per-document rate in minor units (may be fractional via {@code
* unit_amount_decimal}); null when the price row is missing or carries no usable amount
* (e.g. a tiered price, which PAYG doesn't use)
*/
public record SubscriptionBilling(
LocalDateTime periodStart,
LocalDateTime periodEnd,
String priceId,
String status,
String currency,
BigDecimal perDocMinor) {}
/**
* The per-document rate of a Price looked up directly (not via a subscription) — used to price
* the cap estimate for un-subscribed teams, whose default policy points at Stripe Prices that
* carry the same {@code unit_amount} they'd be billed at on subscribing.
*
* @param priceId the resolved Stripe Price id
* @param currency lower-case ISO 4217 of that Price
* @param perDocMinor per-document rate in minor units (may be fractional); never null — a row
* with no usable amount is filtered out rather than returned with a null rate
*/
public record PriceRate(String priceId, String currency, BigDecimal perDocMinor) {}
private static final String QUERY =
"SELECT s.current_period_start, s.current_period_end, s.status::text AS status,"
+ " p.id AS price_id, p.currency AS currency,"
+ " p.unit_amount AS unit_amount, p.unit_amount_decimal AS unit_amount_decimal"
+ " FROM stripe.subscriptions s"
+ " LEFT JOIN LATERAL ("
+ " SELECT si.price FROM stripe.subscription_items si"
+ " WHERE si.subscription = s.id AND COALESCE(si.deleted, false) = false"
+ " ORDER BY si.created DESC NULLS LAST LIMIT 1"
+ " ) item ON true"
+ " LEFT JOIN stripe.prices p ON p.id = item.price"
+ " WHERE s.id = ?";
private final JdbcTemplate jdbcTemplate;
public StripeSubscriptionDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate");
}
/** Billing window + rate for {@code subscriptionId}; empty if unsynced or schema absent. */
public Optional<SubscriptionBilling> findBilling(String subscriptionId) {
if (subscriptionId == null || subscriptionId.isBlank()) {
return Optional.empty();
}
try {
List<SubscriptionBilling> rows =
jdbcTemplate.query(
QUERY,
(rs, i) -> {
long startEpoch = rs.getLong("current_period_start");
boolean startNull = rs.wasNull();
long endEpoch = rs.getLong("current_period_end");
boolean endNull = rs.wasNull();
if (startNull || endNull) {
return null;
}
return new SubscriptionBilling(
toLocal(startEpoch),
toLocal(endEpoch),
rs.getString("price_id"),
rs.getString("status"),
rs.getString("currency"),
extractRate(rs));
},
subscriptionId);
return rows.stream().filter(Objects::nonNull).findFirst();
} catch (DataAccessException e) {
// Missing stripe schema (sync engine not provisioned) or transient DB issue. The
// caller falls back to a calendar-month window; usage still accrues correctly.
log.warn(
"stripe.subscriptions lookup failed for {}: {}",
subscriptionId,
e.getMessage());
return Optional.empty();
}
}
/**
* Per-document rate of the active {@code stripe.prices} row with the given {@code lookupKey} in
* {@code currency} — the elegant mirror of {@link #findBilling}, reading the same synced table
* by Stripe Price {@code lookup_key} instead of via a subscription. The PAYG layer uses this to
* price the cap estimate for an un-subscribed team: there's no subscription to read a rate off,
* but the PAYG Price (lookup key {@code plan:processor}) carries the very rate they'd be billed
* at. We resolve by lookup_key rather than the default policy's price ids because those aren't
* seeded — the lookup key is the stable, env-agnostic handle (same one the price-lookup edge
* function uses).
*
* <p>Empty when the {@code stripe} schema is absent (H2 unit tests), no active matching row
* exists, or the row carries no usable per-unit amount (e.g. a tiered price, which PAYG doesn't
* use). Callers degrade to "no estimate" exactly as the subscribed path degrades to a
* calendar-month window.
*/
public Optional<PriceRate> findRateByLookupKey(String lookupKey, String currency) {
if (lookupKey == null || lookupKey.isBlank() || currency == null || currency.isBlank()) {
return Optional.empty();
}
String sql =
"SELECT p.id AS price_id, p.currency AS currency,"
+ " p.unit_amount AS unit_amount,"
+ " p.unit_amount_decimal AS unit_amount_decimal"
+ " FROM stripe.prices p"
+ " WHERE p.lookup_key = ? AND p.currency = ?"
+ " AND COALESCE(p.active, true) = true"
+ " ORDER BY p.created DESC NULLS LAST LIMIT 1";
try {
List<PriceRate> rows =
jdbcTemplate.query(
sql,
(rs, i) -> {
BigDecimal rate = extractRate(rs);
if (rate == null) {
return null;
}
return new PriceRate(
rs.getString("price_id"), rs.getString("currency"), rate);
},
lookupKey,
currency.toLowerCase());
return rows.stream().filter(Objects::nonNull).findFirst();
} catch (DataAccessException e) {
log.warn(
"stripe.prices rate lookup failed for lookup_key {} / currency {}: {}",
lookupKey,
currency,
e.getMessage());
return Optional.empty();
}
}
/**
* Reads the per-document rate off a {@code stripe.prices} row. Prefers {@code
* unit_amount_decimal} (sub-minor-unit precision, e.g. half-cent rates), falls back to the
* integer {@code unit_amount}, and returns null when neither is usable or the amount is ≤ 0
* (tiered/zero prices PAYG doesn't bill on). Both queries alias the columns identically so this
* is shared verbatim.
*/
private static BigDecimal extractRate(ResultSet rs) throws SQLException {
BigDecimal rate = null;
String decimal = rs.getString("unit_amount_decimal");
if (decimal != null && !decimal.isBlank()) {
try {
rate = new BigDecimal(decimal);
} catch (NumberFormatException ignore) {
rate = null;
}
}
if (rate == null) {
long unitAmount = rs.getLong("unit_amount");
if (!rs.wasNull()) {
rate = BigDecimal.valueOf(unitAmount);
}
}
if (rate != null && rate.signum() <= 0) {
rate = null;
}
return rate;
}
private static LocalDateTime toLocal(long epochSeconds) {
return LocalDateTime.ofInstant(Instant.ofEpochSecond(epochSeconds), ZoneId.systemDefault());
}
}
@@ -22,6 +22,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.LedgerBucket;
import stirling.software.saas.payg.model.LedgerEntryType;
import stirling.software.saas.payg.model.ReferenceType;
@@ -76,6 +77,15 @@ public class WalletLedgerEntry implements Serializable {
@Column(name = "stripe_event_id", length = 128)
private String stripeEventId;
/**
* PAYG analytics axis. {@code null} for system entries (grants, resets) and pre-V16 rows; set
* by the charge interceptor for billable debits. Stripe pricing never reads this — it's a
* single flat meter — so the column stays soft-typed (no NOT NULL, no FK).
*/
@Enumerated(EnumType.STRING)
@Column(name = "billing_category", length = 16)
private BillingCategory billingCategory;
@CreationTimestamp
@Column(name = "occurred_at", nullable = false, updatable = false)
private LocalDateTime occurredAt;
@@ -42,6 +42,24 @@ public interface TeamMembershipRepository extends JpaRepository<TeamMembership,
@Query("SELECT tm FROM TeamMembership tm JOIN FETCH tm.team WHERE tm.user.id = :userId")
List<TeamMembership> findByUserId(@Param("userId") Long userId);
/**
* Resolve the single membership a user belongs to. In the PAYG design every user is owned by
* exactly one team — personal-team-for-new-signups, then optionally migrated when they accept
* an invite (the old personal team is deleted on accept). For diagnostic safety this picks the
* earliest-created row if multiple exist, but in steady state there is exactly one.
*
* <p>Returns both the team and its role so the PAYG wallet endpoint can answer "what does this
* user see?" in a single query rather than a list-then-filter dance.
*
* @param userId the user ID
* @return the user's primary membership, if any
*/
@Query(
"SELECT tm FROM TeamMembership tm JOIN FETCH tm.team"
+ " WHERE tm.user.id = :userId"
+ " ORDER BY tm.createdAt ASC")
List<TeamMembership> findPrimaryMembership(@Param("userId") Long userId);
/**
* Find all members with a specific role in a team
*
@@ -54,6 +72,16 @@ public interface TeamMembershipRepository extends JpaRepository<TeamMembership,
List<TeamMembership> findByTeamIdAndRole(
@Param("teamId") Long teamId, @Param("role") TeamRole role);
/**
* Count members with a specific role in a team. Lighter than {@link #findByTeamIdAndRole} when
* only the tally is needed (e.g. last-leader checks) — avoids fetching and join-loading rows.
*
* @param teamId the team ID
* @param role the team role (LEADER or MEMBER)
* @return number of members with that role
*/
long countByTeamIdAndRole(Long teamId, TeamRole role);
/**
* Check if a user is a member of a team
*
@@ -91,7 +91,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
+ " END, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
+ " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)",
nativeQuery = true)
int consumeCreditBySupabaseId(
@@ -109,7 +109,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
+ " cycle_credits_remaining = cycle_credits_remaining - :amount, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
+ " AND cycle_credits_remaining >= :amount",
nativeQuery = true)
int consumeCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
@@ -123,7 +123,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
+ " bought_credits_remaining = bought_credits_remaining - :amount, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
+ " AND bought_credits_remaining >= :amount",
nativeQuery = true)
int consumeBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
@@ -133,7 +133,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
value =
"SELECT CASE WHEN uc.cycle_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
+ "FROM user_credits uc "
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId)",
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)",
nativeQuery = true)
Boolean hasCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
@@ -142,7 +142,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
value =
"SELECT CASE WHEN uc.bought_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
+ "FROM user_credits uc "
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId)",
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)",
nativeQuery = true)
Boolean hasBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
}
@@ -6,6 +6,8 @@ import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import stirling.software.proprietary.security.model.User;
/**
* JWT auth token that exposes the Supabase subject UUID and email alongside the standard claims, so
* downstream code (audit, credit accounting) can avoid re-parsing the JWT every request.
@@ -14,15 +16,35 @@ public class EnhancedJwtAuthenticationToken extends JwtAuthenticationToken {
private final String supabaseId;
private final String email;
private final User user;
public EnhancedJwtAuthenticationToken(
Jwt jwt,
Collection<? extends GrantedAuthority> authorities,
String email,
String supabaseId) {
this(jwt, authorities, email, supabaseId, null);
}
public EnhancedJwtAuthenticationToken(
Jwt jwt,
Collection<? extends GrantedAuthority> authorities,
String email,
String supabaseId,
User user) {
super(jwt, authorities, email);
this.email = email;
this.supabaseId = supabaseId;
this.user = user;
}
/**
* Returns the resolved local {@link User} when available so shared {@code principal instanceof
* User} authorization works under JWT auth; falls back to the decoded Jwt.
*/
@Override
public Object getPrincipal() {
return user != null ? user : super.getPrincipal();
}
public String getSupabaseId() {
@@ -155,9 +155,15 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
User user = getOrCreateUser(jwt);
// Full accounts carry the resolved User as principal for shared
// instanceof-User authorization; anonymous sessions keep the raw Jwt.
EnhancedJwtAuthenticationToken authToken =
new EnhancedJwtAuthenticationToken(
jwt, user.getAuthorities(), user.getUsername(), supabaseId);
jwt,
user.getAuthorities(),
user.getUsername(),
supabaseId,
isAnonymous(jwt) ? null : user);
SecurityContextHolder.getContext().setAuthentication(authToken);
// Hot path: runs on every authenticated request (>10 per page on a typical SPA),
@@ -23,8 +23,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
@@ -44,6 +46,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.service.CreditService;
@@ -333,6 +336,16 @@ public class SupabaseSecurityConfig {
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(",")));
}
return new EnhancedJwtAuthenticationToken(jwt, authorities, email, supabaseId);
// BearerTokenAuthenticationFilter overwrites the context SupabaseAuthenticationFilter
// built; carry its resolved User across so instanceof-User authorization keeps working.
User user = null;
Authentication existing = SecurityContextHolder.getContext().getAuthentication();
if (existing instanceof EnhancedJwtAuthenticationToken enhanced
&& supabaseId != null
&& supabaseId.equals(enhanced.getSupabaseId())
&& enhanced.getPrincipal() instanceof User existingUser) {
user = existingUser;
}
return new EnhancedJwtAuthenticationToken(jwt, authorities, email, supabaseId, user);
}
}
@@ -303,6 +303,11 @@ public class SaasTeamService {
throw new IllegalStateException("Team has no available seats");
}
// Validate: accepting won't orphan a team the user leads or that has a paid plan.
// Accepting moves the user off their current team; leaveTeam already blocks the
// last leader of a team from walking away, so accept must enforce the same rule.
assertCanLeaveCurrentTeamsToJoinAnother(acceptingUser);
// User can only be in one team . leave existing teams before joining new one
List<TeamMembership> existingMemberships =
membershipRepository.findByUserId(acceptingUser.getId());
@@ -442,6 +447,46 @@ public class SaasTeamService {
teamId);
}
/**
* Guard against silently orphaning a team when a user accepts an invite to another one.
*
* <p>{@link #acceptInvitation} moves a user to the inviting team by first leaving their current
* team(s). Personal teams are disposable (they get deleted on accept), but a non-personal team
* must not be left memberless while still billing. {@link #leaveTeam} already refuses to let
* the last leader walk away; accept took a shortcut around that check, which let a paid team's
* leader join another team and orphan their old team together with its live subscription.
*
* <p>So: for each non-personal team the user leads as its <em>last</em> leader, block the
* accept. The message points them at the right remedy — cancel the plan if the team is paid,
* otherwise transfer leadership first.
*
* @param user the user attempting to accept an invitation
* @throws IllegalStateException if accepting would orphan a team the user leads
*/
private void assertCanLeaveCurrentTeamsToJoinAnother(User user) {
for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) {
Team team = membership.getTeam();
if (saasTeamExtensionService.isPersonal(team) || !membership.isLeader()) {
// Personal teams are deleted on accept; non-leaders leaving never orphans a team.
continue;
}
// Only reached for a non-personal team the user leads — at most one such team in the
// one-team-per-user model — so this count runs ~once, not per membership.
if (membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) > 1) {
// Another leader remains, so the team keeps an owner.
continue;
}
if (hasActivePaidSubscription(team)) {
throw new IllegalStateException(
"Your team has an active plan and you are its last leader. Cancel the plan"
+ " or transfer leadership before joining another team.");
}
throw new IllegalStateException(
"You are the last leader of your team. Transfer leadership before joining"
+ " another team.");
}
}
/**
* Leave team (self-removal)
*

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