Revert "SaaS fixes (#6578)"

This reverts commit ddf78d11ae.
This commit is contained in:
Anthony Stirling
2026-06-16 16:48:30 +01:00
committed by GitHub
parent ddf78d11ae
commit 5389e39cfc
415 changed files with 5857 additions and 29554 deletions
@@ -0,0 +1,136 @@
name: Docker Compose Cucumber tests (saas / PAYG)
# Self-contained CI job for the PAYG shadow-mode cucumber scenarios.
# Triggers only on PAYG-relevant paths so we don't add CI minutes to every PR
# that doesn't touch the saas flavour.
#
# Companion to `docker-compose-tests.yml` (which runs against the
# proprietary-flavour stack and skips features/payg via behave.ini's
# exclude_re). Kept as a separate workflow so the saas matrix can fail and
# succeed independently without touching the main cucumber harness.
on:
pull_request:
paths:
- "app/saas/**"
- "testing/cucumber/features/payg/**"
- "testing/cucumber/features/steps/payg_step_definitions.py"
- "testing/cucumber/requirements.txt"
- "testing/compose/docker-compose-saas.yml"
- "testing/compose/payg/**"
- "testing/test-payg.sh"
- ".github/workflows/docker-compose-tests-saas.yml"
push:
branches: [main]
paths:
- "app/saas/**"
- "testing/cucumber/features/payg/**"
- "testing/cucumber/features/steps/payg_step_definitions.py"
- "testing/cucumber/requirements.txt"
- "testing/compose/docker-compose-saas.yml"
- "testing/compose/payg/**"
- "testing/test-payg.sh"
- ".github/workflows/docker-compose-tests-saas.yml"
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
docker-compose-tests-saas:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
permissions:
actions: write
contents: read
checks: write
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-saas-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
cache-disabled: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Expose GitHub runtime for Buildx cache
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
# No "Install Docker Compose" step: Ubuntu runners ship with `docker compose`
# v2 (built into the Docker CLI). test-payg.sh uses the v2 form throughout
# (`docker compose …`, no hyphen), so the legacy v1 `docker-compose` binary
# isn't needed. Avoids a `curl | sudo install` without checksum verification
# (Aikido flagged this when copy-pasted from docker-compose-tests.yml).
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: ./testing/cucumber/requirements.txt
- name: Pip requirements
run: |
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
- name: Run PAYG Cucumber Tests
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
run: |
chmod +x ./testing/test-payg.sh
./testing/test-payg.sh
- name: Dump saas container logs on failure
if: failure()
run: |
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 500 stirling-pdf-saas || true
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 200 postgres-saas || true
- name: Upload PAYG Cucumber Report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: payg-cucumber-report
path: testing/cucumber/report-payg.html
retention-days: 7
if-no-files-found: warn
- name: PAYG Cucumber Test Report
if: always()
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: PAYG Cucumber Tests
path: testing/cucumber/junit-payg/*.xml
reporter: java-junit
fail-on-error: false
+2 -22
View File
@@ -188,30 +188,10 @@ jobs:
name: backend-log-live-${{ github.run_id }}
path: .test-state/playwright/backend.log
retention-days: 7
- name: List Playwright output locations (debug)
if: always()
run: |
echo "::group::Playwright output dirs"
# Playwright anchors its default outputDir + HTML report to the
# nearest package.json, which is frontend/ (frontend/editor has
# none), so artifacts land under frontend/, not frontend/editor/.
ls -la frontend/playwright-report 2>/dev/null \
|| echo "no playwright-report at frontend/"
ls -la frontend/test-results 2>/dev/null \
|| echo "no test-results at frontend/"
find . -name node_modules -prune -o -name 'trace.zip' -print 2>/dev/null || true
echo "::endgroup::"
- name: Upload Playwright report + traces
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-live-${{ github.run_id }}
# test-results/ holds the per-test trace.zip (with browser console
# logs) + screenshots/video; playwright-report/ is the HTML report.
# Both live under frontend/ (Playwright anchors them to the nearest
# package.json, which is frontend/; frontend/editor has none).
path: |
frontend/playwright-report/
frontend/test-results/
path: frontend/editor/playwright-report/
retention-days: 7
if-no-files-found: warn
-14
View File
@@ -18,15 +18,6 @@ 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"}}'
@@ -59,14 +50,9 @@ 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,11 +20,6 @@ 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:
+16 -3
View File
@@ -1,5 +1,12 @@
version: '3'
vars:
# Engine-specific names to avoid overriding the root Taskfile's FIND_FREE_PORT_*
# vars (Task merges included-file vars into the global scope).
# Paths are relative to the engine/ include dir.
ENGINE_FIND_FREE_PORT_SH: "bash ../scripts/find-free-port.sh"
ENGINE_FIND_FREE_PORT_PS: "powershell -NoProfile -File ../scripts/find-free-port.ps1"
tasks:
install:
desc: "Install engine dependencies"
@@ -29,11 +36,14 @@ tasks:
ignore_error: true
dir: src
vars:
PORT: '{{.PORT | default "5001"}}'
# When PORT is provided (e.g. from dev:all), use it directly.
# When running standalone, probe for a free port starting at 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}}'
env:
PYTHONUNBUFFERED: "1"
cmds:
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}}
dev:
desc: "Start engine dev server with hot reload"
@@ -41,7 +51,10 @@ tasks:
ignore_error: true
dir: src
vars:
PORT: '{{.PORT | default "5001"}}'
# When PORT is provided (e.g. from dev:all), use it directly.
# When running standalone, probe for a free port starting at 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}}'
env:
PYTHONUNBUFFERED: "1"
cmds:
+16 -12
View File
@@ -60,20 +60,24 @@ tasks:
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
cmds:
- task: dev:_all
vars: { FRONTEND: saas, BACKEND: saas }
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"
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}}'
@@ -83,11 +87,11 @@ tasks:
- task: engine:dev
vars:
PORT: '{{.ENGINE_PORT}}'
- task: 'backend:dev:{{.BACKEND}}'
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
- task: 'frontend:dev:{{.FRONTEND}}'
- task: frontend:dev
vars:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
@@ -367,15 +367,6 @@ 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
@@ -1005,10 +996,6 @@ public class ApplicationProperties {
@Data
public static class Signing {
private boolean enabled = false;
// Signing user-picker scope: 'org' (default) = whole instance, anything else =
// caller's team only (fail-closed). The saas profile pins 'team'.
private String userListScope = "org";
}
}
@@ -50,16 +50,6 @@ 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;
@@ -106,11 +96,6 @@ 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");
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
// without this RestTemplate would use urlencoded instead of the multipart the controller
@@ -31,22 +31,6 @@ class ApplicationPropertiesLogicTest {
assertTrue(sys.isAnalyticsEnabled());
}
@Test
void storageSigning_userListScope_defaultsToOrg_andIsSettable() {
// Self-host backward-compat: scope must default to "org" (saas profile pins "team").
ApplicationProperties.Storage.Signing signing = new ApplicationProperties.Storage.Signing();
assertFalse(signing.isEnabled());
assertEquals("org", signing.getUserListScope());
signing.setUserListScope("team");
assertEquals("team", signing.getUserListScope());
// Reachable from the full tree as storage.signing.userListScope.
assertEquals(
"org", new ApplicationProperties().getStorage().getSigning().getUserListScope());
}
@Test
void tempFileManagement_defaults_and_overrides() {
Function<String, String> normalize = s -> Path.of(s).normalize().toString();
@@ -59,53 +59,6 @@ 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<>();
@@ -290,7 +290,6 @@ storage:
linkExpirationDays: 3 # Number of days before share links expire
signing:
enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA]
userListScope: org # Signing user-picker scope: 'org' (default) = whole instance, else caller's team only.
autoPipeline:
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
fileReadiness:
@@ -391,9 +390,6 @@ 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,9 +1,6 @@
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;
@@ -11,35 +8,20 @@ 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 (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.
* 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.
*/
public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
private final Set<String> acceptedAudiences;
private final String expectedResourceId;
public McpAudienceValidator(String 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;
this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt token) {
if (acceptedAudiences.isEmpty()) {
if (expectedResourceId.isBlank()) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error(
"invalid_token",
@@ -48,13 +30,12 @@ public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
null));
}
List<String> aud = token.getAudience();
if (aud == null || aud.stream().noneMatch(acceptedAudiences::contains)) {
if (aud == null || !aud.contains(expectedResourceId)) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error(
"invalid_token",
"Token audience does not include this server's resource id or an"
+ " accepted audience ("
+ String.join(", ", acceptedAudiences)
"Token audience does not include this server's resource id ("
+ expectedResourceId
+ ").",
null));
}
@@ -24,7 +24,6 @@ import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
@@ -158,11 +157,7 @@ public class McpSecurityConfig {
throws Exception {
String metadataPath = "/.well-known/oauth-protected-resource";
applyCors(http);
// 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 + "/**")
http.securityMatcher(BASE_PATH, BASE_PATH + "/**", 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
@@ -173,8 +168,7 @@ public class McpSecurityConfig {
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
a ->
a.requestMatchers(
HttpMethod.GET, metadataPath, metadataPath + "/**")
a.requestMatchers(HttpMethod.GET, metadataPath)
.permitAll()
.anyRequest()
.authenticated())
@@ -193,18 +187,28 @@ public class McpSecurityConfig {
.oauth2ResourceServer(
oauth2 ->
oauth2.authenticationEntryPoint(
// Advertise the path-inserted form; RFC 9728 makes
// it the canonical location for a resource with a
// path component.
new McpAuthenticationEntryPoint(
metadataPath + BASE_PATH))
new McpAuthenticationEntryPoint(metadataPath))
// RFC 9728 protected-resource metadata for OAuth discovery.
.protectedResourceMetadata(
prm ->
prm.protectedResourceMetadataCustomizer(
builder ->
buildResourceMetadata(
builder, auth)))
builder -> {
if (!auth.getResourceId()
.isBlank()) {
builder.resource(
auth
.getResourceId());
}
if (!auth.getIssuerUri()
.isBlank()) {
builder.authorizationServer(
auth
.getIssuerUri());
}
builder.scope("mcp.tools.read");
builder.scope(
"mcp.tools.write");
}))
.jwt(
jwt ->
jwt.decoder(mcpJwtDecoder)
@@ -213,25 +217,6 @@ public class McpSecurityConfig {
return http.build();
}
/** Populate the RFC 9728 protected-resource metadata document from the configured auth. */
private void buildResourceMetadata(
OAuth2ProtectedResourceMetadata.Builder builder, ApplicationProperties.Mcp.Auth auth) {
if (!auth.getResourceId().isBlank()) {
builder.resource(auth.getResourceId());
}
if (!auth.getIssuerUri().isBlank()) {
builder.authorizationServer(auth.getIssuerUri());
}
// Only advertise the granular tool scopes when we actually enforce them. When scopes are
// disabled (e.g. the IdP only mints coarse tokens, like Supabase), advertising scopes the
// authorization server can't issue makes spec-compliant clients request them and get
// rejected with invalid_request.
if (applicationProperties.getMcp().isScopesEnabled()) {
builder.scope("mcp.tools.read");
builder.scope("mcp.tools.write");
}
}
@Bean
JwtDecoder mcpJwtDecoder() {
ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
@@ -251,9 +236,7 @@ public class McpSecurityConfig {
JwtValidators.createDefaultWithIssuer(auth.getIssuerUri());
OAuth2TokenValidator<Jwt> combined =
new DelegatingOAuth2TokenValidator<>(
defaultValidators,
new McpAudienceValidator(
auth.getResourceId(), auth.getAcceptedAudiences()));
defaultValidators, new McpAudienceValidator(auth.getResourceId()));
decoder.setJwtValidator(combined);
return decoder;
}
@@ -105,19 +105,4 @@ public class AiWorkflowResponse {
+ " body or via the X-Stirling-Tool-Report header. May be null for tools"
+ " that produce only a file.")
private JsonNode report;
@Schema(
description =
"Structured error code when a downstream tool call was blocked (e.g."
+ " PAYG_LIMIT_REACHED). Lets the client react — such as opening the"
+ " usage-limit modal — instead of only seeing a generic failure. Null"
+ " for ordinary outcomes.")
private String errorCode;
@Schema(
description =
"Whether the team is subscribed, carried from a downstream usage-limit response."
+ " Selects which limit modal the client shows (free → subscribe,"
+ " subscribed → raise cap). Null when the downstream body omitted it.")
private Boolean errorSubscribed;
}
@@ -1,41 +0,0 @@
package stirling.software.proprietary.policy.config;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
/**
* Default (non-SaaS) policy context: a global admin may edit policies; scoping uses the current
* user's team (typically a single shared team self-hosted). SaaS overrides this with a team-leader
* check (see the {@code saas}-profiled implementation).
*/
@Component
@Profile("!saas")
@RequiredArgsConstructor
public class AdminPolicyManagementAuthority implements PolicyManagementAuthority {
private final UserService userService;
@Override
public boolean canEditPolicies() {
return userService.isCurrentUserAdmin();
}
@Override
public Long currentUserTeamId() {
String username = userService.getCurrentUsername();
if (username == null) {
return null;
}
return userService
.findByUsername(username)
.map(User::getTeam)
.map(Team::getId)
.orElse(null);
}
}
@@ -13,18 +13,26 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.model.Policy;
/**
* Authority on which filesystem locations a policy may read/write. Checked at save time and again
* at run time, fail-closed in order:
* 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.
*
* <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>
* <p>Enforced fail-closed, in order:
*
* <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.
* <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.)
*/
@Component
public class FolderAccessGuard {
@@ -42,7 +50,13 @@ public class FolderAccessGuard {
this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath())));
}
/** Returns the normalised absolute path; throws if not permitted. */
/**
* 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
*/
public Path requirePermitted(Path dir) {
if (saasActive) {
throw new IllegalArgumentException(
@@ -67,7 +81,7 @@ public class FolderAccessGuard {
return normalized;
}
/** Whether this policy touches a folder source/sink, and so is subject to these rules. */
/** Whether this policy reads from or writes to a folder, and so is subject to these rules. */
public boolean usesFolderAccess(Policy policy) {
boolean readsFolder =
policy.sources().stream().anyMatch(spec -> FOLDER_TYPE.equals(spec.type()));
@@ -1,60 +0,0 @@
package stirling.software.proprietary.policy.config;
import java.util.List;
import java.util.Objects;
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;
/**
* Policies are scoped to a team: a user may view, run, edit, and delete only the policies belonging
* to their own team (the team a policy is stamped with at creation). This binds everyone — admins
* included — so no one sees or touches another team's policies. <em>Whether</em> a user may edit
* (vs only view/run) is a separate check gated at the controller ({@code
* PolicyController#requirePolicyEditingAllowed} → team leader). Enforced only when login is
* enabled; single-user deployments (login disabled) pass every check.
*/
@Component
@RequiredArgsConstructor
public class PolicyAccessGuard {
private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties;
private final PolicyManagementAuthority policyManagementAuthority;
/** Owner for a new policy: the current user, or {@code null} when login is disabled. */
public String ownerForNewPolicy() {
return enforced() ? userService.getCurrentUsername() : null;
}
/** Team a new policy is stamped with — the creator's team. {@code null} when login disabled. */
public Long teamForNewPolicy() {
return enforced() ? policyManagementAuthority.currentUserTeamId() : null;
}
/** Whether the policy belongs to the current user's team (so they may view/run/edit it). */
public boolean canAccess(Policy policy) {
if (!enforced()) {
return true;
}
return Objects.equals(policy.teamId(), policyManagementAuthority.currentUserTeamId());
}
/** The subset of {@code policies} scoped to the current user's team. */
public List<Policy> visible(List<Policy> policies) {
if (!enforced()) {
return policies;
}
Long teamId = policyManagementAuthority.currentUserTeamId();
return policies.stream().filter(policy -> Objects.equals(policy.teamId(), teamId)).toList();
}
private boolean enforced() {
return applicationProperties.getSecurity().isEnableLogin();
}
}
@@ -1,22 +0,0 @@
package stirling.software.proprietary.policy.config;
/**
* The current user's policy context, pluggable per deployment so the policy layer (proprietary)
* needn't know the team model. SaaS: a user may edit policies only if they lead their team, and
* every user is scoped to their own team. Self-hosted: a global admin may edit, scoped to their
* (typically single) team. Policies are isolated per team — nobody, admins included, sees or edits
* another team's policies.
*/
public interface PolicyManagementAuthority {
/** Whether the current user may create, edit, or delete policies (for their own team). */
boolean canEditPolicies();
/**
* The team that scopes the current user's policies — the team a new policy is stamped with and
* the only team whose policies the user may see/run/edit. {@code null} when it can't be
* resolved (e.g. login disabled / no team), in which case access falls back to the unteamed
* ({@code null}-team) policies.
*/
Long currentUserTeamId();
}
@@ -1,29 +0,0 @@
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,16 +11,17 @@ 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.RequestPart;
import org.springframework.web.bind.annotation.RequestParam;
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;
@@ -29,17 +30,15 @@ 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;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
import stirling.software.proprietary.policy.engine.PolicyRunner;
@@ -52,15 +51,25 @@ import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.model.PolicyRunView;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* 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}}.
* 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.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/policies")
@Hidden
@PremiumEndpoint
@RequiredArgsConstructor
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
public class PolicyController {
@@ -69,9 +78,10 @@ public class PolicyController {
private final PolicyRunRegistry runRegistry;
private final PolicyStore policyStore;
private final PolicyValidator policyValidator;
private final PolicyAccessGuard policyAccessGuard;
private final PolicyManagementAuthority policyManagementAuthority;
private final FolderAccessGuard folderAccessGuard;
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)
@@ -79,16 +89,15 @@ public class PolicyController {
summary = "Run a tool pipeline",
description =
"Accepts the documents to process (multipart field 'fileInput'), any supporting"
+ " 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}.")
+ " 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}.")
public ResponseEntity<JobResponse<Void>> run(
@RequestPart("json") PipelineDefinition definition,
@Valid @ModelAttribute PolicyRunFiles files)
@RequestParam("json") String json, MultipartHttpServletRequest request)
throws IOException {
requireRunnable(definition);
PolicyInputs inputs = toInputs(files);
PipelineDefinition definition = parseDefinition(json);
PolicyInputs inputs = collectInputs(request);
String runId =
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId();
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
@@ -102,19 +111,18 @@ public class PolicyController {
+ " starts and completes, then a terminal 'completed', 'failed',"
+ " 'cancelled', or 'waiting' event carrying the final run view.")
public SseEmitter runStream(
@RequestPart("json") PipelineDefinition definition,
@Valid @ModelAttribute PolicyRunFiles files)
@RequestParam("json") String json, MultipartHttpServletRequest request)
throws IOException {
requireRunnable(definition);
PolicyInputs inputs = toInputs(files);
PipelineDefinition definition = parseDefinition(json);
PolicyInputs inputs = collectInputs(request);
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));
// whenComplete runs on the worker thread after the run finishes, so the terminal event
// never races the step events.
// 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.
handle.completion()
.whenComplete(
(run, throwable) -> {
@@ -151,80 +159,41 @@ 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 Policy policy) {
requirePolicyEditingAllowed();
Policy owned = resolveOwnership(policy);
public ResponseEntity<Policy> savePolicy(@RequestBody String json) {
Policy policy = parsePolicy(json);
requireAuthorizedForFolderAccess(policy);
try {
policyValidator.validate(owned);
policyValidator.validate(policy);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
return ResponseEntity.ok(policyStore.save(owned));
return ResponseEntity.ok(policyStore.save(policy));
}
/**
* Assign owner + owning team server-side. Create stamps the current user and their team; update
* preserves the existing owner and team after verifying the policy belongs to the caller's team
* — so the client can neither forge ownership/team on create nor reach across teams on update
* (a policy in another team reads as not-found).
* 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.
*/
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 withOwnerAndTeam(incoming, existing.owner(), existing.teamId());
}
private void requireAuthorizedForFolderAccess(Policy policy) {
if (!folderAccessGuard.usesFolderAccess(policy)) {
return;
}
return withOwnerAndTeam(
incoming,
policyAccessGuard.ownerForNewPolicy(),
policyAccessGuard.teamForNewPolicy());
}
private static Policy withOwnerAndTeam(Policy policy, String owner, Long teamId) {
return new Policy(
policy.id(),
policy.name(),
owner,
policy.enabled(),
policy.trigger(),
policy.sources(),
policy.steps(),
policy.output(),
teamId);
}
/**
* Creating, editing, pausing/resuming, and deleting policies requires the editor role for the
* caller's team — a team leader on SaaS (see {@link PolicyManagementAuthority}); the global
* admin gets no say on SaaS. Team scoping (which team's policies) is enforced separately by
* {@link PolicyAccessGuard}. Every mutation routes through {@link #savePolicy} (pause/resume
* re-save with a flipped {@code enabled} flag) or {@link #deletePolicy}, so gating those two
* covers them all; runs ({@code /run}) stay open to the team. Single-user deployments (login
* disabled) have no such role, so they trust the local operator. The path allowlist for folder
* sources/outputs is enforced separately by {@link PolicyValidator} at validation time.
*/
private void requirePolicyEditingAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
if (!userService.isCurrentUserAdmin()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Policies may only be created or modified by a team leader");
"Folder sources and outputs may only be configured by an administrator");
}
}
@GetMapping
@Operation(
summary = "List policies",
description = "Lists the policies belonging to the caller's team.")
@Operation(summary = "List policies")
public List<Policy> listPolicies() {
return policyAccessGuard.visible(policyStore.all());
return policyStore.all();
}
@GetMapping("/{policyId}")
@@ -232,7 +201,6 @@ public class PolicyController {
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
return policyStore
.get(policyId)
.filter(policyAccessGuard::canAccess)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@@ -240,14 +208,9 @@ public class PolicyController {
@DeleteMapping("/{policyId}")
@Operation(summary = "Delete a policy by id")
public ResponseEntity<Void> deletePolicy(@PathVariable String policyId) {
requirePolicyEditingAllowed();
// Scope to the caller's team: a policy in another team reads as not-found.
boolean accessible =
policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent();
if (accessible && policyStore.delete(policyId)) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
return policyStore.delete(policyId)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
@PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -255,52 +218,70 @@ 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 'assets[i].key' /"
+ " 'assets[i].file'). Runs regardless of the policy's enabled flag,"
+ " which only gates automatic triggering. Returns a run id.")
+ " 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.")
public ResponseEntity<JobResponse<Void>> runStoredPolicy(
@PathVariable String policyId, @Valid @ModelAttribute PolicyRunFiles files)
throws IOException {
@PathVariable String policyId, MultipartHttpServletRequest request) throws IOException {
Policy policy =
policyStore
.get(policyId)
.filter(policyAccessGuard::canAccess)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND, "No policy: " + policyId));
PolicyInputs inputs = toInputs(files);
PolicyInputs inputs = collectInputs(request);
String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId();
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
}
private static void requireRunnable(PipelineDefinition definition) {
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");
}
if (definition.steps().isEmpty()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Pipeline definition has no steps");
}
return definition;
}
/**
* 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.
* 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}.
*/
private PolicyInputs toInputs(PolicyRunFiles files) throws IOException {
List<Resource> primary = toResources(files.getFileInput());
private PolicyInputs collectInputs(MultipartHttpServletRequest request) throws IOException {
MultiValueMap<String, MultipartFile> fileMap = request.getMultiFileMap();
List<Resource> primary = toResources(fileMap.get("fileInput"));
Map<String, List<Resource>> supportingFiles = new LinkedHashMap<>();
for (NamedAsset asset : files.getAssets()) {
Resource resource = toResource(asset.getFile());
if (resource != null) {
supportingFiles
.computeIfAbsent(asset.getKey(), key -> new ArrayList<>())
.add(resource);
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);
}
}
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
@@ -339,8 +320,8 @@ public class PolicyController {
try {
emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON));
} catch (IOException | IllegalStateException e) {
// Client gone or emitter closed. The run continues and outputs stay downloadable via
// the job endpoints.
// Client disconnected or the emitter already closed. The run continues and its results
// remain downloadable via the job endpoints; nothing useful left to stream.
log.debug("Dropping policy SSE event '{}': {}", name, e.getMessage());
}
}
@@ -351,27 +332,20 @@ public class PolicyController {
return resources;
}
for (MultipartFile file : files) {
Resource resource = toResource(file);
if (resource != null) {
resources.add(resource);
if (file == null || file.isEmpty()) {
continue;
}
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;
}
};
}
}
@@ -1,32 +0,0 @@
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,13 +8,9 @@ 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;
@@ -27,7 +23,6 @@ import stirling.software.common.service.JobQueue;
import stirling.software.common.service.ResourceMonitor;
import stirling.software.common.service.TaskManager;
import stirling.software.common.util.ExecutorFactory;
import stirling.software.common.util.JobContext;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.Policy;
@@ -36,27 +31,33 @@ import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.WaitState;
import stirling.software.proprietary.policy.output.PolicyOutputSink;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.service.DownstreamEntitlementError;
/**
* 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}.
* Runs pipelines asynchronously as tracked jobs.
*
* <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.
* <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.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PolicyEngine {
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
// files. See ResourceMonitor#shouldQueueJob(int).
/**
* 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.
*/
private static final int RUN_RESOURCE_WEIGHT = 50;
private final PolicyExecutor stepExecutor;
@@ -71,65 +72,27 @@ public class PolicyEngine {
private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor();
/**
* 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.
* 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.
*/
public PolicyRunHandle submit(
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
// Ad-hoc run (no stored policy): bill whoever kicked it off and own the outputs as them
// too.
// Capture the principal on this (request) thread — it does not survive the hop onto the
// async
// worker.
String principal = currentActingPrincipal();
return submitForPrincipal(principal, principal, 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, and the async worker
// doesn't inherit the caller's, so the owner (stamped at policy creation) is the reliable
// billing identity — and for org-wide policies the org/owner is meant to pay. But own the
// OUTPUT files as the user who triggered the run (captured here on the request thread) so
// they can download their enforced file; otherwise an org-wide policy's output is owned by
// the admin and the triggering user is denied it. Trigger-fired runs have no such user, so
// the owner owns those outputs.
String triggeringUser = currentActingPrincipal();
String fileOwner = triggeringUser != null ? triggeringUser : policy.owner();
return submitForPrincipal(
policy.owner(), fileOwner, policy.toDefinition(), inputs, listener);
}
private PolicyRunHandle submitForPrincipal(
String billingPrincipal,
String fileOwner,
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.
// 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.
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);
// 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(
billingPrincipal,
fileOwner,
() -> runToCompletion(run, inputs, tracking, completion));
Runnable task = () -> runToCompletion(run, inputs, tracking, completion);
// 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.
// 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.
if (resourceMonitor.shouldQueueJob(RUN_RESOURCE_WEIGHT)) {
log.debug("Queueing policy run {} under resource pressure", runId);
jobQueue.queueJob(
@@ -147,12 +110,22 @@ 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);
}
/**
* Mark a run cancelled if not already finished. Does not yet interrupt an in-flight tool call.
* 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.
*/
public boolean cancel(String runId) {
PolicyRun run = registry.get(runId);
@@ -166,7 +139,10 @@ public class PolicyEngine {
return cancelled;
}
/** Resume a run paused in {@code WAITING_FOR_INPUT}. Not yet implemented. */
/**
* 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.
*/
public String resume(String runId, List<Resource> additionalInputs) {
throw new UnsupportedOperationException("Pause/resume is not yet implemented");
}
@@ -187,8 +163,8 @@ public class PolicyEngine {
taskManager.setComplete(runId);
run.complete(outputs);
} catch (PolicyInputRequiredException e) {
// Expected path: suspend rather than fail. Persist intermediates as fileIds so the run
// can resume after this worker thread is gone.
// 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.
WaitState wait = suspend(e);
run.waitForInput(wait);
taskManager.addNote(runId, "Waiting for input: " + e.getMessage());
@@ -201,41 +177,21 @@ 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 = DownstreamEntitlementError.extractCode(e);
if (code != null) {
log.info("Policy run {} blocked by downstream entitlement gate ({})", runId, code);
String message = "Usage limit reached";
run.failWithCode(message, code, DownstreamEntitlementError.extractSubscribed(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 so stream/await callers unblock.
// Always resolve the handle with the run's final state 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. queue full); a started run resolves its own
// completion in runToCompletion.
// Only reached if the run never started (e.g. the queue was full). A run that started
// always 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());
@@ -302,61 +258,4 @@ public class PolicyEngine {
"The %s tool did not respond within %d seconds and was aborted.",
e.getEndpointPath(), e.getReadTimeout().toSeconds());
}
/**
* 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 billingPrincipal, String fileOwner, Runnable body) {
// Billing identity (MDC auditPrincipal) and output-file ownership (JobContext owner) are
// set
// independently: usage is charged to billingPrincipal, but stored output files are owned by
// fileOwner — the user who triggered an org-wide policy — so they can fetch their results.
// Either may be null (e.g. login disabled, or a trigger-fired run); each is applied only
// when present and restored afterward (defensive — worker threads aren't pooled).
String previousPrincipal = MDC.get(AUDIT_PRINCIPAL_MDC_KEY);
String previousOwner = JobContext.getOwner();
if (billingPrincipal != null && !billingPrincipal.isBlank()) {
MDC.put(AUDIT_PRINCIPAL_MDC_KEY, billingPrincipal);
}
if (fileOwner != null && !fileOwner.isBlank()) {
JobContext.setOwner(fileOwner);
}
try {
body.run();
} finally {
if (previousPrincipal != null) {
MDC.put(AUDIT_PRINCIPAL_MDC_KEY, previousPrincipal);
} else {
MDC.remove(AUDIT_PRINCIPAL_MDC_KEY);
}
JobContext.setOwner(previousOwner);
}
}
}
@@ -7,8 +7,11 @@ import org.springframework.core.io.Resource;
import tools.jackson.databind.JsonNode;
/**
* 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.
* 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.
*/
public record PolicyExecutionResult(List<Resource> files, JsonNode report, String reportTool) {}
@@ -35,11 +35,15 @@ import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Runs an ordered chain of tool steps, feeding each step's output files into the next.
* Runs an ordered chain of tool steps, chaining each step's output files into the next step's
* input.
*
* <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
* <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
* caller.
*/
@Slf4j
@@ -54,16 +58,25 @@ public class PolicyExecutor {
private final TempFileManager tempFileManager;
private final ObjectMapper objectMapper;
// files: result files (one, or many for ZIP-response tools). report: optional structured
// payload the tool surfaced alongside or instead of a file.
/**
* 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.
*/
private record ToolResult(List<Resource> files, JsonNode report) {}
/**
* 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.
* 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.
*
* @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 on a non-OK tool response, a missing supporting file, or a read failure
* @throws IOException if a tool returns a non-OK response, references a missing supporting
* file, or a file cannot be read
*/
public PolicyExecutionResult execute(
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener)
@@ -75,7 +88,7 @@ public class PolicyExecutor {
List<Resource> currentFiles = inputs.primary();
Map<String, List<Resource>> supportingFiles = inputs.supportingFiles();
// Last non-null report wins: the terminal step defines the output.
// Propagate the *last* non-null report; the terminal step defines the output.
JsonNode lastReport = null;
String lastReportTool = null;
@@ -100,9 +113,13 @@ public class PolicyExecutor {
}
/**
* 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.
* 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.
*/
private ToolResult executeStep(
PipelineStep step,
@@ -129,10 +146,17 @@ public class PolicyExecutor {
}
/**
* 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.
* 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>
*/
private ToolResult callEndpoint(
PipelineStep step, List<Resource> files, Map<String, List<Resource>> supportingFiles)
@@ -142,8 +166,8 @@ public class PolicyExecutor {
for (Resource file : files) {
body.add("fileInput", file);
}
// Bind supporting files to named tool fields (e.g. stampImage); from the asset store, not
// the document stream.
// 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.
for (Map.Entry<String, String> binding : step.fileParameters().entrySet()) {
String fieldName = binding.getKey();
String assetKey = binding.getValue();
@@ -165,9 +189,9 @@ public class PolicyExecutor {
for (Map.Entry<String, Object> entry : step.parameters().entrySet()) {
if (entry.getValue() instanceof List<?> list) {
if (containsStructuredElements(list)) {
// 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.
// 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.
body.add(entry.getKey(), objectMapper.writeValueAsString(list));
} else {
for (Object item : list) {
@@ -185,8 +209,8 @@ public class PolicyExecutor {
}
Resource resource = response.getBody();
// Filter ops return an empty body to mean "filtered out": drop it rather than forward a
// zero-byte document.
// Filter operations return an empty body to signal the file was filtered out: drop it
// rather than forwarding a zero-byte document.
if (isFilterOperation(endpointPath) && isEmpty(resource)) {
return new ToolResult(List.of(), null);
}
@@ -194,7 +218,7 @@ public class PolicyExecutor {
HttpHeaders headers = response.getHeaders();
MediaType contentType = headers.getContentType();
// JSON-only response: whole body is the report, no file.
// JSON-only response: the whole body is the structured report, no result file.
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
try (InputStream is = resource.getInputStream()) {
JsonNode report = objectMapper.readTree(is);
@@ -209,7 +233,10 @@ public class PolicyExecutor {
return new ToolResult(List.of(resource), report);
}
/** Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header, or null. */
/**
* Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header into a {@link JsonNode},
* or return null.
*/
private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) {
String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT);
if (raw == null || raw.isBlank()) {
@@ -237,7 +264,8 @@ public class PolicyExecutor {
}
/**
* Fail if any primary-stream file is a type the step rejects. No declared type means anything.
* 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.
*/
private void requireAcceptedTypes(String operation, List<Resource> files) throws IOException {
List<String> accepted = toolMetadataService.getExtensionTypes(false, operation);
@@ -7,9 +7,15 @@ import org.springframework.core.io.Resource;
import lombok.Getter;
/**
* 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.
* 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.
*/
@Getter
public class PolicyInputRequiredException extends RuntimeException {
@@ -5,9 +5,12 @@ import java.util.concurrent.CompletableFuture;
import stirling.software.proprietary.policy.model.PolicyRun;
/**
* 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.
* 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.
*/
public record PolicyRunHandle(String runId, CompletableFuture<PolicyRun> completion) {}
@@ -19,12 +19,16 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.model.PolicyRun;
/**
* In-memory store of live {@link PolicyRun} state, keyed by runId. Authoritative run state machine;
* durable status/files are projected separately into {@code TaskManager}.
* 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}.
*
* <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.
* <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.
*/
@Slf4j
@Service
@@ -57,7 +61,7 @@ public class PolicyRunRegistry {
return runs.values();
}
/** Scheduled sweep entry point. */
/** Scheduled hook: evict terminal runs that finished before the expiry window. */
private void evictExpiredRuns() {
try {
evictExpired(Instant.now().minus(runExpiry));
@@ -67,8 +71,9 @@ public class PolicyRunRegistry {
}
/**
* 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.
* 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.
*/
int evictExpired(Instant cutoff) {
int removed = 0;
@@ -20,8 +20,14 @@ import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
/**
* 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.
* 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.
*/
@Slf4j
@Service
@@ -32,9 +38,10 @@ public class PolicyRunner {
private final List<InputSource> inputSources;
/**
* 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).
* 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.
*/
public void run(Policy policy) {
List<InputSpec> sources = policy.sources();
@@ -47,7 +54,11 @@ public class PolicyRunner {
}
}
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
/**
* 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.
*/
public PolicyRunHandle runWith(
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
return policyEngine.runPolicy(policy, inputs, listener);
@@ -15,9 +15,12 @@ import stirling.software.proprietary.policy.output.PolicyOutputSink;
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
/**
* Validates a policy at save time by delegating each facet (trigger, sources, output) to the bean
* that handles its type, so a misconfiguration fails fast rather than at run time. A null trigger
* is a manual-only policy and skips trigger validation.
* 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.
*/
@Service
@RequiredArgsConstructor
@@ -28,7 +31,8 @@ public class PolicyValidator {
private final List<PolicyOutputSink> outputSinks;
/**
* @throws IllegalArgumentException if any facet's type is unknown or its config is invalid
* @throws IllegalArgumentException if any facet's type is unknown or its configuration is
* invalid
*/
public void validate(Policy policy) {
if (policy.trigger() != null) {
@@ -22,13 +22,21 @@ import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.PolicyInputs;
/**
* Reads input files from a directory; each ready file is its own unit of work so one failure does
* not affect the others.
* 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.
*
* <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.
* <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.
*/
@Slf4j
@Service
@@ -36,7 +44,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 dir so the watched folder stays tidy.
// Bookkeeping lives under one hidden namespace 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";
@@ -99,7 +107,6 @@ 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);
@@ -128,6 +135,7 @@ 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);
}
@@ -158,6 +166,7 @@ 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,9 +7,14 @@ import java.util.List;
import stirling.software.proprietary.policy.model.InputSpec;
/**
* 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.
* 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.
*/
public interface InputSource {
@@ -19,18 +24,24 @@ public interface InputSource {
/** Whether this source can handle the given spec. */
boolean supports(InputSpec spec);
/** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
/**
* 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.
*/
default void validate(InputSpec spec) {}
/**
* 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.
* 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.
*/
List<ResolvedInput> resolve(InputSpec spec) throws IOException;
/**
* 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.
* 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.
*/
default List<Path> watchTargets(InputSpec spec) {
return List.of();
@@ -5,9 +5,10 @@ import java.util.function.Consumer;
import stirling.software.proprietary.policy.model.PolicyInputs;
/**
* 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.
* 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).
*/
public record ResolvedInput(PolicyInputs inputs, Consumer<Boolean> onComplete) {
@@ -15,7 +16,7 @@ public record ResolvedInput(PolicyInputs inputs, Consumer<Boolean> onComplete) {
onComplete = onComplete == null ? success -> {} : onComplete;
}
/** No completion side effect. */
/** A unit of work with no completion side effect. */
public static ResolvedInput of(PolicyInputs inputs) {
return new ResolvedInput(inputs, success -> {});
}
@@ -3,8 +3,15 @@ package stirling.software.proprietary.policy.model;
import java.util.Map;
/**
* One input source for a policy. {@code type} keys an {@code InputSource} bean; a run pulls from
* every source.
* 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.
*/
public record InputSpec(String type, Map<String, Object> options) {
@@ -2,13 +2,19 @@ package stirling.software.proprietary.policy.model;
import java.util.Map;
/** Where a run's outputs are delivered. {@code type} keys a {@code PolicyOutputSink} bean. */
/**
* 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.
*/
public record OutputSpec(String type, Map<String, Object> options) {
public OutputSpec {
options = options == null ? Map.of() : options;
}
/** Default sink: store outputs and return them to the caller for download. */
/** The default destination: store outputs and return them to the caller for download. */
public static OutputSpec inline() {
return new OutputSpec("inline", Map.of());
}
@@ -3,10 +3,11 @@ package stirling.software.proprietary.policy.model;
import java.util.List;
/**
* An ordered chain of tool steps plus an output destination; the unit the engine executes.
* An ordered chain of tool steps plus where the output should go.
*
* <p>{@code output} may be null for callers that handle result files themselves (e.g. the AI
* workflow, which builds its own response payload).
* <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).
*/
public record PipelineDefinition(String name, List<PipelineStep> steps, OutputSpec output) {
public PipelineDefinition {
@@ -3,13 +3,17 @@ package stirling.software.proprietary.policy.model;
import java.util.Map;
/**
* 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.
* A single tool invocation in a pipeline: the API endpoint path to call and the inputs to pass.
*
* <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.
* <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.
*/
public record PipelineStep(
String operation, Map<String, Object> parameters, Map<String, String> fileParameters) {
@@ -3,11 +3,16 @@ package stirling.software.proprietary.policy.model;
import java.util.List;
/**
* A stored automation: ordered tool steps, input sources, and an output destination.
* A stored automation: an ordered chain of tool steps, the sources its input files come from, and
* an output destination for the results.
*
* <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.
* <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.
*/
public record Policy(
String id,
@@ -17,8 +22,7 @@ public record Policy(
TriggerConfig trigger,
List<InputSpec> sources,
List<PipelineStep> steps,
OutputSpec output,
Long teamId) {
OutputSpec output) {
public Policy {
sources = sources == null ? List.of() : List.copyOf(sources);
@@ -26,22 +30,6 @@ public record Policy(
output = output == null ? OutputSpec.inline() : output;
}
/**
* Without an explicit owning team. Kept for the engine and tests; the controller always stamps
* a {@code teamId} on stored policies so they stay scoped to the creating user's team.
*/
public Policy(
String id,
String name,
String owner,
boolean enabled,
TriggerConfig trigger,
List<InputSpec> sources,
List<PipelineStep> steps,
OutputSpec output) {
this(id, name, owner, enabled, trigger, sources, steps, output, null);
}
/** A policy with no configured sources (a generator, or files supplied directly to a run). */
public Policy(
String id,
@@ -51,10 +39,10 @@ public record Policy(
TriggerConfig trigger,
List<PipelineStep> steps,
OutputSpec output) {
this(id, name, owner, enabled, trigger, List.of(), steps, output, null);
this(id, name, owner, enabled, trigger, List.of(), steps, output);
}
/** This policy's pipeline as the engine sees it. */
/** The engine-level, trigger-agnostic view of this policy's pipeline. */
public PipelineDefinition toDefinition() {
return new PipelineDefinition(name, steps, output);
}
@@ -6,9 +6,17 @@ import java.util.Map;
import org.springframework.core.io.Resource;
/**
* 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).
* 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).
*/
public record PolicyInputs(List<Resource> primary, Map<String, List<Resource>> supportingFiles) {
@@ -8,10 +8,12 @@ import lombok.Getter;
import stirling.software.common.model.job.ResultFile;
/**
* 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.
* 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.
*/
@Getter
public class PolicyRun {
@@ -27,21 +29,6 @@ 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();
@@ -76,24 +63,15 @@ 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();
}
/** Cancels unless already terminal; returns whether it transitioned. */
/**
* Mark cancelled if the run has not already reached a terminal state. Returns whether it did.
*/
public synchronized boolean cancel() {
if (status.isTerminal()) {
return false;
@@ -1,8 +1,11 @@
package stirling.software.proprietary.policy.model;
/**
* Lifecycle states of a {@link PolicyRun}. {@code WAITING_FOR_INPUT} models a thread-free pause;
* the resume handshake lands in a later stage.
* 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.
*/
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} for the status endpoint. Outputs are {@link ResultFile}s,
* downloadable via {@code GET /api/v1/general/files/{id}}.
* 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}}.
*/
public record PolicyRunView(
String runId,
@@ -14,8 +14,6 @@ public record PolicyRunView(
int currentStep,
int stepCount,
String error,
String errorCode,
Boolean errorSubscribed,
List<ResultFile> outputs) {
public static PolicyRunView of(PolicyRun run) {
@@ -25,8 +23,6 @@ public record PolicyRunView(
run.getCurrentStep(),
run.stepCount(),
run.getError(),
run.getErrorCode(),
run.getErrorSubscribed(),
run.getOutputs());
}
}
@@ -11,9 +11,16 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
/**
* 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.
* 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.
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@@ -35,7 +42,10 @@ public sealed interface Schedule {
DAYS
}
/** A fixed offset from {@code after}: "every 15 minutes", "every 6 hours". No time of day. */
/**
* A fixed cadence repeating from when the policy was last seen: "every 15 minutes", "every 6
* hours". Time of day is irrelevant.
*/
record Every(long count, Unit unit) implements Schedule {
public Every {
if (count <= 0) {
@@ -81,7 +91,8 @@ public sealed interface Schedule {
@Override
public ZonedDateTime nextAfter(ZonedDateTime after) {
// Soonest of the next 7 days landing on a chosen weekday, at the configured time.
// The soonest of the next 7 days that lands 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,9 +3,17 @@ package stirling.software.proprietary.policy.model;
import java.util.Map;
/**
* 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.
* 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.
*/
public record TriggerConfig(String type, Map<String, Object> options) {
@@ -3,10 +3,14 @@ package stirling.software.proprietary.policy.model;
import java.util.List;
/**
* 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.
* 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.
*/
public record WaitState(String reason, int resumeStepIndex, List<String> pendingFileIds) {
public WaitState {
@@ -22,10 +22,13 @@ import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.model.OutputSpec;
/**
* 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}}.
* 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}}.
*/
@Slf4j
@Service
@@ -92,7 +95,11 @@ public class FolderOutputSink implements PolicyOutputSink {
return Path.of(directory.toString());
}
// Strip any directory component / "../" so a crafted output name cannot escape targetDir.
/**
* 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.
*/
private static String safeName(String filename, int index) {
if (filename == null || filename.isBlank()) {
return "output-" + index;
@@ -104,7 +111,7 @@ public class FolderOutputSink implements PolicyOutputSink {
return name;
}
// Non-colliding path, appending " (n)" before the extension.
/** Resolve a non-colliding path in {@code dir}, appending " (n)" before the extension. */
private static Path uniqueTarget(Path dir, String filename) {
Path candidate = dir.resolve(filename);
if (!Files.exists(candidate)) {
@@ -17,8 +17,9 @@ import stirling.software.common.service.FileStorage;
import stirling.software.proprietary.policy.model.OutputSpec;
/**
* 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.
* 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.
*/
@Service
@RequiredArgsConstructor
@@ -9,9 +9,11 @@ import stirling.software.common.model.job.ResultFile;
import stirling.software.proprietary.policy.model.OutputSpec;
/**
* 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.
* 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.
*/
public interface PolicyOutputSink {
@@ -21,10 +23,19 @@ public interface PolicyOutputSink {
/** Whether this sink can handle the given output spec. */
boolean supports(OutputSpec spec);
/** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
/**
* 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.
*/
default void validate(OutputSpec spec) {}
/** Persist/deliver the output files and return their descriptors. */
/**
* 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
*/
List<ResultFile> deliver(String runId, List<Resource> outputs, OutputSpec spec)
throws IOException;
}
@@ -1,17 +1,22 @@
package stirling.software.proprietary.policy.progress;
/**
* 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.
* 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.
*/
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) {}
/** Keep-alive tick so downstream connections can detect disconnects promptly. */
/** Called on a keep-alive tick so downstream connections can detect disconnects promptly. */
default void onHeartbeat() {}
}
@@ -9,8 +9,9 @@ import java.util.concurrent.ConcurrentHashMap;
import stirling.software.proprietary.policy.model.Policy;
/**
* In-memory {@link PolicyStore} for tests and any future no-database mode. {@link JpaPolicyStore}
* is the runtime bean.
* 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.
*/
public class InProcessPolicyStore implements PolicyStore {
@@ -31,8 +32,7 @@ public class InProcessPolicyStore implements PolicyStore {
policy.trigger(),
policy.sources(),
policy.steps(),
policy.output(),
policy.teamId());
policy.output());
policies.put(id, stored);
return stored;
}
@@ -13,8 +13,9 @@ import stirling.software.proprietary.policy.model.Policy;
import tools.jackson.databind.ObjectMapper;
/**
* 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.
* 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.
*/
@Service
@RequiredArgsConstructor
@@ -38,8 +39,7 @@ public class JpaPolicyStore implements PolicyStore {
policy.trigger(),
policy.sources(),
policy.steps(),
policy.output(),
policy.teamId());
policy.output());
PolicyEntity entity = new PolicyEntity();
entity.setId(id);
@@ -12,11 +12,13 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives
* as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies
* for querying, notably {@code triggerType} + {@code enabled} so background triggers can fetch
* their policies. {@code owner} is a plain string, not a foreign key, to stay decoupled from the
* security entities.
* 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.
*/
@Entity
@Table(name = "policies")
@@ -5,19 +5,24 @@ import java.util.Optional;
import stirling.software.proprietary.policy.model.Policy;
/** Stores {@link Policy} definitions. */
/**
* 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.
*/
public interface PolicyStore {
/** Create or update; a blank/absent id is assigned. Returns the stored policy. */
/** Create or update a policy. A blank/absent id is assigned; returns the stored policy. */
Policy save(Policy policy);
Optional<Policy> get(String id);
List<Policy> all();
/** Enabled policies with the given trigger type, for background triggers. */
/**
* Enabled policies whose automatic trigger is of the given type (used by background triggers).
*/
List<Policy> findByTriggerType(String triggerType);
/** Returns whether the policy existed. */
/** Remove a policy; returns whether it existed. */
boolean delete(String id);
}
@@ -33,14 +33,20 @@ import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Fires policies when a file lands in one of their folder sources, rather than polling on a timer.
* 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.
*
* <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>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>Watch state is in memory, so this assumes a single node and rebuilds registrations on restart.
* <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}.
*/
@Slf4j
@Service
@@ -59,7 +65,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
private volatile boolean running;
// Package-visible so tests can drive syncRegistrations() against a real service.
// Package-visible (not private) so tests can drive syncRegistrations() against a real service.
volatile WatchService watchService;
private volatile ScheduledExecutorService reconciler;
@@ -119,7 +125,8 @@ public class FolderWatchTrigger implements PolicyTrigger {
}
private void watchLoop() {
// Capture once: stop() may null the field; close() still wakes take()/poll() on this local.
// 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).
WatchService watcher = watchService;
if (watcher == null) {
return;
@@ -139,8 +146,9 @@ public class FolderWatchTrigger implements PolicyTrigger {
}
/**
* 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".
* 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".
*/
private Set<Path> drainBurst(WatchService watcher, WatchKey first) {
long quietPeriodMs = applicationProperties.getPolicies().getWatchQuietPeriodMs();
@@ -192,7 +200,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
}
}
/** Reconcile safety net: run every folder-watch policy regardless of watch events. */
/** The reconcile safety net: run every folder-watch policy regardless of watch events. */
void runAll() {
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
try {
@@ -206,7 +214,10 @@ public class FolderWatchTrigger implements PolicyTrigger {
}
}
/** Register newly-wanted dirs that exist on disk, cancel ones no longer wanted. */
/**
* 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.
*/
synchronized void syncRegistrations() {
if (watchService == null) {
return;
@@ -263,8 +274,11 @@ public class FolderWatchTrigger implements PolicyTrigger {
return dirs;
}
// Absolute + normalised so registration keys and event-time matching compare regardless of how
// the path was configured.
/**
* 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.
*/
private List<Path> watchDirsOf(Policy policy) {
List<Path> dirs = new ArrayList<>();
for (InputSpec spec : policy.sources()) {
@@ -3,21 +3,36 @@ package stirling.software.proprietary.policy.trigger;
import stirling.software.proprietary.policy.model.Policy;
/**
* 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.
* 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.
*/
public interface PolicyTrigger {
/** Matches {@code TriggerConfig.type()}. */
/** Stable identifier for this trigger kind, matching {@code TriggerConfig.type()}. */
String type();
/**
* 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.
* 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()}.
*/
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,7 +8,14 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/** Starts and stops every {@link PolicyTrigger} with the application lifecycle. */
/**
* 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.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@@ -24,9 +24,16 @@ import stirling.software.proprietary.policy.store.PolicyStore;
import tools.jackson.databind.ObjectMapper;
/**
* Fires policies on a {@link Schedule}: a fixed-interval sweep runs each due "schedule" policy.
* 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.
*
* <p>Last-fire times are in memory, so this assumes a single node and resets on restart.
* <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.
*/
@Slf4j
@Service
@@ -94,7 +101,8 @@ public class ScheduleTrigger implements PolicyTrigger {
continue;
}
// Baseline a newly-seen policy to now so it does not fire immediately.
// 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.
Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now);
ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone()));
if (!next.toInstant().isAfter(now)) {
@@ -106,8 +114,9 @@ public class ScheduleTrigger implements PolicyTrigger {
}
/**
* Validated schedule-trigger options: the {@link Schedule} and the zone it runs in (UTC by
* default).
* 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.
*/
record ScheduleConfig(Schedule schedule, ZoneId zone) {
@@ -1,13 +1,11 @@
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;
@@ -39,17 +37,6 @@ 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() {
@@ -64,15 +51,9 @@ public class InitialSecuritySetup {
}
configureJWTSettings();
assignUsersToDefaultTeamIfMissing();
initializeInternalApiUser();
if (isSaas()) {
log.info(
"SaaS profile active - skipping self-host user-table bootstrap"
+ " (default-team backfill, seat-license grandfathering).");
} else {
assignUsersToDefaultTeamIfMissing();
initializeUserLicenseSettings();
}
initializeUserLicenseSettings();
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
log.error("Failed to initialize security setup.", e);
System.exit(1);
@@ -978,35 +978,23 @@ public class UserController {
}
}
// Lists enabled users for the signing user picker, scoped by storage.signing.userListScope:
// 'org' (default) = whole instance, anything else = caller's team only (fail-closed).
/**
* List all enabled users for selection in signing workflows.
*
* @param principal The authenticated user
* @return List of user summaries
*/
@GetMapping("/users")
public ResponseEntity<List<UserSummaryDTO>> listUsers(Principal principal) {
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// Fail-closed: only literal "org" opens the whole instance; anything else scopes to team.
String scope = applicationProperties.getStorage().getSigning().getUserListScope();
boolean teamScoped = !"org".equalsIgnoreCase(scope == null ? "" : scope.trim());
List<User> source;
if (teamScoped) {
Optional<User> callerOpt = userService.findByUsernameIgnoreCase(principal.getName());
if (callerOpt.isEmpty() || callerOpt.get().getTeam() == null) {
// No team: return only the caller rather than leak the org.
source = callerOpt.map(List::of).orElse(List.of());
} else {
// KNOWN LIMITATION: scopes the team via the single User.team FK - correct while
// acceptInvitation() collapses users to one team; revisit if multi-team enabled.
source = userRepository.findAllByTeamId(callerOpt.get().getTeam().getId());
}
} else {
source = userRepository.findAll();
}
List<UserSummaryDTO> users =
source.stream().filter(User::isEnabled).map(this::toUserSummaryDTO).toList();
userRepository.findAll().stream()
.filter(User::isEnabled)
.map(this::toUserSummaryDTO)
.toList();
return ResponseEntity.ok(users);
}
@@ -87,11 +87,7 @@ public class User implements UserDetails, Serializable {
private String email;
// SaaS-only: Supabase user UUID. Null in OSS / proprietary deployments.
// 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)
@Column(name = "supabase_id", unique = true)
private UUID supabaseId;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
@@ -18,7 +18,6 @@ import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
@@ -386,13 +385,6 @@ public class AiWorkflowService {
return new WorkflowState.Terminal(
cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
} catch (Exception e) {
AiWorkflowResponse limit = paygLimitResponseOrNull(e);
if (limit != null) {
log.info(
"AI markdown conversion blocked by downstream entitlement gate ({})",
limit.getErrorCode());
return new WorkflowState.Terminal(limit);
}
log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e);
return new WorkflowState.Terminal(
cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
@@ -478,14 +470,6 @@ public class AiWorkflowService {
log.error("Tool {} timed out: {}", endpointPath, e.getMessage());
return new WorkflowState.Terminal(cannotContinue(toolTimeoutMessage(endpointPath, e)));
} catch (Exception e) {
AiWorkflowResponse limit = paygLimitResponseOrNull(e);
if (limit != null) {
log.info(
"AI workflow tool {} blocked by downstream entitlement gate ({})",
endpointPath,
limit.getErrorCode());
return new WorkflowState.Terminal(limit);
}
log.error("Failed to execute tool {}: {}", endpointPath, e.getMessage(), e);
return new WorkflowState.Terminal(cannotContinue(toolFailureMessage(endpointPath, e)));
}
@@ -601,13 +585,6 @@ public class AiWorkflowService {
log.error("Plan step failed (HTTP {}): {}", e.getStatusCode(), reason);
return new WorkflowState.Terminal(cannotContinue(reason));
} catch (Exception e) {
AiWorkflowResponse limit = paygLimitResponseOrNull(e);
if (limit != null) {
log.info(
"AI workflow plan blocked by downstream entitlement gate ({})",
limit.getErrorCode());
return new WorkflowState.Terminal(limit);
}
log.error("Failed to execute plan: {}", e.getMessage(), e);
return new WorkflowState.Terminal(
cannotContinue("Plan execution failed: " + e.getMessage()));
@@ -745,33 +722,6 @@ public class AiWorkflowService {
return response;
}
/**
* If {@code e} is a downstream usage-limit block a 401/402 from a tool call carrying the saas
* EntitlementGuard's {@code error} sentinel build a terminal response that carries the
* structured code (+ {@code subscribed}) through to the client, so it can pop the matching
* usage-limit modal instead of surfacing the raw "tool failed: 402…" text. Returns null for any
* other failure, so the caller falls back to its normal tool-failure handling.
*
* <p>The agent's tool calls run server-side (loopback HTTP via {@link PolicyExecutor}), so this
* 402 never reaches the frontend's API-client interceptor that pops the modal for direct calls
* same gap the policy auto-run path bridges in {@code PolicyEngine}.
*/
private AiWorkflowResponse paygLimitResponseOrNull(Throwable e) {
if (!(e instanceof RestClientResponseException rce)) {
return null;
}
String code = DownstreamEntitlementError.extractCode(rce);
if (code == null) {
return null;
}
AiWorkflowResponse response = new AiWorkflowResponse();
response.setOutcome(AiWorkflowOutcome.CANNOT_CONTINUE);
response.setReason("You've reached your current usage limit.");
response.setErrorCode(code);
response.setErrorSubscribed(DownstreamEntitlementError.extractSubscribed(rce));
return response;
}
/**
* Drive the engine's streaming orchestrator endpoint. Progress events are forwarded to {@code
* listener} as they arrive (each one keeps the SSE connection to the frontend alive too). The
@@ -1,64 +0,0 @@
package stirling.software.proprietary.service;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.web.client.RestClientResponseException;
/**
* Reads the {@code error} sentinel and {@code subscribed} flag out of a downstream 401/402 JSON
* body e.g. the saas EntitlementGuard's {@code
* {"error":"PAYG_LIMIT_REACHED","subscribed":false}}.
*
* <p>Server-side run paths (policy auto-run, AI agent workflows) execute tool calls via loopback
* HTTP, so a usage-limit 402 surfaces as a {@link RestClientResponseException} rather than reaching
* the frontend's API-client interceptor. These helpers let those paths pass the structured code
* through to the client, which maps it to the right usage-limit modal instead of showing a generic
* failure.
*
* <p>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 (saas) coupling.
*/
public final class DownstreamEntitlementError {
private DownstreamEntitlementError() {}
/** Matches the {@code "error":"CODE"} field of a small JSON error body. */
private static final Pattern ERROR_CODE_FIELD =
Pattern.compile("\"error\"\\s*:\\s*\"([^\"]+)\"");
/** Matches the {@code "subscribed":true|false} field of a small JSON error body. */
private static final Pattern SUBSCRIBED_FIELD =
Pattern.compile("\"subscribed\"\\s*:\\s*(true|false)");
/**
* Pull the {@code error} sentinel out of a downstream 401/402 JSON body. Returns null for other
* statuses or an unmatched body, in which case the caller treats it as a generic failure.
*/
public static String extractCode(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;
}
Matcher m = ERROR_CODE_FIELD.matcher(body);
return m.find() ? m.group(1) : null;
}
/**
* Pull the {@code subscribed} flag out of the body (present on the saas {@code
* PAYG_LIMIT_REACHED}/{@code FEATURE_DEGRADED} responses). Null when absent the client then
* defaults to the free-limit modal.
*/
public static Boolean extractSubscribed(RestClientResponseException e) {
String body = e.getResponseBodyAsString();
if (body == null || body.isBlank()) {
return null;
}
Matcher m = SUBSCRIBED_FIELD.matcher(body);
return m.find() ? Boolean.valueOf(m.group(1)) : null;
}
}
@@ -53,30 +53,6 @@ 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,8 +116,7 @@ class McpOAuthIntegrationTest {
assertThat(response.statusCode()).isEqualTo(401);
String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse("");
assertThat(wwwAuth).contains("resource_metadata=");
// The advertised URL must be the RFC 9728 path-inserted form for the /mcp resource.
assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource/mcp");
assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource");
}
@Test
@@ -248,24 +247,6 @@ 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()
@@ -1,105 +0,0 @@
package stirling.software.proprietary.mcp.security;
import static org.assertj.core.api.Assertions.assertThat;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.mcp.McpServerController;
import stirling.software.proprietary.mcp.tools.DescribeOperationTool;
import stirling.software.proprietary.security.service.UserService;
/**
* Regression test for the protected-resource metadata when {@code mcp.scopes-enabled=false} (the
* SaaS/Supabase setup, where the IdP cannot mint {@code mcp.tools.*} scopes). Advertising scopes
* the authorization server can't issue makes spec-compliant MCP clients request them and get
* bounced with {@code invalid_request}, so the metadata must omit them when scopes are not
* enforced.
*/
@SpringBootTest(
classes = McpScopeMetadataDisabledTest.TestApp.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class McpScopeMetadataDisabledTest {
private static final String ISSUER = "https://test-issuer.example.com";
private static final String RESOURCE_ID = "http://localhost/mcp";
@LocalServerPort private int port;
private final HttpClient http = HttpClient.newHttpClient();
// McpSecurityConfig is @ConditionalOnProperty("mcp.enabled"); that condition reads the Spring
// Environment, so it must be set here (the ApplicationProperties bean alone is not enough to
// register the chain).
@DynamicPropertySource
static void mcpProperties(DynamicPropertyRegistry registry) {
registry.add("mcp.enabled", () -> "true");
}
@Test
void metadata_omitsToolScopes_whenScopesDisabled() throws Exception {
String body = getMetadata("/.well-known/oauth-protected-resource");
assertThat(body).contains(RESOURCE_ID);
assertThat(body).contains(ISSUER);
assertThat(body).doesNotContain("mcp.tools.read");
assertThat(body).doesNotContain("mcp.tools.write");
}
@Test
void pathInsertedMetadata_omitsToolScopes_whenScopesDisabled() throws Exception {
String body = getMetadata("/.well-known/oauth-protected-resource/mcp");
assertThat(body).contains(RESOURCE_ID);
assertThat(body).contains("authorization_servers");
assertThat(body).doesNotContain("mcp.tools.read");
assertThat(body).doesNotContain("mcp.tools.write");
}
private String getMetadata(String path) throws Exception {
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + path))
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
return response.body();
}
@SpringBootConfiguration
@EnableAutoConfiguration
@Import({McpSecurityConfig.class, McpServerController.class, DescribeOperationTool.class})
static class TestApp {
@Bean
ApplicationProperties applicationProperties() {
ApplicationProperties props = new ApplicationProperties();
props.getMcp().setEnabled(true);
props.getMcp().setScopesEnabled(false);
props.getMcp().getAuth().setIssuerUri(ISSUER);
// No real JWKS fetch happens for the permitAll metadata endpoint; a placeholder URI is
// fine because NimbusJwtDecoder.withJwkSetUri(...) resolves the key set lazily.
props.getMcp().getAuth().setJwksUri(ISSUER + "/jwks");
props.getMcp().getAuth().setResourceId(RESOURCE_ID);
props.getAutomaticallyGenerated().setAppVersion("test");
return props;
}
@Bean
UserService userService() {
return org.mockito.Mockito.mock(UserService.class);
}
}
}
@@ -1,58 +0,0 @@
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.Optional;
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.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
/** Self-hosted policy context: a global admin may edit; scoping uses the current user's team. */
@ExtendWith(MockitoExtension.class)
class AdminPolicyManagementAuthorityTest {
@Mock private UserService userService;
private AdminPolicyManagementAuthority authority() {
return new AdminPolicyManagementAuthority(userService);
}
@Test
void adminMayEditPolicies() {
when(userService.isCurrentUserAdmin()).thenReturn(true);
assertTrue(authority().canEditPolicies());
}
@Test
void nonAdminMayNot() {
when(userService.isCurrentUserAdmin()).thenReturn(false);
assertFalse(authority().canEditPolicies());
}
@Test
void currentUserTeamIdResolvesFromTheCurrentUsersTeam() {
Team team = new Team();
team.setId(42L);
User user = new User();
user.setTeam(team);
when(userService.getCurrentUsername()).thenReturn("alice");
when(userService.findByUsername("alice")).thenReturn(Optional.of(user));
assertEquals(42L, authority().currentUserTeamId());
}
@Test
void currentUserTeamIdIsNullWhenNoCurrentUser() {
when(userService.getCurrentUsername()).thenReturn(null);
assertNull(authority().currentUserTeamId());
}
}
@@ -1,84 +0,0 @@
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;
/**
* {@link PolicyAccessGuard}: policies are scoped to the caller's team. A user sees/accesses only
* their own team's policies (admins included there is no cross-team escape). Login disabled
* (single-user) bypasses scoping.
*/
@ExtendWith(MockitoExtension.class)
class PolicyAccessGuardTest {
@Mock private UserServiceInterface userService;
@Mock private PolicyManagementAuthority policyManagementAuthority;
private PolicyAccessGuard guard(boolean loginEnabled) {
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(loginEnabled);
return new PolicyAccessGuard(userService, properties, policyManagementAuthority);
}
@Test
void visibleFiltersToTheCallersTeam() {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L);
List<Policy> all = List.of(inTeam(1L), inTeam(2L), inTeam(1L), inTeam(null));
List<Policy> visible = guard(true).visible(all);
assertEquals(2, visible.size());
assertTrue(visible.stream().allMatch(p -> Long.valueOf(1L).equals(p.teamId())));
}
@Test
void visibleReturnsEverythingWhenLoginDisabled() {
List<Policy> all = List.of(inTeam(1L), inTeam(2L));
assertEquals(all, guard(false).visible(all));
}
@Test
void canAccessOnlyOwnTeamsPolicy() {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L);
assertTrue(guard(true).canAccess(inTeam(1L)));
assertFalse(guard(true).canAccess(inTeam(2L)));
assertFalse(guard(true).canAccess(inTeam(null)));
}
@Test
void canAccessAnythingWhenLoginDisabled() {
assertTrue(guard(false).canAccess(inTeam(2L)));
}
@Test
void ownerAndTeamForNewPolicyComeFromTheCurrentUserWhenLoginEnabled() {
when(userService.getCurrentUsername()).thenReturn("alice");
when(policyManagementAuthority.currentUserTeamId()).thenReturn(7L);
assertEquals("alice", guard(true).ownerForNewPolicy());
assertEquals(7L, guard(true).teamForNewPolicy());
}
@Test
void ownerAndTeamForNewPolicyAreNullWhenLoginDisabled() {
assertNull(guard(false).ownerForNewPolicy());
assertNull(guard(false).teamForNewPolicy());
}
private static Policy inTeam(Long teamId) {
return new Policy(
"p1", "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId);
}
}
@@ -28,13 +28,9 @@ 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;
@@ -174,35 +170,6 @@ 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);
@@ -237,86 +204,6 @@ 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);
@@ -16,7 +16,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.env.Environment;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.ApplicationProperties;
@@ -37,7 +36,6 @@ class InitialSecuritySetupTest {
@Mock private TeamService teamService;
@Mock private DatabaseServiceInterface databaseService;
@Mock private UserLicenseSettingsService licenseSettingsService;
@Mock private Environment environment;
private ApplicationProperties applicationProperties;
private InitialSecuritySetup initialSecuritySetup;
@@ -55,15 +53,13 @@ class InitialSecuritySetupTest {
when(userService.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(Optional.of(internalUser));
when(teamService.getOrCreateInternalTeam()).thenReturn(internalTeam);
when(environment.getActiveProfiles()).thenReturn(new String[] {});
initialSecuritySetup =
new InitialSecuritySetup(
userService,
teamService,
applicationProperties,
databaseService,
licenseSettingsService,
environment);
licenseSettingsService);
}
@Test
@@ -1,17 +1,13 @@
package stirling.software.proprietary.security.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
@@ -154,186 +150,4 @@ class UserControllerTest {
verify(loginAttemptService).resetAttempts("lockeduser");
}
// ---------------------------------------------------------------------
// GET /api/v1/user/users - storage.signing.userListScope scoping
// ---------------------------------------------------------------------
private static User user(long id, String username, boolean enabled, Team team) {
User u = new User();
u.setId(id);
u.setUsername(username);
u.setEnabled(enabled);
u.setTeam(team);
return u;
}
private static Team team(long id, String name) {
Team t = new Team();
t.setId(id);
t.setName(name);
return t;
}
private static Authentication auth(String username) {
return new UsernamePasswordAuthenticationToken(username, "pw");
}
@Test
void listUsersDefaultScopeIsOrgWide() throws Exception {
// Default "org" scope returns every enabled user via findAll(), no team lookup.
Team alpha = team(1L, "alpha");
when(userRepository.findAll())
.thenReturn(
List.of(
user(1L, "[email protected]", true, alpha),
user(2L, "[email protected]", true, alpha)));
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].username").value("[email protected]"))
.andExpect(jsonPath("$[1].username").value("[email protected]"));
verify(userRepository, never()).findAllByTeamId(any());
verify(userService, never()).findByUsernameIgnoreCase(anyString());
}
@Test
void listUsersOrgScopeFiltersDisabledUsers() throws Exception {
Team alpha = team(1L, "alpha");
when(userRepository.findAll())
.thenReturn(
List.of(
user(1L, "[email protected]", true, alpha),
user(2L, "[email protected]", false, alpha)));
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].username").value("[email protected]"));
}
@Test
void listUsersTeamScopeReturnsOnlyCallerTeam() throws Exception {
applicationProperties.getStorage().getSigning().setUserListScope("team");
Team alpha = team(7L, "alpha");
User caller = user(1L, "[email protected]", true, alpha);
when(userService.findByUsernameIgnoreCase("[email protected]"))
.thenReturn(Optional.of(caller));
when(userRepository.findAllByTeamId(7L))
.thenReturn(List.of(caller, user(2L, "[email protected]", true, alpha)));
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].teamName").value("alpha"));
verify(userRepository).findAllByTeamId(7L);
verify(userRepository, never()).findAll();
}
@Test
void listUsersTeamScopeWithMissingCallerReturnsEmpty() throws Exception {
applicationProperties.getStorage().getSigning().setUserListScope("team");
when(userService.findByUsernameIgnoreCase("[email protected]")).thenReturn(Optional.empty());
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(0));
verify(userRepository, never()).findAllByTeamId(any());
verify(userRepository, never()).findAll();
}
@Test
void listUsersTeamScopeWithNullTeamReturnsSelfOnly() throws Exception {
applicationProperties.getStorage().getSigning().setUserListScope("team");
User caller = user(1L, "[email protected]", true, null);
when(userService.findByUsernameIgnoreCase("[email protected]"))
.thenReturn(Optional.of(caller));
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].username").value("[email protected]"));
verify(userRepository, never()).findAllByTeamId(any());
verify(userRepository, never()).findAll();
}
@Test
void listUsersFailsClosedOnUnrecognisedScope() throws Exception {
// Any non-"org" value must restrict to the caller's team, not leak the instance.
applicationProperties.getStorage().getSigning().setUserListScope("tewm");
Team alpha = team(3L, "alpha");
when(userService.findByUsernameIgnoreCase("[email protected]"))
.thenReturn(Optional.of(user(1L, "[email protected]", true, alpha)));
when(userRepository.findAllByTeamId(3L))
.thenReturn(List.of(user(1L, "[email protected]", true, alpha)));
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
.andExpect(status().isOk());
verify(userRepository).findAllByTeamId(3L);
verify(userRepository, never()).findAll();
}
@Test
void listUsersFailsClosedOnBlankScope() throws Exception {
applicationProperties.getStorage().getSigning().setUserListScope(" ");
Team alpha = team(4L, "alpha");
when(userService.findByUsernameIgnoreCase("[email protected]"))
.thenReturn(Optional.of(user(1L, "[email protected]", true, alpha)));
when(userRepository.findAllByTeamId(4L)).thenReturn(List.of());
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
.andExpect(status().isOk());
verify(userRepository).findAllByTeamId(4L);
verify(userRepository, never()).findAll();
}
@Test
void listUsersFailsClosedOnNullScope() throws Exception {
// A null value must also fail closed to the caller's team.
applicationProperties.getStorage().getSigning().setUserListScope(null);
Team alpha = team(9L, "alpha");
when(userService.findByUsernameIgnoreCase("[email protected]"))
.thenReturn(Optional.of(user(1L, "[email protected]", true, alpha)));
when(userRepository.findAllByTeamId(9L)).thenReturn(List.of());
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
.andExpect(status().isOk());
verify(userRepository).findAllByTeamId(9L);
verify(userRepository, never()).findAll();
}
@Test
void listUsersOrgScopeIsCaseInsensitive() throws Exception {
applicationProperties.getStorage().getSigning().setUserListScope("ORG");
when(userRepository.findAll()).thenReturn(List.of(user(1L, "[email protected]", true, null)));
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
.andExpect(status().isOk());
verify(userRepository).findAll();
verify(userRepository, never()).findAllByTeamId(any());
}
@Test
void listUsersRequiresAuthentication() throws Exception {
mockMvc.perform(get("/api/v1/user/users")).andExpect(status().isUnauthorized());
verify(userRepository, never()).findAll();
verify(userRepository, never()).findAllByTeamId(any());
}
@Test
void signingUserListScopeDefaultsToOrg() {
// Self-host backward-compat: default must stay "org" (saas profile flips it to "team").
assertEquals(
"org", new ApplicationProperties().getStorage().getSigning().getUserListScope());
}
}
@@ -38,19 +38,11 @@ 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;
@@ -62,7 +54,6 @@ import stirling.software.saas.util.CreditHeaderUtils;
@Tag(name = "AI")
@Hidden
@RequiredArgsConstructor
@RequiresFeature(FeatureGate.AI_SUPPORT)
@Slf4j
public class AiCreateController {
@@ -73,7 +64,6 @@ 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(
@@ -94,45 +84,9 @@ 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,8 +26,6 @@ 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")
@@ -35,7 +33,6 @@ import stirling.software.saas.payg.model.FeatureGate;
@Tag(name = "AI")
@Hidden
@RequiredArgsConstructor
@RequiresFeature(FeatureGate.AI_SUPPORT)
@Slf4j
public class AiCreateInternalController {
@@ -28,8 +28,6 @@ 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;
@@ -38,7 +36,6 @@ import stirling.software.saas.util.CreditHeaderUtils;
@RestController
@Profile("saas")
@RequestMapping("/api/v1/ai")
@RequiresFeature(FeatureGate.AI_SUPPORT)
@Tag(name = "AI")
@Hidden
@Slf4j
@@ -9,10 +9,8 @@ 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 & legacy-credits")
@Profile("saas")
@RequiredArgsConstructor
public class CreditInterceptorConfig implements WebMvcConfigurer {
@@ -25,9 +25,8 @@ 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 & legacy-credits")
@Profile("saas")
@RequestMapping("/api/v1/credits")
@Tag(name = "Credit Management", description = "Endpoints for managing user API credits")
@RequiredArgsConstructor
@@ -39,10 +39,8 @@ 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 & legacy-credits")
@Profile("saas")
@Slf4j
@Order(1)
public class CreditErrorAdvice {
@@ -27,10 +27,8 @@ 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 & legacy-credits")
@Profile("saas")
@Slf4j
public class CreditSuccessAdvice implements ResponseBodyAdvice<Object> {
@@ -32,10 +32,8 @@ 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 & legacy-credits")
@Profile("saas")
@Slf4j
public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor {
@@ -1,56 +0,0 @@
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;
}
}
@@ -1,424 +0,0 @@
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: 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());
}
}
@@ -1,100 +0,0 @@
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. 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) {}
}
@@ -1,47 +0,0 @@
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) {}
@@ -1,272 +0,0 @@
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()
};
}
}
@@ -1,42 +0,0 @@
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);
}
}
@@ -1,101 +0,0 @@
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:
*
* <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. */
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));
}
}
@@ -1,46 +0,0 @@
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,6 +1,5 @@
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;
@@ -9,18 +8,9 @@ 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,
BillingCategory billingCategory) {
Long ownerUserId, Long ownerTeamId, JobSource source, ProcessType processType) {
public ChargeContext {
if (ownerUserId == null) {
@@ -32,8 +22,5 @@ public record ChargeContext(
if (processType == null) {
throw new IllegalArgumentException("processType is required");
}
if (billingCategory == null) {
throw new IllegalArgumentException("billingCategory is required");
}
}
}
@@ -11,8 +11,6 @@ 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;
@@ -23,23 +21,14 @@ 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
@@ -66,29 +55,18 @@ 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,
PaygTeamExtensionsRepository teamExtensionsRepository,
PaygMeterReportingService meterReportingService,
WalletLedgerRepository ledgerRepository) {
ProcessingJobRepository jobRepository) {
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");
}
/**
@@ -126,115 +104,11 @@ public class JobChargeService {
int units = computeUnits(inputs, policy);
result.job().setDocUnits(units);
int freeUsed = consumeFreeGrant(ctx, units);
recordShadowRow(ctx, result.job().getId(), policy.getId(), units, freeUsed);
recordLedgerDebit(ctx, result.job().getId(), policy.getId(), units);
recordShadowRow(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);
@@ -268,28 +142,17 @@ public class JobChargeService {
}
private void recordShadowRow(
ChargeContext ctx,
java.util.UUID jobId,
Long policyId,
int units,
int freeUnitsConsumed) {
ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) {
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);
}
@@ -319,31 +182,6 @@ 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 CHARGEDREFUNDED 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);
}
}
}
}
@@ -359,142 +197,6 @@ 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
@@ -1,357 +0,0 @@
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();
}
}
}
@@ -1,182 +0,0 @@
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};
}
}
@@ -1,46 +0,0 @@
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,7 +12,6 @@ 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;
@@ -38,15 +37,11 @@ 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;
@@ -57,13 +52,10 @@ 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} 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 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 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
@@ -113,7 +105,6 @@ 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. */
@@ -153,11 +144,6 @@ 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")
@@ -182,42 +168,13 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
if (!properties.isEnabled()) {
return true;
}
if (!(handler instanceof HandlerMethod hm)) {
if (!(handler instanceof HandlerMethod hm)
|| hm.getMethodAnnotation(AutoJobPostMapping.class) == null) {
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, auth, category);
doPreHandle(request);
} catch (RuntimeException e) {
log.warn("PAYG preHandle failed; passing through unbilled", e);
errorsCounter.increment();
@@ -230,8 +187,8 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
}
}
private void doPreHandle(
HttpServletRequest request, Authentication auth, BillingCategory category) {
private void doPreHandle(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
User currentUser = resolveUser(auth);
if (currentUser == null) {
callsShortCircuit.increment();
@@ -291,8 +248,7 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
currentUser.getId(),
currentUser.getTeam() == null ? null : currentUser.getTeam().getId(),
determineSource(request, auth),
ProcessType.SINGLE_TOOL,
category);
ProcessType.SINGLE_TOOL);
ChargeOutcome outcome;
try {
@@ -375,38 +331,12 @@ 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 =
@@ -515,54 +445,6 @@ 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,8 +9,6 @@ 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:
*
@@ -30,7 +28,6 @@ import stirling.software.saas.payg.entitlement.EntitlementGuard;
public class PaygWebMvcConfig implements WebMvcConfigurer {
private final PaygChargeInterceptor paygChargeInterceptor;
private final EntitlementGuard entitlementGuard;
@Bean
public FilterRegistrationBean<PaygResponseBodyWrapperFilter>
@@ -42,27 +39,13 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
}
/**
* 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.
* 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.
*/
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)
@@ -73,14 +56,5 @@ 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,20 +229,6 @@ 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,69 +1,34 @@
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, 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.
* safety net.
*
* <p>Single-fire only at V1: not {@code @SchedulerLock}'d, consistent with the other
* {@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.
* {@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.
*/
@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() {
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());
}
}
int closed = jobService.closeStale();
if (closed > 0) {
log.info("StaleJobCloser closed {} job(s) idle past the workflow window.", closed);
}
@@ -1,69 +0,0 @@
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;
}
@@ -1,139 +0,0 @@
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());
}
}
}
@@ -1,221 +0,0 @@
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);
}
}
}
@@ -1,18 +0,0 @@
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,16 +71,6 @@ 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,14 +74,18 @@ public class PricingPolicy implements Serializable {
private Integer fileUnitCap = 1000;
/**
* 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.
* 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.
*/
@Column(name = "free_tier_units", nullable = false)
private Long freeTierUnits = 0L;
@Column(name = "free_tier_units_per_cycle", nullable = false)
private Long freeTierUnitsPerCycle = 0L;
/**
* Max tool steps allowed in one process before it splits, keyed by the caller's {@link

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