SaaS Consolidation (#6384)

Co-authored-by: ConnorYoh <[email protected]>
This commit is contained in:
Anthony Stirling
2026-05-21 16:05:35 +01:00
committed by GitHub
co-authored by ConnorYoh
parent 089de247b4
commit 9d081d1792
208 changed files with 14064 additions and 242 deletions
@@ -0,0 +1,410 @@
package stirling.software.saas.ai.controller;
import java.io.InputStream;
import java.net.http.HttpResponse;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
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.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.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
@RestController
@Profile("saas")
@RequestMapping("/api/v1/ai/create")
@RequiredArgsConstructor
@Slf4j
public class AiCreateController {
private final AiCreateSessionService sessionService;
private final AiCreateProxyService proxyService;
private final ObjectMapper objectMapper = new ObjectMapper();
private final CreditService creditService;
private final TeamCreditService teamCreditService;
private final UserRepository userRepository;
private final CreditHeaderUtils creditHeaderUtils;
@PostMapping("/sessions")
public ResponseEntity<CreateSessionResponse> createSession(
@RequestBody CreateSessionRequest request) {
if (request.prompt() == null || request.prompt().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Prompt is required");
}
AiCreateSession session =
sessionService.createSession(
request.prompt(),
request.docType(),
request.templateId(),
request.templateTex(),
request.previewTex());
log.info(
"AI create session created sessionId={} userId={} docType={} templateId={}",
session.getSessionId(),
session.getUserId(),
session.getDocType(),
session.getTemplateId());
return ResponseEntity.ok(new CreateSessionResponse(session.getSessionId()));
}
@DeleteMapping("/sessions/{sessionId}")
public ResponseEntity<Void> deleteSession(@PathVariable String sessionId) {
sessionService.deleteSessionForCurrentUser(sessionId);
return ResponseEntity.noContent().build();
}
@GetMapping("/sessions/{sessionId}")
@Transactional(readOnly = true)
public ResponseEntity<AiCreateSessionResponse> getSession(@PathVariable String sessionId) {
AiCreateSession session = sessionService.getSessionForCurrentUser(sessionId);
return ResponseEntity.ok(toResponse(session));
}
@GetMapping("/sessions")
@Transactional(readOnly = true)
public ResponseEntity<List<AiCreateSessionSummary>> listSessions(
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "10") int size,
@RequestParam(name = "includeDrafts", defaultValue = "false") boolean includeDrafts) {
int safePage = Math.max(0, page);
int safeSize = Math.max(1, Math.min(size, 50));
List<AiCreateSessionRepository.AiCreateSessionSummaryProjection> sessions =
sessionService.listSessionSummariesForCurrentUser(
org.springframework.data.domain.PageRequest.of(safePage, safeSize),
includeDrafts);
return ResponseEntity.ok(sessions.stream().map(this::toSummary).toList());
}
@PostMapping("/sessions/{sessionId}/outline")
public ResponseEntity<AiCreateSessionResponse> updateOutline(
@PathVariable String sessionId, @RequestBody OutlineRequest request) {
if (request.outlineText() == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Outline text is required");
}
// Allow empty string to indicate "use AI-generated outline"
String constraintsPayload = null;
if (request.constraints() != null) {
try {
constraintsPayload = objectMapper.writeValueAsString(request.constraints());
} catch (JsonProcessingException exc) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid constraints payload", exc);
}
}
AiCreateSession session =
sessionService.updateOutline(
sessionId,
request.outlineText(),
request.outlineFilename(),
constraintsPayload);
return ResponseEntity.ok(toResponse(session));
}
@PostMapping("/sessions/{sessionId}/reprompt")
public ResponseEntity<AiCreateSessionResponse> reprompt(
@PathVariable String sessionId, @RequestBody RepromptRequest request) {
if (request.prompt() == null || request.prompt().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Prompt is required");
}
AiCreateSession session = sessionService.reprompt(sessionId, request.prompt());
return ResponseEntity.ok(toResponse(session));
}
@PostMapping("/sessions/{sessionId}/draft")
public ResponseEntity<AiCreateSessionResponse> updateDraft(
@PathVariable String sessionId, @RequestBody DraftRequest request) {
if (request.draftSections() == null) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Draft sections are required");
}
// Allow empty list to indicate "use AI-generated sections"
String payload;
try {
payload = objectMapper.writeValueAsString(request.draftSections());
} catch (JsonProcessingException exc) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc);
}
AiCreateSession session = sessionService.updateDraftSections(sessionId, payload);
return ResponseEntity.ok(toResponse(session));
}
@PostMapping("/sessions/{sessionId}/template")
public ResponseEntity<AiCreateSessionResponse> updateTemplate(
@PathVariable String sessionId, @RequestBody TemplateRequest request) {
if ((request.docType() == null || request.docType().isBlank())
&& (request.templateId() == null || request.templateId().isBlank())) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "docType or templateId is required");
}
AiCreateSession session =
sessionService.updateTemplate(sessionId, request.docType(), request.templateId());
return ResponseEntity.ok(toResponse(session));
}
@PostMapping("/sessions/{sessionId}/fields")
public ResponseEntity<StreamingResponseBody> fillFields(
@PathVariable String sessionId, HttpServletRequest request) {
sessionService.getSessionForCurrentUser(sessionId);
log.info("AI create fillFields sessionId={}", sessionId);
return proxy(
"POST", "/api/create/sessions/" + sessionId + "/fields", request, false, false);
}
@GetMapping(
value = "/sessions/{sessionId}/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<StreamingResponseBody> stream(
@PathVariable String sessionId, HttpServletRequest request) {
sessionService.getSessionForCurrentUser(sessionId);
return proxy(
"GET",
"/api/create/sessions/" + sessionId + "/stream",
request,
true,
true); // Add credits header: frontend endpoint that triggers AI
}
private ResponseEntity<StreamingResponseBody> proxy(
String method,
String path,
HttpServletRequest request,
boolean acceptEventStream,
boolean includeCreditsHeader) {
try {
HttpResponse<InputStream> response =
proxyService.forward(method, path, request, acceptEventStream);
HttpHeaders headers = new HttpHeaders();
copyHeader(response, headers, HttpHeaders.CONTENT_TYPE);
copyHeader(response, headers, HttpHeaders.CACHE_CONTROL);
copyHeader(response, headers, "X-Accel-Buffering");
copyHeader(response, headers, HttpHeaders.CONTENT_DISPOSITION);
copyHeader(response, headers, HttpHeaders.CONTENT_LENGTH);
if (acceptEventStream && !headers.containsHeader(HttpHeaders.CONTENT_TYPE)) {
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE);
}
// Add credit headers if requested
if (includeCreditsHeader) {
addCreditHeaders(headers);
}
StreamingResponseBody body =
outputStream -> {
try (InputStream inputStream = response.body()) {
inputStream.transferTo(outputStream);
}
};
HttpStatus status =
Optional.ofNullable(HttpStatus.resolve(response.statusCode()))
.orElse(HttpStatus.BAD_GATEWAY);
return new ResponseEntity<>(body, headers, status);
} catch (Exception exc) {
log.error("AI create proxy failed path={}", path, exc);
StreamingResponseBody body =
outputStream ->
outputStream.write("{\"error\":\"AI backend unavailable\"}".getBytes());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return new ResponseEntity<>(body, headers, HttpStatus.SERVICE_UNAVAILABLE);
}
}
private void copyHeader(HttpResponse<?> response, HttpHeaders headers, String headerName) {
response.headers()
.firstValue(headerName)
.ifPresent(value -> headers.set(headerName, value));
}
/**
* Add credit headers to the response headers.
*
* @param headers The headers to add credit information to
*/
private void addCreditHeaders(HttpHeaders headers) {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
log.debug("[AI-CREATE] No authentication found, skipping credit header");
return;
}
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
int remainingCredits =
creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService);
if (remainingCredits >= 0) {
headers.set("X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn("[AI-CREATE] Added X-Credits-Remaining header: {}", remainingCredits);
}
} catch (Exception e) {
log.error("[AI-CREATE] Failed to add credit header: {}", e.getMessage(), e);
}
}
public record CreateSessionRequest(
String prompt,
String docType,
String templateId,
String templateTex,
String previewTex) {}
public record CreateSessionResponse(String sessionId) {}
public record OutlineRequest(
String outlineText, String outlineFilename, Map<String, Object> constraints) {}
public record RepromptRequest(String prompt) {}
public record DraftRequest(List<DraftSection> draftSections) {}
public record DraftSection(String label, String value) {}
public record TemplateRequest(String docType, String templateId) {}
public record AiCreateSessionResponse(
String sessionId,
String userId,
String docType,
String templateId,
String templateTex,
String previewTex,
String promptInitial,
String promptLatest,
String outlineText,
String outlineFilename,
boolean outlineApproved,
Map<String, Object> outlineConstraints,
List<DraftSection> draftSections,
String polishedLatex,
String pdfUrl,
Instant createdAt,
Instant updatedAt,
String status) {}
public record AiCreateSessionSummary(
String sessionId,
String docType,
String templateId,
String promptLatest,
String promptInitial,
String status,
String pdfUrl,
Instant createdAt,
Instant updatedAt) {}
private AiCreateSessionResponse toResponse(AiCreateSession session) {
return new AiCreateSessionResponse(
session.getSessionId(),
session.getUserId(),
session.getDocType(),
session.getTemplateId(),
session.getTemplateTex(),
session.getPreviewTex(),
session.getPromptInitial(),
session.getPromptLatest(),
session.getOutlineText(),
session.getOutlineFilename(),
session.isOutlineApproved(),
parseOutlineConstraints(session.getOutlineConstraints()),
parseDraftSections(session.getDraftSections()),
session.getPolishedLatex(),
session.getPdfUrl(),
session.getCreatedAt(),
session.getUpdatedAt(),
session.getStatus() != null ? session.getStatus().name() : null);
}
private AiCreateSessionSummary toSummary(AiCreateSession session) {
return new AiCreateSessionSummary(
session.getSessionId(),
session.getDocType(),
session.getTemplateId(),
session.getPromptLatest(),
session.getPromptInitial(),
session.getStatus() != null ? session.getStatus().name() : null,
session.getPdfUrl(),
session.getCreatedAt(),
session.getUpdatedAt());
}
private AiCreateSessionSummary toSummary(
AiCreateSessionRepository.AiCreateSessionSummaryProjection session) {
return new AiCreateSessionSummary(
session.getSessionId(),
session.getDocType(),
session.getTemplateId(),
session.getPromptLatest(),
session.getPromptInitial(),
session.getStatus() != null ? session.getStatus().name() : null,
session.getPdfUrl(),
session.getCreatedAt(),
session.getUpdatedAt());
}
private List<DraftSection> parseDraftSections(String payload) {
if (payload == null || payload.isBlank()) {
return null;
}
try {
return objectMapper.readValue(
payload,
objectMapper
.getTypeFactory()
.constructCollectionType(List.class, DraftSection.class));
} catch (JsonProcessingException exc) {
log.warn("Failed to parse draft sections payload", exc);
return null;
}
}
private Map<String, Object> parseOutlineConstraints(String payload) {
if (payload == null || payload.isBlank()) {
return null;
}
try {
return objectMapper.readValue(
payload,
objectMapper
.getTypeFactory()
.constructMapType(Map.class, String.class, Object.class));
} catch (JsonProcessingException exc) {
log.warn("Failed to parse outline constraints payload", exc);
return null;
}
}
}
@@ -0,0 +1,152 @@
package stirling.software.saas.ai.controller;
import java.util.List;
import java.util.Map;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
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;
@RestController
@Profile("saas")
@RequestMapping("/api/v1/ai/create/internal")
@RequiredArgsConstructor
@Slf4j
public class AiCreateInternalController {
private final AiCreateSessionService sessionService;
// Inlined: Stirling's parent build uses Jackson 3 (tools.jackson), no Jackson 2 ObjectMapper
// bean in the context. Stateless usage, so a fresh instance per controller is fine.
private final ObjectMapper objectMapper = new ObjectMapper();
@GetMapping("/sessions/{sessionId}")
public ResponseEntity<AiCreateController.AiCreateSessionResponse> getSession(
@PathVariable String sessionId) {
log.info("AI create internal getSession sessionId={}", sessionId);
AiCreateSession session = sessionService.getSession(sessionId);
return ResponseEntity.ok(toResponse(session));
}
@PostMapping("/sessions/{sessionId}/update")
public ResponseEntity<AiCreateController.AiCreateSessionResponse> updateSession(
@PathVariable String sessionId, @RequestBody UpdateSessionRequest request) {
log.info("AI create internal updateSession sessionId={}", sessionId);
String outlineConstraintsPayload = null;
if (request.outlineConstraints() != null) {
try {
outlineConstraintsPayload =
objectMapper.writeValueAsString(request.outlineConstraints());
} catch (JsonProcessingException exc) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid outline constraints payload", exc);
}
}
String draftSectionsPayload = null;
if (request.draftSections() != null) {
try {
draftSectionsPayload = objectMapper.writeValueAsString(request.draftSections());
} catch (JsonProcessingException exc) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc);
}
}
AiCreateSession session =
sessionService.applyInternalUpdate(
sessionId,
request.outlineText(),
request.outlineFilename(),
request.outlineApproved(),
outlineConstraintsPayload,
draftSectionsPayload,
request.polishedLatex(),
request.pdfUrl(),
request.docType(),
request.templateId(),
request.status());
return ResponseEntity.ok(toResponse(session));
}
public record UpdateSessionRequest(
String outlineText,
String outlineFilename,
Boolean outlineApproved,
Map<String, Object> outlineConstraints,
List<AiCreateController.DraftSection> draftSections,
String polishedLatex,
String pdfUrl,
String docType,
String templateId,
AiCreateSessionStatus status) {}
private AiCreateController.AiCreateSessionResponse toResponse(AiCreateSession session) {
return new AiCreateController.AiCreateSessionResponse(
session.getSessionId(),
session.getUserId(),
session.getDocType(),
session.getTemplateId(),
session.getTemplateTex(),
session.getPreviewTex(),
session.getPromptInitial(),
session.getPromptLatest(),
session.getOutlineText(),
session.getOutlineFilename(),
session.isOutlineApproved(),
parseOutlineConstraints(session.getOutlineConstraints()),
parseDraftSections(session.getDraftSections()),
session.getPolishedLatex(),
session.getPdfUrl(),
session.getCreatedAt(),
session.getUpdatedAt(),
session.getStatus() != null ? session.getStatus().name() : null);
}
private List<AiCreateController.DraftSection> parseDraftSections(String payload) {
if (payload == null || payload.isBlank()) {
return null;
}
try {
return objectMapper.readValue(
payload,
objectMapper
.getTypeFactory()
.constructCollectionType(
List.class, AiCreateController.DraftSection.class));
} catch (JsonProcessingException exc) {
log.warn("Failed to parse draft sections payload", exc);
return null;
}
}
private Map<String, Object> parseOutlineConstraints(String payload) {
if (payload == null || payload.isBlank()) {
return null;
}
try {
return objectMapper.readValue(
payload,
objectMapper
.getTypeFactory()
.constructMapType(Map.class, String.class, Object.class));
} catch (JsonProcessingException exc) {
log.warn("Failed to parse outline constraints payload", exc);
return null;
}
}
}
@@ -0,0 +1,268 @@
package stirling.software.saas.ai.controller;
import java.io.InputStream;
import java.net.http.HttpResponse;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import jakarta.servlet.http.HttpServletRequest;
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.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
@RestController
@Profile("saas")
@RequestMapping("/api/v1/ai")
@Slf4j
public class AiProxyController {
private final AiProxyService aiProxyService;
private final CreditService creditService;
private final TeamCreditService teamCreditService;
private final UserRepository userRepository;
private final CreditHeaderUtils creditHeaderUtils;
public AiProxyController(
AiProxyService aiProxyService,
CreditService creditService,
TeamCreditService teamCreditService,
UserRepository userRepository,
CreditHeaderUtils creditHeaderUtils) {
this.aiProxyService = aiProxyService;
this.creditService = creditService;
this.teamCreditService = teamCreditService;
this.userRepository = userRepository;
this.creditHeaderUtils = creditHeaderUtils;
}
@PostMapping("/generate_section")
public ResponseEntity<StreamingResponseBody> generateSection(HttpServletRequest request) {
return proxy("POST", "/api/generate_section", request, false, false);
}
@PostMapping("/generate_all_sections")
public ResponseEntity<StreamingResponseBody> generateAllSections(HttpServletRequest request) {
return proxy("POST", "/api/generate_all_sections", request, false, false);
}
@PostMapping("/intent/check")
public ResponseEntity<StreamingResponseBody> intentCheck(HttpServletRequest request) {
return proxy("POST", "/api/intent/check", request, false, false);
}
@PostMapping("/chat/route")
public ResponseEntity<StreamingResponseBody> chatRoute(HttpServletRequest request) {
return proxy("POST", "/api/chat/route", request, false, true);
}
@PostMapping("/chat/create-smart-folder")
public ResponseEntity<StreamingResponseBody> createSmartFolder(HttpServletRequest request) {
return proxy("POST", "/api/chat/create-smart-folder", request, false, true);
}
@PostMapping("/chat/info")
public ResponseEntity<StreamingResponseBody> chatInfo(HttpServletRequest request) {
return proxy("POST", "/api/chat/info", request, false, true);
}
@PostMapping("/pdf/answer")
public ResponseEntity<StreamingResponseBody> pdfAnswer(HttpServletRequest request) {
return proxy("POST", "/api/pdf/answer", request, false, false);
}
@PostMapping("/progressive_render")
public ResponseEntity<StreamingResponseBody> progressiveRender(HttpServletRequest request) {
return proxy("POST", "/api/progressive_render", request, false, false);
}
@GetMapping("/versions/{userId}")
public ResponseEntity<StreamingResponseBody> versions(
@PathVariable("userId") String userId, HttpServletRequest request) {
return proxy("GET", "/api/versions/" + userId, request, false, false);
}
@GetMapping("/style/{userId}")
public ResponseEntity<StreamingResponseBody> style(
@PathVariable("userId") String userId, HttpServletRequest request) {
return proxy("GET", "/api/style/" + userId, request, false, false);
}
@PostMapping("/style/{userId}")
public ResponseEntity<StreamingResponseBody> updateStyle(
@PathVariable("userId") String userId, HttpServletRequest request) {
return proxy("POST", "/api/style/" + userId, request, false, false);
}
@PostMapping("/import_template")
public ResponseEntity<StreamingResponseBody> importTemplate(HttpServletRequest request) {
return proxy("POST", "/api/import_template", request, false, false);
}
@PostMapping("/edit/sessions")
public ResponseEntity<StreamingResponseBody> createEditSession(HttpServletRequest request) {
return proxy("POST", "/api/edit/sessions", request, false, false);
}
@PostMapping("/edit/sessions/{sessionId}/messages")
public ResponseEntity<StreamingResponseBody> editSessionMessage(
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
return proxy("POST", "/api/edit/sessions/" + sessionId + "/messages", request, false, true);
}
@PostMapping("/edit/sessions/{sessionId}/attachments")
public ResponseEntity<StreamingResponseBody> editSessionAttachment(
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
return proxy(
"POST", "/api/edit/sessions/" + sessionId + "/attachments", request, false, false);
}
@PostMapping(
value = "/edit/sessions/{sessionId}/run",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<StreamingResponseBody> runEditSession(
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
return proxy("POST", "/api/edit/sessions/" + sessionId + "/run", request, true, false);
}
@GetMapping("/pdf-editor/document")
public ResponseEntity<StreamingResponseBody> pdfEditorDocument(HttpServletRequest request) {
return proxy("GET", "/api/pdf-editor/document", request, false, false);
}
@PostMapping("/pdf-editor/upload")
public ResponseEntity<StreamingResponseBody> pdfEditorUpload(HttpServletRequest request) {
return proxy("POST", "/api/pdf-editor/upload", request, false, false);
}
@GetMapping("/output/**")
public ResponseEntity<StreamingResponseBody> output(HttpServletRequest request) {
String requestUri = request.getRequestURI();
String prefix = request.getContextPath() + "/api/v1/ai/output/";
String path = requestUri.startsWith(prefix) ? requestUri.substring(prefix.length()) : "";
return proxy("GET", "/output/" + path, request, false, false);
}
// Health endpoint at /api/v1/ai/health is owned by the proprietary AiEngineController; both
// proxy to the same backing AI engine. No need for credit-aware wrapping on a health probe.
/**
* Proxy method that optionally adds credit headers.
*
* @param method HTTP method
* @param path API path
* @param request The incoming request
* @param acceptEventStream Whether to accept event stream responses
* @param includeCreditsHeader Whether to add credit balance header
*/
private ResponseEntity<StreamingResponseBody> proxy(
String method,
String path,
HttpServletRequest request,
boolean acceptEventStream,
boolean includeCreditsHeader) {
try {
// Forward to AI backend
HttpResponse<InputStream> aiResponse =
aiProxyService.forward(method, path, request, acceptEventStream);
// Build response headers
HttpHeaders headers = new HttpHeaders();
copyHeader(aiResponse, headers, HttpHeaders.CONTENT_TYPE);
copyHeader(aiResponse, headers, HttpHeaders.CACHE_CONTROL);
copyHeader(aiResponse, headers, "X-Accel-Buffering");
copyHeader(aiResponse, headers, HttpHeaders.CONTENT_DISPOSITION);
copyHeader(aiResponse, headers, HttpHeaders.CONTENT_LENGTH);
if (acceptEventStream && !headers.containsHeader(HttpHeaders.CONTENT_TYPE)) {
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE);
}
// Add credit headers if requested (after AI processing completes)
if (includeCreditsHeader) {
addCreditHeaders(headers);
}
StreamingResponseBody body =
outputStream -> {
try (InputStream inputStream = aiResponse.body()) {
inputStream.transferTo(outputStream);
}
};
HttpStatus status =
Optional.ofNullable(HttpStatus.resolve(aiResponse.statusCode()))
.orElse(HttpStatus.BAD_GATEWAY);
return new ResponseEntity<>(body, headers, status);
} catch (Exception exc) {
log.error("AI proxy failed path={}", path, exc);
StreamingResponseBody body =
outputStream ->
outputStream.write("{\"error\":\"AI backend unavailable\"}".getBytes());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return new ResponseEntity<>(body, headers, HttpStatus.SERVICE_UNAVAILABLE);
}
}
private void copyHeader(HttpResponse<?> response, HttpHeaders headers, String headerName) {
if (headerName == null || headerName.isBlank()) {
return;
}
response.headers()
.firstValue(headerName)
.filter(value -> value != null && !value.isBlank())
.filter(value -> !value.contains("\r") && !value.contains("\n"))
.ifPresent(
value -> {
try {
headers.set(headerName, value);
} catch (IllegalArgumentException exc) {
log.warn("Skipping invalid header {}: {}", headerName, value);
}
});
}
/**
* Add credit headers to the response headers.
*
* @param headers The headers to add credit information to
*/
private void addCreditHeaders(HttpHeaders headers) {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
log.debug("[AI-PROXY] No authentication found, skipping credit header");
return;
}
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
int remainingCredits =
creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService);
if (remainingCredits >= 0) {
headers.set("X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn("[AI-PROXY] Added X-Credits-Remaining header: {}", remainingCredits);
}
headers.set("X-Credit-Source", "AI_TOOL_CALL");
} catch (Exception e) {
log.error("[AI-PROXY] Failed to add credit header: {}", e.getMessage(), e);
}
}
}
@@ -0,0 +1,63 @@
package stirling.software.saas.ai.model;
import java.time.Instant;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import lombok.Data;
@Entity
@Table(name = "ai_create_sessions")
@Data
public class AiCreateSession {
@Id private String sessionId;
@Column(nullable = false)
private String userId;
private String docType;
private String templateId;
private String templateTex;
private String previewTex;
@Lob private String promptInitial;
@Lob private String promptLatest;
@Lob private String outlineText;
private String outlineFilename;
private boolean outlineApproved;
@Lob private String outlineConstraints;
@Lob private String draftSections;
@Lob private String polishedLatex;
// Default JPA String column is varchar(255); signed Supabase / S3 URLs commonly run
// 500-1500 chars due to embedded query params (signature, expiry, response headers).
@Column(length = 2048)
private String pdfUrl;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AiCreateSessionStatus status;
@CreationTimestamp private Instant createdAt;
@UpdateTimestamp private Instant updatedAt;
}
@@ -0,0 +1,10 @@
package stirling.software.saas.ai.model;
public enum AiCreateSessionStatus {
OUTLINE_PENDING,
OUTLINE_APPROVED,
DRAFT_READY,
POLISHED_READY,
SAVED,
SHARED
}
@@ -0,0 +1,79 @@
package stirling.software.saas.ai.repository;
import java.time.Instant;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import stirling.software.saas.ai.model.AiCreateSession;
import stirling.software.saas.ai.model.AiCreateSessionStatus;
public interface AiCreateSessionRepository extends JpaRepository<AiCreateSession, String> {
List<AiCreateSession> findByUserIdOrderByUpdatedAtDesc(String userId);
List<AiCreateSession> findByUserIdOrderByUpdatedAtDesc(String userId, Pageable pageable);
List<AiCreateSession> findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(
String userId, Pageable pageable);
@Query(
"""
select s.sessionId as sessionId,
s.docType as docType,
s.templateId as templateId,
s.promptLatest as promptLatest,
s.promptInitial as promptInitial,
s.status as status,
s.pdfUrl as pdfUrl,
s.createdAt as createdAt,
s.updatedAt as updatedAt
from AiCreateSession s
where s.userId = :userId
order by s.updatedAt desc
""")
List<AiCreateSessionSummaryProjection> findSummariesByUserIdOrderByUpdatedAtDesc(
@Param("userId") String userId, Pageable pageable);
@Query(
"""
select s.sessionId as sessionId,
s.docType as docType,
s.templateId as templateId,
s.promptLatest as promptLatest,
s.promptInitial as promptInitial,
s.status as status,
s.pdfUrl as pdfUrl,
s.createdAt as createdAt,
s.updatedAt as updatedAt
from AiCreateSession s
where s.userId = :userId
and s.pdfUrl is not null
order by s.updatedAt desc
""")
List<AiCreateSessionSummaryProjection>
findSummariesByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(
@Param("userId") String userId, Pageable pageable);
interface AiCreateSessionSummaryProjection {
String getSessionId();
String getDocType();
String getTemplateId();
String getPromptLatest();
String getPromptInitial();
AiCreateSessionStatus getStatus();
String getPdfUrl();
Instant getCreatedAt();
Instant getUpdatedAt();
}
}
@@ -0,0 +1,145 @@
package stirling.software.saas.ai.service;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.service.UserService;
@Service
@Profile("saas")
@Slf4j
public class AiCreateProxyService {
private static final String DEFAULT_AI_BASE_URL = "http://localhost:5001";
private final String aiServiceBaseUrl;
private final HttpClient httpClient;
private final UserRepository userRepository;
private final UserService userService;
public AiCreateProxyService(
@Value("${app.ai.service-base-url:" + DEFAULT_AI_BASE_URL + "}")
String aiServiceBaseUrl,
UserRepository userRepository,
UserService userService) {
this.aiServiceBaseUrl = aiServiceBaseUrl;
this.httpClient = HttpClient.newBuilder().build();
this.userRepository = userRepository;
this.userService = userService;
}
public HttpResponse<InputStream> forward(
String method, String path, HttpServletRequest request, boolean acceptEventStream)
throws IOException, InterruptedException {
String targetUrl = buildTargetUrl(path, request.getQueryString());
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(targetUrl));
String contentType = request.getContentType();
if (contentType != null && !contentType.isBlank()) {
builder.header("Content-Type", contentType);
}
String authorization = request.getHeader("Authorization");
if (authorization != null && !authorization.isBlank()) {
builder.header("Authorization", authorization);
}
// Extract user API key from authenticated user and forward to AI backend
String apiKey = request.getHeader("X-API-KEY");
if (apiKey == null || apiKey.isBlank()) {
apiKey = extractUserApiKey();
}
if (apiKey != null && !apiKey.isBlank()) {
builder.header("X-API-KEY", apiKey);
log.debug("Forwarding X-API-KEY header to AI backend");
}
String accept = request.getHeader("Accept");
if (acceptEventStream) {
builder.header("Accept", "text/event-stream");
} else if (accept != null && !accept.isBlank()) {
builder.header("Accept", accept);
}
builder.method(method, buildBodyPublisher(method, request));
log.debug("Proxying AI create request {} {}", method, targetUrl);
return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
}
private String buildTargetUrl(String path, String queryString) {
String baseUrl = aiServiceBaseUrl;
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = DEFAULT_AI_BASE_URL;
}
baseUrl = baseUrl.trim();
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
if (!path.startsWith("/")) {
path = "/" + path;
}
String url = baseUrl + path;
if (queryString != null && !queryString.isBlank()) {
url += "?" + queryString;
}
return url;
}
private HttpRequest.BodyPublisher buildBodyPublisher(
String method, HttpServletRequest request) {
if ("GET".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method)) {
return HttpRequest.BodyPublishers.noBody();
}
return HttpRequest.BodyPublishers.ofInputStream(
() -> {
try {
return request.getInputStream();
} catch (IOException exc) {
throw new UncheckedIOException(exc);
}
});
}
/**
* Extract the authenticated user's API key from the database. If the user doesn't have an API
* key, one will be created automatically.
*
* @return The user's API key, or null if not authenticated or key creation fails
*/
private String extractUserApiKey() {
try {
// Use getCurrentUsername() which handles all auth types including anonymous users
String username = userService.getCurrentUsername();
if (username == null || username.isBlank()) {
log.debug("No authenticated user found for API key extraction");
return null;
}
// getApiKeyForUser will create a key if it doesn't exist
String apiKey = userService.getApiKeyForUser(username);
log.debug("Retrieved API key for user: {}", username);
return apiKey;
} catch (Exception e) {
log.error(
"Failed to extract or create user API key for user: {}",
userService.getCurrentUsername(),
e);
return null;
}
}
}
@@ -0,0 +1,262 @@
package stirling.software.saas.ai.service;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.server.ResponseStatusException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.saas.ai.model.AiCreateSession;
import stirling.software.saas.ai.model.AiCreateSessionStatus;
import stirling.software.saas.ai.repository.AiCreateSessionRepository;
import stirling.software.saas.util.AuthenticationUtils;
@Service
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class AiCreateSessionService {
private static final String DEFAULT_USER_ID = "default_user";
private final AiCreateSessionRepository repository;
private final Optional<UserServiceInterface> userService;
public AiCreateSession createSession(
String prompt,
String docType,
String templateId,
String templateTex,
String previewTex) {
String userId = resolveUserId();
AiCreateSession session = new AiCreateSession();
session.setSessionId(UUID.randomUUID().toString());
session.setUserId(userId);
session.setDocType(docType);
session.setTemplateId(templateId);
session.setTemplateTex(templateTex);
session.setPreviewTex(previewTex);
session.setPromptInitial(prompt);
session.setPromptLatest(prompt);
session.setOutlineApproved(false);
session.setStatus(AiCreateSessionStatus.OUTLINE_PENDING);
return repository.save(session);
}
public AiCreateSession getSession(String sessionId) {
return repository
.findById(sessionId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND, "AI session not found"));
}
public AiCreateSession getSessionForCurrentUser(String sessionId) {
AiCreateSession session = getSession(sessionId);
String userId = resolveUserId();
if (!userId.equals(session.getUserId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "AI session not found");
}
return session;
}
public AiCreateSession updateOutline(
String sessionId,
String outlineText,
String outlineFilename,
String outlineConstraints) {
AiCreateSession session = getSessionForCurrentUser(sessionId);
session.setOutlineText(outlineText);
if (outlineFilename != null && !outlineFilename.isBlank()) {
session.setOutlineFilename(outlineFilename);
}
session.setOutlineApproved(true);
if (outlineConstraints != null) {
session.setOutlineConstraints(outlineConstraints);
}
session.setStatus(AiCreateSessionStatus.OUTLINE_APPROVED);
return repository.save(session);
}
public AiCreateSession updateDraftSections(String sessionId, String draftSections) {
AiCreateSession session = getSessionForCurrentUser(sessionId);
session.setDraftSections(draftSections);
session.setStatus(AiCreateSessionStatus.DRAFT_READY);
return repository.save(session);
}
public AiCreateSession updateTemplate(String sessionId, String docType, String templateId) {
AiCreateSession session = getSessionForCurrentUser(sessionId);
if (docType != null && !docType.isBlank()) {
session.setDocType(docType);
}
if (templateId != null && !templateId.isBlank()) {
session.setTemplateId(templateId);
}
return repository.save(session);
}
public AiCreateSession reprompt(String sessionId, String prompt) {
AiCreateSession session = getSessionForCurrentUser(sessionId);
session.setPromptLatest(prompt);
session.setOutlineText(null);
session.setOutlineFilename(null);
session.setOutlineApproved(false);
session.setOutlineConstraints(null);
session.setDraftSections(null);
session.setPolishedLatex(null);
session.setPdfUrl(null);
session.setStatus(AiCreateSessionStatus.OUTLINE_PENDING);
return repository.save(session);
}
public void deleteSessionForCurrentUser(String sessionId) {
AiCreateSession session = getSessionForCurrentUser(sessionId);
repository.delete(session);
}
public AiCreateSession applyInternalUpdate(
String sessionId,
String outlineText,
String outlineFilename,
Boolean outlineApproved,
String outlineConstraints,
String draftSections,
String polishedLatex,
String pdfUrl,
String docType,
String templateId,
AiCreateSessionStatus status) {
AiCreateSession session = getSession(sessionId);
if (outlineText != null) {
session.setOutlineText(outlineText);
}
if (outlineFilename != null) {
session.setOutlineFilename(outlineFilename);
}
if (outlineApproved != null) {
session.setOutlineApproved(outlineApproved);
}
if (outlineConstraints != null) {
session.setOutlineConstraints(outlineConstraints);
}
if (draftSections != null) {
session.setDraftSections(draftSections);
}
if (polishedLatex != null) {
session.setPolishedLatex(polishedLatex);
}
if (pdfUrl != null) {
session.setPdfUrl(pdfUrl);
}
if (docType != null) {
session.setDocType(docType);
}
if (templateId != null) {
session.setTemplateId(templateId);
}
if (status != null) {
session.setStatus(status);
}
return repository.save(session);
}
public List<AiCreateSession> listSessionsForCurrentUser() {
String userId = resolveUserId();
return repository.findByUserIdOrderByUpdatedAtDesc(userId);
}
public List<AiCreateSession> listSessionsForCurrentUser(Pageable pageable) {
String userId = resolveUserId();
return repository.findByUserIdOrderByUpdatedAtDesc(userId, pageable);
}
public List<AiCreateSession> listSessionsForCurrentUser(
Pageable pageable, boolean includeDrafts) {
String userId = resolveUserId();
if (includeDrafts) {
return repository.findByUserIdOrderByUpdatedAtDesc(userId, pageable);
}
return repository.findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(userId, pageable);
}
public List<AiCreateSessionRepository.AiCreateSessionSummaryProjection>
listSessionSummariesForCurrentUser(Pageable pageable, boolean includeDrafts) {
String userId = resolveUserId();
if (includeDrafts) {
return repository.findSummariesByUserIdOrderByUpdatedAtDesc(userId, pageable);
}
return repository.findSummariesByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(
userId, pageable);
}
public String resolveUserId() {
String userId = resolveFromUserService();
if (userId != null) {
return userId;
}
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
String authId = AuthenticationUtils.extractSupabaseId(authentication);
if (authId != null && !authId.isBlank() && !"anonymousUser".equals(authId)) {
return authId;
}
}
String sessionScoped = resolveSessionScopedId();
if (sessionScoped != null) {
return sessionScoped;
}
return DEFAULT_USER_ID;
}
private String resolveFromUserService() {
if (userService.isEmpty()) {
return null;
}
try {
String username = userService.get().getCurrentUsername();
if (username != null && !username.isBlank() && !"anonymousUser".equals(username)) {
return username;
}
} catch (Exception exc) {
log.debug("Failed to resolve current username: {}", exc.getMessage());
}
return null;
}
private String resolveSessionScopedId() {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return null;
}
HttpServletRequest request = attributes.getRequest();
if (request == null) {
return null;
}
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
return "session:" + session.getId();
}
}
@@ -0,0 +1,217 @@
package stirling.software.saas.ai.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.service.UserService;
@Service
@Profile("saas")
@Slf4j
public class AiProxyService {
private static final String DEFAULT_AI_BASE_URL = "http://localhost:5001";
private final String aiServiceBaseUrl;
private final HttpClient httpClient;
private final UserRepository userRepository;
private final UserService userService;
public AiProxyService(
@Value("${app.ai.service-base-url:" + DEFAULT_AI_BASE_URL + "}")
String aiServiceBaseUrl,
UserRepository userRepository,
UserService userService) {
this.aiServiceBaseUrl = aiServiceBaseUrl;
this.httpClient = HttpClient.newBuilder().build();
this.userRepository = userRepository;
this.userService = userService;
}
public HttpResponse<InputStream> forward(
String method, String path, HttpServletRequest request, boolean acceptEventStream)
throws IOException, InterruptedException {
String targetUrl = buildTargetUrl(path, request.getQueryString());
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(targetUrl));
String contentType = request.getContentType();
String authorization = request.getHeader("Authorization");
if (authorization != null && !authorization.isBlank()) {
builder.header("Authorization", authorization);
}
// Extract user API key from authenticated user and forward to AI backend
String apiKey = request.getHeader("X-API-KEY");
if (apiKey == null || apiKey.isBlank()) {
apiKey = extractUserApiKey();
}
if (apiKey != null && !apiKey.isBlank()) {
builder.header("X-API-KEY", apiKey);
log.debug("Forwarding X-API-KEY header to AI backend");
}
String accept = request.getHeader("Accept");
if (acceptEventStream) {
builder.header("Accept", "text/event-stream");
} else if (accept != null && !accept.isBlank()) {
builder.header("Accept", accept);
}
BodyPublisherWithContentType body = buildBodyPublisher(method, request, contentType);
if (body.contentType != null && !body.contentType.isBlank()) {
builder.header("Content-Type", body.contentType);
} else if (contentType != null && !contentType.isBlank()) {
builder.header("Content-Type", contentType);
}
builder.method(method, body.publisher);
log.debug("Proxying AI request {} {}", method, targetUrl);
return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
}
private String buildTargetUrl(String path, String queryString) {
String baseUrl = aiServiceBaseUrl;
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = DEFAULT_AI_BASE_URL;
}
baseUrl = baseUrl.trim();
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
if (!path.startsWith("/")) {
path = "/" + path;
}
String url = baseUrl + path;
if (queryString != null && !queryString.isBlank()) {
url += "?" + queryString;
}
return url;
}
private BodyPublisherWithContentType buildBodyPublisher(
String method, HttpServletRequest request, String contentType) throws IOException {
if ("GET".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method)) {
return new BodyPublisherWithContentType(HttpRequest.BodyPublishers.noBody(), null);
}
if (contentType != null && contentType.startsWith("multipart/form-data")) {
String boundary = "----spdf-" + UUID.randomUUID().toString().replace("-", "");
byte[] body = buildMultipartBody(request, boundary);
return new BodyPublisherWithContentType(
HttpRequest.BodyPublishers.ofByteArray(body),
"multipart/form-data; boundary=" + boundary);
}
return new BodyPublisherWithContentType(
HttpRequest.BodyPublishers.ofInputStream(
() -> {
try {
return request.getInputStream();
} catch (IOException exc) {
throw new UncheckedIOException(exc);
}
}),
null);
}
private byte[] buildMultipartBody(HttpServletRequest request, String boundary)
throws IOException {
try {
ByteArrayOutputStream output = new ByteArrayOutputStream();
for (Part part : request.getParts()) {
writeLine(output, "--" + boundary);
String name = part.getName();
String filename = part.getSubmittedFileName();
if (filename != null && !filename.isBlank()) {
writeLine(
output,
"Content-Disposition: form-data; name=\""
+ name
+ "\"; filename=\""
+ filename
+ "\"");
} else {
writeLine(output, "Content-Disposition: form-data; name=\"" + name + "\"");
}
String partContentType = part.getContentType();
if (partContentType != null && !partContentType.isBlank()) {
writeLine(output, "Content-Type: " + partContentType);
}
writeLine(output, "");
try (InputStream input = part.getInputStream()) {
input.transferTo(output);
}
writeLine(output, "");
}
writeLine(output, "--" + boundary + "--");
writeLine(output, "");
return output.toByteArray();
} catch (Exception exc) {
if (exc instanceof IOException) {
throw (IOException) exc;
}
throw new IOException("Failed to proxy multipart request", exc);
}
}
private void writeLine(ByteArrayOutputStream output, String value) throws IOException {
output.write(value.getBytes(StandardCharsets.UTF_8));
output.write("\r\n".getBytes(StandardCharsets.UTF_8));
}
/**
* Extract the authenticated user's API key from the database. If the user doesn't have an API
* key, one will be created automatically.
*
* @return The user's API key, or null if not authenticated or key creation fails
*/
private String extractUserApiKey() {
try {
// Use getCurrentUsername() which handles all auth types including anonymous users
String username = userService.getCurrentUsername();
if (username == null || username.isBlank()) {
log.debug("No authenticated user found for API key extraction");
return null;
}
// getApiKeyForUser will create a key if it doesn't exist
String apiKey = userService.getApiKeyForUser(username);
log.debug("Retrieved API key for user: {}", username);
return apiKey;
} catch (Exception e) {
log.error(
"Failed to extract or create user API key for user: {}",
userService.getCurrentUsername(),
e);
return null;
}
}
private static class BodyPublisherWithContentType {
private final HttpRequest.BodyPublisher publisher;
private final String contentType;
private BodyPublisherWithContentType(
HttpRequest.BodyPublisher publisher, String contentType) {
this.publisher = publisher;
this.contentType = contentType;
}
}
}
@@ -0,0 +1,74 @@
package stirling.software.saas.billing.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Stripe billing subscription mirror. A row appears here when Supabase webhooks the server to
* record a tenant's subscription state.
*/
@Entity
@Table(name = "billing_subscriptions")
@NoArgsConstructor
@Getter
@Setter
public class BillingSubscription implements Serializable {
private static final long serialVersionUID = 1L;
/** Stripe subscription ID. */
@Id
@Column(name = "id")
private String id;
/** Supabase auth user ID owning this subscription. */
@Column(name = "user_id", nullable = false)
private UUID userId;
/** Optional team ID if this subscription is at team level rather than per-user. */
@Column(name = "team_id")
private Long teamId;
/** Stripe subscription status: active, trialing, past_due, canceled, etc. */
@Column(name = "status", nullable = false)
private String status;
/** Stripe price ID. */
@Column(name = "price_id")
private String priceId;
@Column(name = "current_period_end")
private LocalDateTime currentPeriodEnd;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public boolean isActive() {
return "active".equalsIgnoreCase(status)
|| "trialing".equalsIgnoreCase(status)
|| "past_due".equalsIgnoreCase(status);
}
public boolean isValid() {
return isActive()
&& (currentPeriodEnd == null || currentPeriodEnd.isAfter(LocalDateTime.now()));
}
}
@@ -0,0 +1,52 @@
package stirling.software.saas.billing.repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.saas.billing.model.BillingSubscription;
/** Read/write access to the Stripe subscription mirror table. */
@Repository
public interface BillingSubscriptionRepository extends JpaRepository<BillingSubscription, String> {
List<BillingSubscription> findByUserId(UUID userId);
@Query(
"SELECT s FROM BillingSubscription s "
+ "WHERE s.userId = :userId "
+ "AND s.status IN ('active', 'trialing', 'past_due') "
+ "ORDER BY s.createdAt DESC")
List<BillingSubscription> findActiveSubscriptionsByUserId(@Param("userId") UUID userId);
@Query(
"SELECT COUNT(s) > 0 FROM BillingSubscription s "
+ "WHERE s.userId = :userId "
+ "AND s.status IN ('active', 'trialing', 'past_due')")
boolean existsActiveSubscriptionForUser(@Param("userId") UUID userId);
/**
* Active PAID subscription (excludes trials). 'active' and 'past_due' count as paid; 'trialing'
* does not, because trial users can be invited to teams without becoming payers.
*/
@Query(
"SELECT COUNT(s) > 0 FROM BillingSubscription s "
+ "WHERE s.userId = :userId "
+ "AND s.status IN ('active', 'past_due')")
boolean existsActivePaidSubscriptionForUser(@Param("userId") UUID userId);
default Optional<BillingSubscription> findLatestActiveSubscription(UUID userId) {
return findActiveSubscriptionsByUserId(userId).stream().findFirst();
}
@Query(
"SELECT COUNT(s) > 0 FROM BillingSubscription s "
+ "WHERE s.teamId = :teamId "
+ "AND s.status IN ('active', 'trialing', 'past_due')")
boolean existsActiveSubscriptionForTeam(@Param("teamId") Long teamId);
}
@@ -0,0 +1,139 @@
package stirling.software.saas.billing.service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.config.SupabaseConfigurationProperties;
/**
* Reports per-tenant overage to Stripe Billing Meters via the Supabase {@code meter-usage} Edge
* Function. Only credits consumed above the free tier flow through {@link #reportUsageToStripe}.
*/
@Slf4j
@Service
@Profile("saas")
public class StripeUsageReportingService {
private final ObjectMapper objectMapper = new ObjectMapper();
private final SupabaseConfigurationProperties supabaseConfig;
@Value("${supabase.url:}")
private String supabaseUrl;
private final HttpClient httpClient =
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
public StripeUsageReportingService(SupabaseConfigurationProperties supabaseConfig) {
this.supabaseConfig = supabaseConfig;
}
/**
* Reports a usage overage for a tenant. Returns {@code true} on a 200 response from Supabase,
* {@code false} on any non-success outcome (including missing config). Caller should retry with
* the same {@code idempotencyKey} on transient failures.
*/
public boolean reportUsageToStripe(
String supabaseId, int overageCredits, String idempotencyKey) {
if (overageCredits <= 0) {
log.warn(
"[USAGE-BILLING] non-positive overage {} for user {}",
overageCredits,
supabaseId);
return false;
}
if (supabaseUrl == null || supabaseUrl.isEmpty()) {
log.error(
"[USAGE-BILLING] supabase.url not configured; cannot report usage. Set SUPABASE_URL.");
return false;
}
if (!supabaseConfig.isEdgeFunctionConfigured()) {
log.error(
"[USAGE-BILLING] Supabase edge function not configured (URL + secret required); cannot report usage.");
return false;
}
try {
String meterUsageUrl = supabaseUrl + "/functions/v1/meter-usage";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("user_id", supabaseId);
requestBody.put("credits", overageCredits);
requestBody.put("idempotency_key", idempotencyKey);
String requestJson = objectMapper.writeValueAsString(requestBody);
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(meterUsageUrl))
.header("Content-Type", "application/json")
.header(
"Authorization",
"Bearer " + supabaseConfig.getEdgeFunctionSecret())
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.timeout(Duration.ofSeconds(30))
.build();
HttpResponse<String> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
log.info(
"[USAGE-BILLING] reported {} overage credits for user {}",
overageCredits,
supabaseId);
return true;
}
log.error(
"[USAGE-BILLING] failed to report usage HTTP {}: {}",
response.statusCode(),
response.body());
return false;
} catch (java.io.IOException e) {
log.error(
"[USAGE-BILLING] network error reporting usage for {}: {}",
supabaseId,
e.getMessage(),
e);
return false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error(
"[USAGE-BILLING] interrupted reporting usage for {}: {}",
supabaseId,
e.getMessage());
return false;
} catch (Exception e) {
log.error(
"[USAGE-BILLING] unexpected error reporting usage for {}: {}",
supabaseId,
e.getMessage(),
e);
return false;
}
}
/**
* Idempotency key derived from the user + amount + operation. Stable across retries of the same
* logical operation. Caller supplies a stable {@code operationId} (e.g. the request UUID
* captured at the start of the credit-consume path) so a retry produces the same key.
*/
public String generateIdempotencyKey(
String supabaseId, int overageCredits, String operationId) {
return String.format("usage_%s_%d_%s", supabaseId, overageCredits, operationId);
}
}
@@ -0,0 +1,29 @@
package stirling.software.saas.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
import stirling.software.saas.interceptor.UnifiedCreditInterceptor;
@Configuration
@Profile("saas")
@RequiredArgsConstructor
public class CreditInterceptorConfig implements WebMvcConfigurer {
private final UnifiedCreditInterceptor unifiedCreditInterceptor;
private final CreditsProperties creditsProperties;
@Override
public void addInterceptors(InterceptorRegistry registry) {
if (creditsProperties.isEnabled()) {
registry.addInterceptor(unifiedCreditInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns(
"/api/v1/credits/**", "/api/v1/config/**", "/api/v1/info/**");
}
}
}
@@ -0,0 +1,75 @@
package stirling.software.saas.config;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.Data;
@Data
@Component
@Profile("saas")
@ConfigurationProperties(prefix = "credits")
public class CreditsProperties {
/** Whether the credits system is enabled */
private boolean enabled = true;
/** Credit allocations per billing cycle (monthly) */
private CycleAllocations cycle = new CycleAllocations();
/** Reset configuration */
private Reset reset = new Reset();
/** Error tracking configuration */
private Errors errors = new Errors();
/** Cache configuration */
private Cache cache = new Cache();
@Data
public static class CycleAllocations {
/** Whether admin role has unlimited credits */
private boolean adminUnlimited = true;
/** Credit allocations per billing cycle (monthly) per role */
private Map<String, Integer> allocations =
Map.of(
"ROLE_ADMIN", 1000,
"ROLE_PRO_USER", 500,
"ROLE_USER", 50,
"ROLE_LIMITED_API_USER", 10,
"ROLE_EXTRA_LIMITED_API_USER", 20,
"ROLE_WEB_ONLY_USER", 0,
"ROLE_DEMO_USER", 100);
}
@Data
public static class Reset {
/** Cron expression for monthly reset (default: 1st of month 02:00 UTC) */
private String cron = "0 0 2 1 * *";
/** Time zone for the reset schedule */
private String zone = "UTC";
}
@Data
public static class Errors {
/** How long error counts are tracked (in minutes) */
private int ttlMinutes = 60;
/** Number of free processing errors before charging */
private int freeProcessingErrors = 2;
}
@Data
public static class Cache {
/** Enable local Caffeine cache for error counts */
private boolean localEnabled = true;
/** Enable Redis cache for multi-instance deployments */
private boolean redisEnabled = false;
}
}
@@ -0,0 +1,100 @@
package stirling.software.saas.config;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.jdbc.DatabaseDriver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
/** SaaS-profile Postgres Hikari DataSource. {@code @Primary} so it shadows the OSS H2 default. */
@Slf4j
@Configuration
@Profile("saas")
public class SaasDataSourceConfig {
@Value("${spring.datasource.url:}")
private String url;
@Value("${spring.datasource.username:postgres}")
private String username;
@Value("${spring.datasource.password:}")
private String password;
@Value("${spring.datasource.hikari.maximum-pool-size:20}")
private int maximumPoolSize;
@Value("${spring.datasource.hikari.minimum-idle:5}")
private int minimumIdle;
@Value("${spring.datasource.hikari.idle-timeout:600000}")
private long idleTimeout;
@Value("${spring.datasource.hikari.max-lifetime:1800000}")
private long maxLifetime;
@Value("${spring.datasource.hikari.keepalive-time:300000}")
private long keepaliveTime;
@Value("${spring.datasource.hikari.data-source-properties.ApplicationName:StirlingPDF-SaaS}")
private String applicationName;
// search_path so native SQL hits stirling_pdf, not the postgres-default public.
@Value(
"${spring.datasource.hikari.connection-init-sql:SET search_path TO stirling_pdf, auth, public}")
private String connectionInitSql;
@Bean
@Primary
@Qualifier("dataSource")
public DataSource saasDataSource() {
if (url == null || url.isBlank()) {
throw new IllegalStateException(
"spring.datasource.url is required when the saas profile is active. "
+ "Set it via application-{profile}.properties (e.g. application-dev.properties) "
+ "or via the SPRING_DATASOURCE_URL env var.");
}
HikariConfig config = new HikariConfig();
config.setJdbcUrl(addApplicationName(url, applicationName));
config.setUsername(username);
config.setPassword(password);
config.setDriverClassName(DatabaseDriver.POSTGRESQL.getDriverClassName());
config.setMaximumPoolSize(maximumPoolSize);
config.setMinimumIdle(minimumIdle);
config.setIdleTimeout(idleTimeout);
config.setMaxLifetime(maxLifetime);
config.setKeepaliveTime(keepaliveTime);
if (connectionInitSql != null && !connectionInitSql.isBlank()) {
config.setConnectionInitSql(connectionInitSql);
}
log.info(
"Saas DataSource configured (ApplicationName: '{}', max pool: {}, min idle: {}, search_path init: '{}')",
applicationName,
maximumPoolSize,
minimumIdle,
connectionInitSql);
return new HikariDataSource(config);
}
private static String addApplicationName(String jdbcUrl, String appName) {
if (jdbcUrl == null
|| appName == null
|| jdbcUrl.toLowerCase().contains("applicationname=")) {
return jdbcUrl;
}
String separator = jdbcUrl.contains("?") ? "&" : "?";
return jdbcUrl + separator + "ApplicationName=" + appName;
}
}
@@ -0,0 +1,22 @@
package stirling.software.saas.config;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/** Registers the {@code :saas} module's entities and repositories with Spring Data JPA. */
@Configuration
@Profile("saas")
@EnableJpaRepositories(
basePackages = {
"stirling.software.saas.repository",
"stirling.software.saas.billing.repository",
"stirling.software.saas.ai.repository"
})
@EntityScan({
"stirling.software.saas.model",
"stirling.software.saas.billing.model",
"stirling.software.saas.ai.model"
})
public class SaasJpaConfig {}
@@ -0,0 +1,26 @@
package stirling.software.saas.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
/** Saas mode is unconditionally ENTERPRISE (every tenant is a paying Stripe customer). */
@Configuration
@Profile("saas")
public class SaasLicenseOverride {
@Bean(name = "runningProOrHigher")
public boolean runningProOrHigherSaas() {
return true;
}
@Bean(name = "license")
public String licenseTypeSaas() {
return "ENTERPRISE";
}
@Bean(name = "runningEE")
public boolean runningEnterpriseSaas() {
return true;
}
}
@@ -0,0 +1,23 @@
package stirling.software.saas.config;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
/** {@link RestTemplate} for talking to Supabase Edge Functions, with bounded timeouts. */
@Configuration
@Profile("saas")
public class SaasRestTemplateConfig {
@Bean
public RestTemplate saasRestTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout((int) Duration.ofSeconds(10).toMillis());
factory.setReadTimeout((int) Duration.ofSeconds(30).toMillis());
return new RestTemplate(factory);
}
}
@@ -0,0 +1,45 @@
package stirling.software.saas.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
/** Supabase configuration ({@code app.supabase.*}) for saas mode. */
@Slf4j
@Data
@Component
@Profile("saas")
@ConfigurationProperties(prefix = "app.supabase")
public class SupabaseConfigurationProperties {
/** Supabase project issuer URL, e.g. {@code https://abcd1234.supabase.co/auth/v1}. */
private String issuer;
/** Optional expected JWT audience. Empty string disables aud validation. */
private String expectedAud;
/** Clock skew tolerance for JWT exp validation. */
private long clockSkewSeconds = 120L;
/** Edge Function URL for server→Supabase admin calls (optional). */
private String edgeFunctionUrl;
/** Edge Function shared secret for authenticated server→Supabase admin calls. */
private String edgeFunctionSecret;
/** True when JWT verification can run (issuer URL set so JWKS can be fetched). */
public boolean isJwtConfigured() {
return issuer != null && !issuer.isBlank();
}
/** True when server→Supabase Edge Function calls can run (URL + shared secret set). */
public boolean isEdgeFunctionConfigured() {
return edgeFunctionUrl != null
&& !edgeFunctionUrl.isBlank()
&& edgeFunctionSecret != null
&& !edgeFunctionSecret.isBlank();
}
}
@@ -0,0 +1,312 @@
package stirling.software.saas.controller;
import java.util.Map;
import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.CreditService.CreditSummary;
import stirling.software.saas.util.LogRedactionUtils;
@RestController
@Profile("saas")
@RequestMapping("/api/v1/credits")
@Tag(name = "Credit Management", description = "Endpoints for managing user API credits")
@RequiredArgsConstructor
@Slf4j
public class CreditController {
private final CreditService creditService;
@GetMapping
@Operation(
summary = "Get user credit information",
description =
"Retrieve current credit balance and usage statistics for the authenticated user")
@ApiResponse(
responseCode = "200",
description = "Credit information retrieved successfully",
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
public ResponseEntity<CreditSummary> getUserCredits(Authentication authentication) {
return ResponseEntity.ok(getCreditSummaryForAuthentication(authentication));
}
@PostMapping("/purchase")
@Hidden
@Operation(
summary = "Purchase additional credits",
description = "Add bought credits to user account (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Credits purchased successfully")
public ResponseEntity<Map<String, Object>> purchaseCredits(
@RequestParam("username") String username, @RequestParam("credits") int credits) {
if (credits <= 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits must be positive"));
}
try {
creditService.addBoughtCredits(username, credits);
log.info("Admin added {} credits to user: {}", credits, username);
return ResponseEntity.ok(Map.of("success", true, "creditsAdded", credits));
} catch (IllegalArgumentException e) {
log.warn("purchaseCredits rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@PostMapping("/purchase-by-supabase-id")
@Hidden
@Operation(
summary = "Purchase additional credits by Supabase ID",
description = "Add bought credits to user account using Supabase ID (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Credits purchased successfully")
public ResponseEntity<Map<String, Object>> purchaseCreditsBySupabaseId(
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
if (credits <= 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits must be positive"));
}
try {
creditService.addBoughtCreditsBySupabaseId(supabaseId, credits);
log.info(
"Admin added {} credits to user with Supabase ID: {}",
credits,
LogRedactionUtils.redactSupabaseId(supabaseId));
return ResponseEntity.ok(Map.of("success", true, "creditsAdded", credits));
} catch (IllegalArgumentException e) {
log.warn("purchaseCreditsBySupabaseId rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@GetMapping("/user/{username}")
@Hidden
@Operation(
summary = "Get credit information for specific user",
description = "Retrieve credit information for a specific user (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(
responseCode = "200",
description = "User credit information retrieved successfully",
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
public ResponseEntity<CreditSummary> getUserCreditsAdmin(
@PathVariable("username") String username) {
CreditSummary summary = creditService.getCreditSummary(username);
return ResponseEntity.ok(summary);
}
@GetMapping("/user-by-supabase-id/{supabaseId}")
@Hidden
@Operation(
summary = "Get credit information for specific user by Supabase ID",
description =
"Retrieve credit information for a specific user using Supabase ID (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(
responseCode = "200",
description = "User credit information retrieved successfully",
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
public ResponseEntity<CreditSummary> getUserCreditsAdminBySupabaseId(
@PathVariable("supabaseId") String supabaseId) {
CreditSummary summary = creditService.getCreditSummaryBySupabaseId(supabaseId);
return ResponseEntity.ok(summary);
}
@PostMapping("/reset-cycle")
@Hidden
@Operation(
summary = "Reset cycle credits for all users",
description = "Manually trigger cycle credit reset for all users (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Cycle credits reset successfully")
public ResponseEntity<String> resetCycleCredits() {
creditService.resetCycleCreditsForAllUsers();
log.info("Manual cycle credit reset triggered by admin");
return ResponseEntity.ok("Cycle credits reset successfully for all users");
}
@PostMapping("/set-bought-credits")
@Hidden
@Operation(
summary = "Set user's bought credits to a specific amount",
description =
"Hard set the bought credits balance for a specific user to an exact amount (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Bought credits set successfully")
public ResponseEntity<Map<String, Object>> setBoughtCredits(
@RequestParam("username") String username, @RequestParam("credits") int credits) {
if (credits < 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
}
try {
creditService.setBoughtCredits(username, credits);
log.info("Admin set bought credits to {} for user: {}", credits, username);
return ResponseEntity.ok(Map.of("success", true, "boughtCredits", credits));
} catch (IllegalArgumentException e) {
log.warn("setBoughtCredits rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@PostMapping("/set-bought-credits-by-supabase-id")
@Hidden
@Operation(
summary = "Set user's bought credits to a specific amount by Supabase ID",
description =
"Hard set the bought credits balance for a specific user using Supabase ID to an exact amount (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Bought credits set successfully")
public ResponseEntity<Map<String, Object>> setBoughtCreditsBySupabaseId(
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
if (credits < 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
}
try {
creditService.setBoughtCreditsBySupabaseId(supabaseId, credits);
log.info(
"Admin set bought credits to {} for user with Supabase ID: {}",
credits,
LogRedactionUtils.redactSupabaseId(supabaseId));
return ResponseEntity.ok(Map.of("success", true, "boughtCredits", credits));
} catch (IllegalArgumentException e) {
log.warn("setBoughtCreditsBySupabaseId rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@PostMapping("/set-cycle-credits")
@Hidden
@Operation(
summary = "Set user's cycle credits remaining to a specific amount",
description =
"Hard set the cycle credits remaining balance for a specific user to an exact amount (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Cycle credits set successfully")
public ResponseEntity<Map<String, Object>> setCycleCredits(
@RequestParam("username") String username, @RequestParam("credits") int credits) {
if (credits < 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
}
try {
creditService.setCycleCredits(username, credits);
log.info("Admin set cycle credits to {} for user: {}", credits, username);
return ResponseEntity.ok(Map.of("success", true, "cycleCredits", credits));
} catch (IllegalArgumentException e) {
log.warn("setCycleCredits rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@PostMapping("/set-cycle-credits-by-supabase-id")
@Hidden
@Operation(
summary = "Set user's cycle credits remaining to a specific amount by Supabase ID",
description =
"Hard set the cycle credits remaining balance for a specific user using Supabase ID to an exact amount (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Cycle credits set successfully")
public ResponseEntity<Map<String, Object>> setCycleCreditsBySupabaseId(
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
if (credits < 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
}
try {
creditService.setCycleCreditsBySupabaseId(supabaseId, credits);
log.info(
"Admin set cycle credits to {} for user with Supabase ID: {}",
credits,
LogRedactionUtils.redactSupabaseId(supabaseId));
return ResponseEntity.ok(Map.of("success", true, "cycleCredits", credits));
} catch (IllegalArgumentException e) {
log.warn("setCycleCreditsBySupabaseId rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@GetMapping("/usage")
@Operation(
summary = "Get credit usage summary",
description = "Get overview of credit usage (for authenticated user or admin view)")
public ResponseEntity<UsageSummary> getCreditUsage(Authentication authentication) {
CreditSummary summary = getCreditSummaryForAuthentication(authentication);
// For unlimited users, don't show meaningless huge usage numbers
int cycleCreditsUsed =
summary.unlimited
? 0
: (summary.cycleCreditsAllocated - summary.cycleCreditsRemaining);
UsageSummary usage =
new UsageSummary(
cycleCreditsUsed,
summary.totalBoughtCredits - summary.boughtCreditsRemaining,
summary.totalAvailableCredits,
summary.unlimited);
return ResponseEntity.ok(usage);
}
/** Resolves the current authentication to a credit summary, handling JWT and API-key auth. */
private CreditSummary getCreditSummaryForAuthentication(Authentication authentication) {
if (authentication instanceof EnhancedJwtAuthenticationToken enhancedJwt) {
return creditService.getCreditSummaryBySupabaseId(enhancedJwt.getSupabaseId());
}
if (authentication instanceof ApiKeyAuthenticationToken apiKeyToken) {
String apiKey = (String) apiKeyToken.getCredentials();
// Principal is the resolved User entity (per SupabaseAuthenticationFilter). Prefer the
// linked Supabase ID; fall back to API-key-keyed credits if there's no supabase link
// or no User row (e.g. legacy API-key-only deployments).
if (apiKeyToken.getPrincipal() instanceof User user && user.getSupabaseId() != null) {
return creditService.getCreditSummaryBySupabaseId(user.getSupabaseId().toString());
}
return creditService.getCreditSummaryByApiKey(apiKey);
}
return creditService.getCreditSummaryBySupabaseId(authentication.getName());
}
public static class UsageSummary {
public final int cycleCreditsUsed;
public final int boughtCreditsUsed;
public final int creditsRemaining;
public final boolean unlimited;
public UsageSummary(
int cycleCreditsUsed,
int boughtCreditsUsed,
int creditsRemaining,
boolean unlimited) {
this.cycleCreditsUsed = cycleCreditsUsed;
this.boughtCreditsUsed = boughtCreditsUsed;
this.creditsRemaining = creditsRemaining;
this.unlimited = unlimited;
}
}
}
@@ -0,0 +1,700 @@
package stirling.software.saas.controller;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
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.transaction.interceptor.TransactionAspectSupport;
import org.springframework.web.bind.annotation.*;
import jakarta.transaction.Transactional;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.TeamApi;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.model.TeamInvitation;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.repository.TeamInvitationRepository;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.security.TeamSecurityExpressions;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.SaasTeamService;
/** SaaS-only team endpoints: invitations, personal teams, billing-aware lookups. */
@TeamApi
@Profile("saas")
@Slf4j
@RequiredArgsConstructor
public class SaasTeamController {
private final TeamRepository teamRepository;
private final UserRepository userRepository;
private final TeamService teamService;
private final SaasTeamService saasTeamService;
private final SaasTeamExtensionService saasTeamExtensionService;
private final TeamMembershipRepository membershipRepository;
private final TeamInvitationRepository invitationRepository;
private final UserService userService;
private final TeamSecurityExpressions teamSecurityExpressions;
// ========== NEW TEAM INVITATION ENDPOINTS ==========
/** Invite user to team (team leader only) */
@PostMapping("/invite")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> inviteUser(@RequestBody InviteUserRequest request) {
try {
User currentUser = getCurrentUser();
// Verify user is team leader before proceeding
// Note: Cannot use @PreAuthorize with #request.teamId as @RequestBody is not yet
// deserialized at annotation evaluation time
if (!teamSecurityExpressions.isTeamLeader(request.teamId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", "Only team leaders can invite members"));
}
TeamInvitation invitation =
saasTeamService.inviteUserToTeam(request.teamId, request.email, currentUser);
return ResponseEntity.ok(toInvitationDTO(invitation));
} catch (SecurityException | IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Error inviting user", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to send invitation"));
}
}
/** Accept team invitation */
@PostMapping("/invitations/{token}/accept")
@PreAuthorize("isAuthenticated()")
@Transactional
public ResponseEntity<?> acceptInvitation(@PathVariable String token) {
try {
User currentUser = getCurrentUser();
saasTeamService.acceptInvitationAndGrantRole(token, currentUser);
return ResponseEntity.ok(Map.of("message", "Invitation accepted", "success", true));
} catch (SecurityException | IllegalArgumentException | IllegalStateException e) {
// Caller-fixable failures (already-accepted, expired, email mismatch, etc.).
// Mark the transaction for rollback so anything the service did is reversed even
// though we don't propagate the exception out of the @Transactional method.
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Error accepting invitation", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to accept invitation"));
}
}
/** Reject team invitation */
@PostMapping("/invitations/{token}/reject")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> rejectInvitation(@PathVariable String token) {
try {
User currentUser = getCurrentUser();
TeamInvitation invitation =
invitationRepository
.findByInvitationToken(token)
.orElseThrow(
() -> new IllegalArgumentException("Invitation not found"));
// Security check: verify invitation belongs to current user
if (!invitation.getInviteeEmail().equalsIgnoreCase(currentUser.getEmail())
&& !invitation.getInviteeEmail().equalsIgnoreCase(currentUser.getUsername())) {
throw new SecurityException(
"You cannot reject an invitation that was not sent to you");
}
// Only allow rejecting pending invitations
if (invitation.getStatus()
!= stirling.software.common.model.enumeration.InvitationStatus.PENDING) {
throw new IllegalStateException("Can only reject pending invitations");
}
invitation.setStatus(
stirling.software.common.model.enumeration.InvitationStatus.REJECTED);
invitationRepository.save(invitation);
return ResponseEntity.ok(Map.of("message", "Invitation rejected"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", e.getMessage()));
} catch (SecurityException | IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Error rejecting invitation", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to reject invitation"));
}
}
/** Cancel team invitation (team leader only) */
@DeleteMapping("/invitations/{invitationId}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> cancelInvitation(@PathVariable Long invitationId) {
try {
User currentUser = getCurrentUser();
TeamInvitation invitation =
invitationRepository
.findById(invitationId)
.orElseThrow(
() -> new IllegalArgumentException("Invitation not found"));
// Security check: verify current user is team leader
Team team = invitation.getTeam();
TeamMembership membership =
membershipRepository
.findByTeamIdAndUserId(team.getId(), currentUser.getId())
.orElseThrow(
() ->
new SecurityException(
"You are not a member of this team"));
if (!membership.isLeader()) {
throw new SecurityException("Only team leaders can cancel invitations");
}
// Only allow canceling pending invitations
if (invitation.getStatus()
!= stirling.software.common.model.enumeration.InvitationStatus.PENDING) {
throw new IllegalStateException("Can only cancel pending invitations");
}
invitation.setStatus(
stirling.software.common.model.enumeration.InvitationStatus.CANCELLED);
invitationRepository.save(invitation);
return ResponseEntity.ok(Map.of("message", "Invitation cancelled"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", e.getMessage()));
} catch (SecurityException | IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Error cancelling invitation", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to cancel invitation"));
}
}
/** Get pending invitations for current user */
@GetMapping("/invitations/pending")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> getPendingInvitations() {
try {
User currentUser = getCurrentUser();
List<TeamInvitation> invitations =
invitationRepository.findPendingInvitationsByEmail(
currentUser.getEmail(), LocalDateTime.now());
List<InvitationDTO> dtos =
invitations.stream().map(this::toInvitationDTO).collect(Collectors.toList());
return ResponseEntity.ok(dtos);
} catch (Exception e) {
log.error("Error fetching pending invitations", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to fetch invitations"));
}
}
/** Get all teams for current user */
@GetMapping("/my")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> getMyTeams() {
try {
User currentUser = getCurrentUser();
List<TeamMembership> memberships =
membershipRepository.findByUserId(currentUser.getId());
// Migrate users from old Default team system to personal teams
boolean needsPersonalTeam = false;
if (memberships.isEmpty()) {
// Case 1: User has no team memberships at all
needsPersonalTeam = true;
} else {
// Case 2: Check if user is only on Default/Internal team (legacy users)
boolean hasPersonalTeam =
memberships.stream()
.anyMatch(m -> saasTeamExtensionService.isPersonal(m.getTeam()));
boolean onlyOnSystemTeams =
memberships.stream()
.allMatch(
m -> {
String teamName = m.getTeam().getName();
return "Default".equals(teamName)
|| "Internal".equals(teamName);
});
if (!hasPersonalTeam && onlyOnSystemTeams) {
needsPersonalTeam = true;
}
}
if (needsPersonalTeam) {
try {
saasTeamService.createPersonalTeam(currentUser);
// Fetch memberships again after creating personal team
memberships = membershipRepository.findByUserId(currentUser.getId());
log.info("Created personal team for user {}", currentUser.getId());
} catch (Exception e) {
log.error(
"Failed to create personal team for user {}: {}",
currentUser.getId(),
e.getMessage(),
e);
// Rethrow to let outer catch block return proper error response
throw new IllegalStateException(
"Failed to initialize personal team for user", e);
}
}
List<TeamDetailsDTO> dtos =
memberships.stream()
.map(
m ->
toTeamDetailsDTO(
m.getTeam(),
m.getRole()
== stirling.software.common.model
.enumeration.TeamRole.LEADER))
.collect(Collectors.toList());
log.info("[TEAM-FETCH] Returning {} teams to client", dtos.size());
return ResponseEntity.ok(dtos);
} catch (Exception e) {
log.error("[TEAM-FETCH] Error fetching user teams: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to fetch teams"));
}
}
/** Get team members (team members only) */
@GetMapping("/{teamId}/members")
@PreAuthorize("@teamSecurity.isTeamMember(#teamId)")
public ResponseEntity<?> getTeamMembers(@PathVariable Long teamId) {
try {
List<TeamMembership> memberships = membershipRepository.findByTeamId(teamId);
List<TeamMemberDTO> dtos =
memberships.stream().map(this::toTeamMemberDTO).collect(Collectors.toList());
return ResponseEntity.ok(dtos);
} catch (Exception e) {
log.error("Error fetching team members", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to fetch team members"));
}
}
/** Get team invitations (team leaders only) */
@GetMapping("/{teamId}/invitations")
@PreAuthorize("@teamSecurity.isTeamLeader(#teamId)")
public ResponseEntity<?> getTeamInvitations(@PathVariable Long teamId) {
try {
List<TeamInvitation> invitations = invitationRepository.findByTeamId(teamId);
List<InvitationDTO> dtos =
invitations.stream().map(this::toInvitationDTO).collect(Collectors.toList());
return ResponseEntity.ok(dtos);
} catch (Exception e) {
log.error("Error fetching team invitations", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to fetch invitations"));
}
}
/** Remove team member (team leader only) */
@DeleteMapping("/{teamId}/members/{memberId}")
@PreAuthorize("@teamSecurity.isTeamLeader(#teamId)")
@Transactional
public ResponseEntity<?> removeTeamMember(
@PathVariable Long teamId, @PathVariable Long memberId) {
try {
User currentUser = getCurrentUser();
// Get the user being removed before removing them
User userToRemove =
userRepository
.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("Member not found"));
Team oldTeam = teamRepository.findById(teamId).orElseThrow();
// Remove the user from the team
saasTeamService.removeTeamMember(teamId, memberId, currentUser);
// Revoke PRO role when removing from any non-personal team
String currentRole = userToRemove.getRolesAsString();
if (stirling.software.common.model.enumeration.Role.PRO_USER
.getRoleId()
.equals(currentRole)) {
log.info(
"Revoking ROLE_PRO_USER from user {} removed from team {}",
userToRemove.getUsername(),
oldTeam.getName());
userService.changeRole(
userToRemove,
stirling.software.common.model.enumeration.Role.USER.getRoleId());
}
return ResponseEntity.ok(Map.of("message", "Member removed successfully"));
} catch (SecurityException | IllegalArgumentException | IllegalStateException e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Error removing team member", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to remove member"));
}
}
/** Leave team (self-removal) */
@PostMapping("/{teamId}/leave")
@PreAuthorize("@teamSecurity.isTeamMember(#teamId)")
@Transactional
public ResponseEntity<?> leaveTeam(@PathVariable Long teamId) {
try {
User currentUser = getCurrentUser();
// Get the team before leaving
Team oldTeam = teamRepository.findById(teamId).orElseThrow();
// Leave the team
saasTeamService.leaveTeam(teamId, currentUser);
// Revoke PRO role when leaving any non-personal team
String currentRole = currentUser.getRolesAsString();
if (stirling.software.common.model.enumeration.Role.PRO_USER
.getRoleId()
.equals(currentRole)) {
log.info(
"Revoking ROLE_PRO_USER from user {} who left team {}",
currentUser.getUsername(),
oldTeam.getName());
userService.changeRole(
currentUser,
stirling.software.common.model.enumeration.Role.USER.getRoleId());
}
return ResponseEntity.ok(Map.of("message", "Left team successfully"));
} catch (IllegalArgumentException | IllegalStateException e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Error leaving team", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to leave team"));
}
}
/** Rename team (team leader only) */
@PostMapping("/{teamId}/rename")
@PreAuthorize("@teamSecurity.isTeamLeader(#teamId)")
public ResponseEntity<?> renameTeamByLeader(
@PathVariable Long teamId, @RequestBody RenameTeamRequest request) {
try {
if (request.newName == null || request.newName.trim().isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Team name cannot be empty"));
}
Team team =
teamRepository
.findById(teamId)
.orElseThrow(() -> new IllegalArgumentException("Team not found"));
// Prevent renaming personal teams
if (saasTeamExtensionService.isPersonal(team)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot rename personal team"));
}
// Prevent renaming the Internal team
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot rename Internal team"));
}
team.setName(request.newName.trim());
teamRepository.save(team);
log.info(
"Team {} renamed to {} by leader {}",
teamId,
request.newName,
getCurrentUser().getUsername());
return ResponseEntity.ok(
Map.of("message", "Team renamed successfully", "newName", team.getName()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Error renaming team", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to rename team"));
}
}
// ========== HELPER METHODS ==========
private User getCurrentUser() {
String username = userService.getCurrentUsername();
return userService
.findByUsername(username)
.orElseThrow(() -> new SecurityException("User not found: " + username));
}
private TeamMemberDTO toTeamMemberDTO(TeamMembership membership) {
User user = membership.getUser();
return new TeamMemberDTO(
user.getId(),
user.getUsername(),
user.getEmail(),
membership.getRole().name(),
membership.getAcceptedAt());
}
private TeamDetailsDTO toTeamDetailsDTO(Team team, boolean isLeader) {
long memberCount = membershipRepository.countByTeamId(team.getId());
int maxSeats = saasTeamExtensionService.getMaxSeats(team);
return new TeamDetailsDTO(
team.getId(),
team.getName(),
saasTeamExtensionService.getTeamType(team),
saasTeamExtensionService.isPersonal(team),
(int) memberCount,
// seatCount and maxSeats now share the same backing field on the extension.
maxSeats,
saasTeamExtensionService.getSeatsUsed(team),
maxSeats,
isLeader);
}
private InvitationDTO toInvitationDTO(TeamInvitation invitation) {
return new InvitationDTO(
invitation.getInvitationId(),
invitation.getTeam().getName(),
invitation.getInviter().getEmail(),
invitation.getInviteeEmail(),
invitation.getInvitationToken(),
invitation.getStatus().name(),
invitation.getExpiresAt());
}
// ========== DTOs ==========
@Data
public static class InviteUserRequest {
private Long teamId;
private String email;
}
@Data
public static class TeamMemberDTO {
private final Long id;
private final String username;
private final String email;
private final String role;
private final LocalDateTime joinedAt;
}
@Data
public static class TeamDetailsDTO {
private final Long teamId;
private final String name;
private final String teamType;
private final Boolean isPersonal;
private final Integer memberCount;
private final Integer seatCount;
private final Integer seatsUsed;
private final Integer maxSeats;
private final Boolean isLeader;
}
@Data
public static class InvitationDTO {
private final Long invitationId;
private final String teamName;
private final String inviterEmail;
private final String inviteeEmail;
private final String invitationToken;
private final String status;
private final LocalDateTime expiresAt;
}
// ========== BILLING/SUPABASE INTEGRATION ENDPOINTS ==========
/**
* Update team seat allocation (called by Supabase webhooks)
*
* <p>Requires ADMIN_API_KEY authentication
*/
@PostMapping("/{teamId}/seats")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> updateTeamSeats(
@PathVariable Long teamId, @RequestBody UpdateSeatsRequest request) {
try {
saasTeamService.updateTeamSeats(teamId, request.maxSeats);
Team team = teamRepository.findById(teamId).orElseThrow();
int maxSeats = saasTeamExtensionService.getMaxSeats(team);
int seatsUsed = saasTeamExtensionService.getSeatsUsed(team);
return ResponseEntity.ok(
Map.of(
"success",
true,
"teamId",
team.getId(),
"maxSeats",
maxSeats,
"seatsUsed",
seatsUsed,
"availableSeats",
maxSeats - seatsUsed));
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Error updating team seats", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to update team seats"));
}
}
/**
* Get user's primary team by Supabase UUID (called by Supabase when creating subscriptions)
*
* <p>Requires ADMIN_API_KEY or service role authentication
*
* <p>Accepts Supabase auth user ID (UUID) and returns the user's primary team information.
*/
@GetMapping("/user/supabase/{supabaseUserId}/primary")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> getUserPrimaryTeamBySupabaseId(@PathVariable String supabaseUserId) {
try {
java.util.UUID uuid = java.util.UUID.fromString(supabaseUserId);
User user =
userRepository
.findBySupabaseId(uuid)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
Team primaryTeam = user.getTeam();
if (primaryTeam == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User has no primary team"));
}
return ResponseEntity.ok(
Map.of(
"teamId", primaryTeam.getId(),
"userId", user.getId(),
"supabaseUserId", user.getSupabaseId().toString(),
"isPersonal", saasTeamExtensionService.isPersonal(primaryTeam),
"maxSeats", saasTeamExtensionService.getMaxSeats(primaryTeam)));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid UUID format or user not found"));
} catch (Exception e) {
log.error("Error fetching user primary team", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to fetch primary team"));
}
}
/**
* Get detailed team information (for billing dashboard)
*
* <p>Requires team membership or admin role
*/
@GetMapping("/{teamId}")
@PreAuthorize("@teamSecurity.isTeamMember(#teamId) or hasRole('ADMIN')")
public ResponseEntity<?> getTeamInfo(@PathVariable Long teamId) {
try {
Team team =
teamRepository
.findById(teamId)
.orElseThrow(() -> new IllegalArgumentException("Team not found"));
List<TeamMembership> memberships = membershipRepository.findByTeamId(teamId);
List<TeamMemberDTO> members =
memberships.stream().map(this::toTeamMemberDTO).collect(Collectors.toList());
// Check if current user is team leader
User currentUser = getCurrentUser();
boolean isLeader =
membershipRepository
.findByTeamIdAndUserId(teamId, currentUser.getId())
.map(
m ->
m.getRole()
== stirling.software.common.model.enumeration
.TeamRole.LEADER)
.orElse(false);
int maxSeats = saasTeamExtensionService.getMaxSeats(team);
int seatsUsed = saasTeamExtensionService.getSeatsUsed(team);
return ResponseEntity.ok(
Map.of(
"teamId",
team.getId(),
"name",
team.getName(),
"isPersonal",
saasTeamExtensionService.isPersonal(team),
"maxSeats",
maxSeats,
"seatsUsed",
seatsUsed,
"availableSeats",
maxSeats - seatsUsed,
"isLeader",
isLeader,
"members",
members));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Error fetching team info", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to fetch team info"));
}
}
// ========== REQUEST DTOs ==========
@Data
public static class UpdateSeatsRequest {
private Integer maxSeats;
private String reason;
}
@Data
public static class RenameTeamRequest {
private String newName;
}
}
@@ -0,0 +1,298 @@
package stirling.software.saas.controller;
import java.security.Principal;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.model.SupabaseUser;
import stirling.software.saas.service.SaasUserAccountService;
import stirling.software.saas.service.SupabaseUserService;
import stirling.software.saas.util.LogRedactionUtils;
/**
* Controller for handling user role upgrades/downgrades via webhooks. These endpoints are
* authenticated via X-API-KEY header with admin privileges. Configure external services (Stripe,
* etc.) to call these endpoints with your admin API key.
*/
@Hidden
@RestController
@Profile("saas")
@RequestMapping("/api/v1/user-role")
@Slf4j
@RequiredArgsConstructor
public class UserRoleWebhookController {
private final UserService userService;
private final SaasUserAccountService saasUserAccountService;
private final SupabaseUserService supabaseUserService;
/**
* Webhook endpoint to handle user upgrade to PRO plan. Called by Stripe when a subscription is
* successfully created or a payment succeeds. Requires ROLE_ADMIN via X-API-KEY authentication.
*
* @param supabaseId The Supabase user ID to upgrade
* @return ResponseEntity with appropriate status
*/
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/upgrade")
public ResponseEntity<String> handleUpgrade(@RequestParam("supabaseId") String supabaseId) {
log.info(
"Received upgrade request for Supabase ID: {}",
LogRedactionUtils.redactSupabaseId(supabaseId));
try {
boolean upgraded = saasUserAccountService.handleUpgrade(supabaseId);
if (upgraded) {
return ResponseEntity.ok("User upgraded to PRO successfully");
} else {
return ResponseEntity.ok("User is already PRO");
}
} catch (IllegalArgumentException e) {
log.warn("handleUpgrade rejected: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid request");
} catch (Exception e) {
log.error("Error processing upgrade webhook", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Error processing webhook");
}
}
/**
* Webhook endpoint to handle user downgrade from PRO plan. Called by Stripe when a subscription
* is canceled or expires. Requires ROLE_ADMIN via X-API-KEY authentication.
*
* @param supabaseId The Supabase user ID to downgrade
* @return ResponseEntity with appropriate status
*/
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/downgrade")
public ResponseEntity<String> handleDowngrade(@RequestParam("supabaseId") String supabaseId) {
log.info(
"Received downgrade request for Supabase ID: {}",
LogRedactionUtils.redactSupabaseId(supabaseId));
try {
boolean downgraded = saasUserAccountService.handleDowngrade(supabaseId);
if (downgraded) {
return ResponseEntity.ok("User downgraded to FREE successfully");
} else {
return ResponseEntity.ok("User is already on FREE tier");
}
} catch (IllegalArgumentException e) {
log.warn("handleDowngrade rejected: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid request");
} catch (Exception e) {
log.error("Error processing downgrade webhook", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Error processing webhook");
}
}
/**
* Webhook endpoint to enable metered billing (pay-what-you-use) for a user. Called by Stripe
* when a user subscribes to the metered billing plan. Can be combined with Pro subscription.
* Requires ROLE_ADMIN via X-API-KEY authentication.
*
* @param supabaseId The Supabase user ID
* @return ResponseEntity with appropriate status
*/
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/enable-metered-billing")
public ResponseEntity<String> enableMeteredBilling(
@RequestParam("supabaseId") String supabaseId) {
log.info(
"Received request to enable metered billing for Supabase ID: {}",
LogRedactionUtils.redactSupabaseId(supabaseId));
try {
boolean enabled = saasUserAccountService.enableMeteredBilling(supabaseId);
if (enabled) {
return ResponseEntity.ok("Metered billing enabled successfully");
} else {
return ResponseEntity.ok("User already has metered billing enabled");
}
} catch (IllegalArgumentException e) {
log.warn("enableMeteredBilling rejected: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid request");
} catch (Exception e) {
log.error("Error enabling metered billing", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Error processing webhook");
}
}
/**
* Webhook endpoint to disable metered billing for a user. Called by Stripe when the metered
* subscription is canceled. Requires ROLE_ADMIN via X-API-KEY authentication.
*
* @param supabaseId The Supabase user ID
* @return ResponseEntity with appropriate status
*/
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/disable-metered-billing")
public ResponseEntity<String> disableMeteredBilling(
@RequestParam("supabaseId") String supabaseId) {
log.info(
"Received request to disable metered billing for Supabase ID: {}",
LogRedactionUtils.redactSupabaseId(supabaseId));
try {
boolean disabled = saasUserAccountService.disableMeteredBilling(supabaseId);
if (disabled) {
return ResponseEntity.ok("Metered billing disabled successfully");
} else {
return ResponseEntity.ok("User does not have metered billing enabled");
}
} catch (IllegalArgumentException e) {
log.warn("disableMeteredBilling rejected: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid request");
} catch (Exception e) {
log.error("Error disabling metered billing", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Error processing webhook");
}
}
/**
* Synchronizes the current user's upgrade from anonymous to authenticated status. This endpoint
* is called after Supabase has successfully upgraded the user. It ensures the local database is
* synchronized with the Supabase auth state.
*
* <p>Only allows users to upgrade their own account; the user is determined from the
* SecurityContext. The email is derived from the SupabaseUser to prevent client tampering.
*
* @param authMethod the authentication method used (e.g., "email", "google", "github")
* @return ResponseEntity with success or error message
*/
@PreAuthorize("isAuthenticated()")
@PostMapping("/promptToAuthUser")
public ResponseEntity<Map<String, String>> promptToAuthUser(
@RequestParam(value = "authMethod", required = false) String authMethod,
Principal principal) {
try {
// Principal is guaranteed to be non-null due to @PreAuthorize
String currentUsername = principal.getName();
// Normalize and validate authMethod
String normalizedAuthMethod = normalizeAuthMethod(authMethod);
if (normalizedAuthMethod != null && !isValidAuthMethod(normalizedAuthMethod)) {
log.warn("Invalid auth method provided: {}", authMethod);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid authentication method"));
}
log.debug(
"User {} attempting to synchronize upgrade using method: {}",
currentUsername,
normalizedAuthMethod);
// Find the current user
User currentUser =
userService
.findByUsername(currentUsername)
.orElseThrow(
() ->
new IllegalStateException(
"Current user not found: " + currentUsername));
// Get the SupabaseUser linked to current user. consolidation stores the linkage as a
// plain UUID column on User (no JPA relationship), so we resolve via
// SupabaseUserService.
UUID supabaseId = currentUser.getSupabaseId();
if (supabaseId == null) {
log.error("Current user {} has no linked Supabase ID", currentUsername);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "No Supabase account linked to current user"));
}
SupabaseUser supabaseUser = supabaseUserService.getUser(supabaseId);
// Verify this is an anonymous user trying to upgrade
if (!AuthenticationType.ANONYMOUS
.name()
.equalsIgnoreCase(currentUser.getAuthenticationType())) {
log.warn("User {} is not anonymous, cannot upgrade", currentUsername);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Only anonymous users can be upgraded"));
}
// Derive the canonical email from SupabaseUser
String canonicalEmail = supabaseUser.getEmail();
if (canonicalEmail == null || canonicalEmail.isBlank()) {
// Fall back to current user's email if available
canonicalEmail = currentUser.getEmail();
if (canonicalEmail == null || canonicalEmail.isBlank()) {
log.error(
"No email found for user {} in Supabase or local DB", currentUsername);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "No email associated with user account"));
}
}
log.debug("Using canonical email {} for user upgrade", canonicalEmail);
User upgradedUser =
saasUserAccountService.synchronizeUserUpgrade(
supabaseUser, canonicalEmail, normalizedAuthMethod);
return ResponseEntity.ok(
Map.of(
"message",
"User upgrade synchronized successfully",
"userId",
upgradedUser.getId().toString(),
"email",
upgradedUser.getEmail() != null
? upgradedUser.getEmail()
: upgradedUser.getUsername()));
} catch (IllegalStateException e) {
log.error("User not found for upgrade: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found"));
} catch (Exception e) {
log.error("Error synchronizing user upgrade", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to synchronize user upgrade"));
}
}
/** Normalizes the auth method string to lowercase and trims whitespace. */
private String normalizeAuthMethod(String authMethod) {
return authMethod == null ? null : authMethod.trim().toLowerCase(Locale.ROOT);
}
/** Validates that the auth method is one of the allowed values. */
private boolean isValidAuthMethod(String authMethod) {
if (authMethod == null) {
return true; // null is valid (will default to email/web auth)
}
// Define allowed auth methods
return Set.of("email", "oauth", "google", "github", "apple", "azure", "linkedin_oidc")
.contains(authMethod);
}
}
@@ -0,0 +1,305 @@
package stirling.software.saas.interceptor;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.servlet.http.HttpServletRequest;
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.User;
import stirling.software.saas.model.CreditConsumptionResult;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.ErrorTrackingService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
/**
* Scoped to controllers annotated with {@link AutoJobPostMapping} so it doesn't hijack the global
* exception flow.
*/
@RestControllerAdvice(annotations = AutoJobPostMapping.class)
@Profile("saas")
@Slf4j
@Order(1)
public class CreditErrorAdvice {
private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
private static final String ATTR_APIKEY = "CREDIT_API_KEY";
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
private final CreditService creditService;
private final TeamCreditService teamCreditService;
private final UserRepository userRepository;
private final ErrorTrackingService errorTrackingService;
private final SaasTeamExtensionService saasTeamExtensionService;
private final CreditHeaderUtils creditHeaderUtils;
private final Counter creditsConsumedCounter;
// Inlined: Stirling's parent build uses Jackson 3 (tools.jackson), no Jackson 2 ObjectMapper
// bean in the context. Stateless usage, so a fresh instance is fine.
private final ObjectMapper objectMapper = new ObjectMapper();
public CreditErrorAdvice(
CreditService creditService,
TeamCreditService teamCreditService,
UserRepository userRepository,
ErrorTrackingService errorTrackingService,
SaasTeamExtensionService saasTeamExtensionService,
CreditHeaderUtils creditHeaderUtils,
MeterRegistry meterRegistry) {
this.creditService = creditService;
this.teamCreditService = teamCreditService;
this.userRepository = userRepository;
this.errorTrackingService = errorTrackingService;
this.saasTeamExtensionService = saasTeamExtensionService;
this.creditHeaderUtils = creditHeaderUtils;
this.creditsConsumedCounter =
Counter.builder("credits.consumed")
.description("Number of credits actually consumed")
.tag("source", "error")
.register(meterRegistry);
}
@ExceptionHandler(Throwable.class)
public ResponseEntity<Object> handleThrowable(HttpServletRequest request, Throwable ex) {
HttpStatus status = determineHttpStatus(ex);
log.debug(
"[CREDIT-DEBUG] CreditErrorAdvice: Handling exception: {} -> {}",
ex.getClass().getSimpleName(),
status);
String message = Optional.ofNullable(ex.getMessage()).orElse("An error occurred");
// Build error body
Map<String, Object> body = new HashMap<>();
body.put("error", ex.getClass().getSimpleName());
body.put("message", message);
body.put("status", status.value());
var builder = ResponseEntity.status(status);
// Handle credit consumption for errors
if (Boolean.TRUE.equals(request.getAttribute(ATTR_ELIGIBLE))
&& request.getAttribute(ATTR_CHARGED) == null) {
var apiKey = (String) request.getAttribute(ATTR_APIKEY);
var resourceWeight = (Integer) request.getAttribute(ATTR_RESOURCE_WEIGHT);
var isApiRequest = (Boolean) request.getAttribute("IS_API_REQUEST");
int creditAmount = resourceWeight != null ? resourceWeight : 1;
String identifierForErrorTracking =
apiKey; // Keep using apiKey/username for error tracking
if (apiKey != null
&& errorTrackingService.recordErrorAndShouldConsumeCredit(
identifierForErrorTracking,
request.getRequestURI(),
ex,
status.value())) {
// Get current user
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
User user = null;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (Exception e) {
log.warn(
"[CREDIT-DEBUG] CreditErrorAdvice: Could not get user for team check: {}",
e.getMessage());
}
if (user == null) {
log.error(
"[CREDIT-DEBUG] CreditErrorAdvice: Unable to resolve user - skipping credit consumption");
} else {
// Check if user is in a non-personal team (must match UnifiedCreditInterceptor
// logic)
Long targetTeamId = null;
if (user.getTeam() != null
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
targetTeamId = user.getTeam().getId();
}
boolean consumed = false;
String creditSource = null;
if (targetTeamId != null) {
// User is in a non-personal team - consume from team credit pool
consumed = teamCreditService.consumeCredit(targetTeamId, creditAmount);
creditSource = "TEAM_CREDITS";
log.debug(
"[CREDIT-DEBUG] CreditErrorAdvice: Consumed {} credits from team {}",
creditAmount,
targetTeamId);
} else {
// No team - use waterfall logic for individual credits
boolean isApiRequestFlag = Boolean.TRUE.equals(isApiRequest);
CreditConsumptionResult result =
creditService.consumeCreditWithWaterfall(
user, creditAmount, isApiRequestFlag);
consumed = result.isSuccess();
creditSource = result.getSource();
if (!consumed) {
log.error(
"[CREDIT-DEBUG] CreditErrorAdvice: Credit consumption failed for user: {} - {}",
user.getUsername(),
result.getMessage());
}
}
if (consumed) {
request.setAttribute(ATTR_CHARGED, Boolean.TRUE);
creditsConsumedCounter.increment();
// Set remaining credits header
int remainingCredits =
creditHeaderUtils.getRemainingCredits(
user, creditService, teamCreditService);
if (remainingCredits >= 0) {
builder.header(
"X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn(
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
remainingCredits);
}
if (creditSource != null) {
builder.header("X-Credit-Source", creditSource);
}
log.info(
"[CREDIT-DEBUG] CreditErrorAdvice: {} credits consumed from {} for user: {} (error case)",
creditAmount,
creditSource,
user.getUsername());
}
}
} else {
log.debug(
"[CREDIT-DEBUG] CreditErrorAdvice: ErrorTrackingService says do NOT consume credit for this error");
}
} else if (request.getAttribute(ATTR_CHARGED) != null) {
// Already charged, set header if user is authenticated
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated()) {
try {
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
int remainingCredits =
creditHeaderUtils.getRemainingCredits(
user, creditService, teamCreditService);
if (remainingCredits >= 0) {
builder.header("X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn(
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
remainingCredits);
}
} catch (Exception e) {
log.debug(
"[CREDIT-HEADER] Could not add credits header for already charged error: {}",
e.getMessage());
}
}
log.debug("[CREDIT-DEBUG] CreditErrorAdvice: Header set for already charged error");
}
if (isSseRequest(request)) {
String payload = toJsonPayload(body);
String sseBody = "event: error\ndata: " + payload + "\n\n";
return builder.contentType(MediaType.TEXT_EVENT_STREAM).body(sseBody);
}
return builder.body(body);
}
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.length() < 8) {
return "***";
}
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
}
private HttpStatus determineHttpStatus(Throwable throwable) {
// Map common exceptions to HTTP status codes
String exceptionClass = throwable.getClass().getSimpleName();
switch (exceptionClass) {
case "IllegalArgumentException":
case "ValidationException":
case "MethodArgumentNotValidException":
return HttpStatus.BAD_REQUEST;
case "AccessDeniedException":
return HttpStatus.FORBIDDEN;
case "UsernameNotFoundException":
return HttpStatus.UNAUTHORIZED;
case "HttpMessageNotReadableException":
return HttpStatus.BAD_REQUEST;
case "MaxUploadSizeExceededException":
return HttpStatus.PAYLOAD_TOO_LARGE;
case "UnsupportedOperationException":
return HttpStatus.NOT_IMPLEMENTED;
default:
// Check error message for clues
String message = throwable.getMessage();
if (message != null) {
if (message.toLowerCase().contains("validation")
|| message.toLowerCase().contains("invalid parameter")) {
return HttpStatus.BAD_REQUEST;
}
if (message.toLowerCase().contains("not found")) {
return HttpStatus.NOT_FOUND;
}
}
return HttpStatus.INTERNAL_SERVER_ERROR;
}
}
private boolean isSseRequest(HttpServletRequest request) {
String accept = request.getHeader("Accept");
if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
return true;
}
String contentType = request.getContentType();
return contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE);
}
private String toJsonPayload(Map<String, Object> payload) {
try {
return objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException exc) {
log.warn("Failed to serialize SSE error payload, falling back to string", exc);
String message = payload.getOrDefault("message", "An error occurred").toString();
return "{\"error\":\"Error\",\"message\":\"" + message + "\",\"status\":500}";
}
}
public static class ErrorResponse {
public final String error;
public final String message;
public final int status;
public ErrorResponse(String error, String message, int status) {
this.error = error;
this.message = message;
this.status = status;
}
}
}
@@ -0,0 +1,226 @@
package stirling.software.saas.interceptor;
import org.springframework.context.annotation.Profile;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.CreditConsumptionResult;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
@RestControllerAdvice
@Profile("saas")
@Slf4j
public class CreditSuccessAdvice implements ResponseBodyAdvice<Object> {
private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
private static final String ATTR_APIKEY = "CREDIT_API_KEY";
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
private final CreditService creditService;
private final TeamCreditService teamCreditService;
private final UserRepository userRepository;
private final SaasTeamExtensionService saasTeamExtensionService;
private final CreditHeaderUtils creditHeaderUtils;
private final Counter creditsConsumedCounter;
public CreditSuccessAdvice(
CreditService creditService,
TeamCreditService teamCreditService,
UserRepository userRepository,
SaasTeamExtensionService saasTeamExtensionService,
CreditHeaderUtils creditHeaderUtils,
MeterRegistry meterRegistry) {
this.creditService = creditService;
this.teamCreditService = teamCreditService;
this.userRepository = userRepository;
this.saasTeamExtensionService = saasTeamExtensionService;
this.creditHeaderUtils = creditHeaderUtils;
this.creditsConsumedCounter =
Counter.builder("credits.consumed")
.description("Number of credits actually consumed")
.tag("source", "success")
.register(meterRegistry);
}
@Override
public boolean supports(
MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// Only REST bodies; this covers @ResponseBody and ResponseEntity
return true;
}
@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
if (!(request instanceof ServletServerHttpRequest)) {
return body;
}
var servletReq = ((ServletServerHttpRequest) request).getServletRequest();
if (!Boolean.TRUE.equals(servletReq.getAttribute(ATTR_ELIGIBLE))) {
return body;
}
if (servletReq.getAttribute(ATTR_CHARGED) != null) {
return body;
}
// If the handler returned an error ResponseEntity (>=400) without throwing,
// don't spend here; the error advice will decide.
int status = 200;
if (response instanceof ServletServerHttpResponse) {
status = ((ServletServerHttpResponse) response).getServletResponse().getStatus();
}
if (status >= 400) {
log.debug(
"[CREDIT-DEBUG] CreditSuccessAdvice: Error status {} detected, skipping credit consumption",
status);
return body;
}
var apiKey = (String) servletReq.getAttribute(ATTR_APIKEY);
var resourceWeight = (Integer) servletReq.getAttribute(ATTR_RESOURCE_WEIGHT);
var isApiRequest = (Boolean) servletReq.getAttribute("IS_API_REQUEST");
int creditAmount = resourceWeight != null ? resourceWeight : 1;
if (apiKey != null) {
// Get current user
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
User user = null;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (Exception e) {
log.warn(
"[CREDIT-DEBUG] CreditSuccessAdvice: Could not get user for team check: {}",
e.getMessage());
}
if (user == null) {
log.error(
"[CREDIT-DEBUG] CreditSuccessAdvice: Unable to resolve user - skipping credit consumption");
return body;
}
// Check if user is in a non-personal team (must match UnifiedCreditInterceptor logic)
// IMPORTANT: Limited API users (anonymous, extra limited) always use personal credits,
// never team credits
boolean isLimitedApiUser =
auth.getAuthorities().stream()
.anyMatch(
authority ->
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|| "ROLE_EXTRA_LIMITED_API_USER"
.equals(authority.getAuthority()));
Long targetTeamId = null;
if (!isLimitedApiUser
&& user.getTeam() != null
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
targetTeamId = user.getTeam().getId();
}
final boolean consumed;
final String creditSource;
if (targetTeamId != null) {
// User is in a non-personal team - use waterfall with leader overage
CreditConsumptionResult result =
teamCreditService.consumeCreditWithWaterfall(targetTeamId, creditAmount);
consumed = result.isSuccess();
creditSource = result.getSource();
if (!consumed) {
log.error(
"[CREDIT-DEBUG] CreditSuccessAdvice: Team credit consumption failed:"
+ " {}",
result.getMessage());
} else {
log.debug(
"[CREDIT-DEBUG] CreditSuccessAdvice: Consumed {} credits from team {}"
+ " via {}",
creditAmount,
targetTeamId,
creditSource);
}
} else {
// No team - use waterfall logic for individual credits
boolean isApiRequestFlag = Boolean.TRUE.equals(isApiRequest);
CreditConsumptionResult result =
creditService.consumeCreditWithWaterfall(
user, creditAmount, isApiRequestFlag);
consumed = result.isSuccess();
creditSource = result.getSource();
if (!consumed) {
log.error(
"[CREDIT-DEBUG] CreditSuccessAdvice: Credit consumption failed for user: {} - {}",
user.getUsername(),
result.getMessage());
}
}
if (consumed) {
servletReq.setAttribute(ATTR_CHARGED, Boolean.TRUE);
creditsConsumedCounter.increment();
// Set remaining credits header
int remainingCredits =
creditHeaderUtils.getRemainingCredits(
user, creditService, teamCreditService);
if (remainingCredits >= 0) {
response.getHeaders()
.set("X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn(
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
remainingCredits);
}
if (creditSource != null) {
response.getHeaders().set("X-Credit-Source", creditSource);
}
log.info(
"[CREDIT-DEBUG] CreditSuccessAdvice: {} credits consumed from {} for user: {}",
creditAmount,
creditSource,
user.getUsername());
}
} else {
log.warn("[CREDIT-DEBUG] CreditSuccessAdvice: No apiKey attribute found");
}
return body;
}
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.length() < 8) {
return "***";
}
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
}
}
@@ -0,0 +1,491 @@
package stirling.software.saas.interceptor;
import org.springframework.context.annotation.Profile;
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.AsyncHandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
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.config.CreditsProperties;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.UserCredit;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.ErrorTrackingService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.SaasUserExtensionService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
@Component
@Profile("saas")
@Slf4j
public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor {
private final CreditService creditService;
private final ErrorTrackingService errorTrackingService;
private final CreditsProperties creditsProperties;
private final UserRepository userRepository;
private final TeamCreditService teamCreditService;
private final TeamMembershipRepository membershipRepository;
private final SaasUserExtensionService saasUserExtensionService;
private final SaasTeamExtensionService saasTeamExtensionService;
private final Counter creditsCheckedCounter;
private final Counter creditsRejectedCounter;
private final Counter jwtBypassCounter;
private final Timer creditCheckTimer;
private static final String ATTR_CREDIT_ELIGIBLE = "CREDIT_ELIGIBLE";
private static final String ATTR_API_KEY = "CREDIT_API_KEY";
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
public UnifiedCreditInterceptor(
CreditService creditService,
ErrorTrackingService errorTrackingService,
CreditsProperties creditsProperties,
UserRepository userRepository,
TeamCreditService teamCreditService,
TeamMembershipRepository membershipRepository,
SaasUserExtensionService saasUserExtensionService,
SaasTeamExtensionService saasTeamExtensionService,
MeterRegistry meterRegistry) {
this.creditService = creditService;
this.errorTrackingService = errorTrackingService;
this.creditsProperties = creditsProperties;
this.userRepository = userRepository;
this.teamCreditService = teamCreditService;
this.membershipRepository = membershipRepository;
this.saasUserExtensionService = saasUserExtensionService;
this.saasTeamExtensionService = saasTeamExtensionService;
this.creditsCheckedCounter =
Counter.builder("credits.validation.checked")
.description("Number of requests that had credit validation performed")
.register(meterRegistry);
this.creditsRejectedCounter =
Counter.builder("credits.validation.rejected")
.description("Number of requests rejected due to insufficient credits")
.register(meterRegistry);
this.jwtBypassCounter =
Counter.builder("credits.validation.jwt_bypass")
.description("Number of JWT requests that bypassed credit validation")
.register(meterRegistry);
this.creditCheckTimer =
Timer.builder("credits.validation.duration")
.description("Time taken to validate credits")
.register(meterRegistry);
}
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
log.debug(
"[CREDIT-DEBUG] UnifiedCreditInterceptor.preHandle() - handler: {}",
handler.getClass().getSimpleName());
// Credits system disabled - allow all requests
if (!creditsProperties.isEnabled()) {
log.debug("[CREDIT-DEBUG] Credits system disabled - allowing request");
return true;
}
// Only apply to @AutoJobPostMapping endpoints and extract resource weight
if (!(handler instanceof HandlerMethod hm)
|| !hm.getMethod().isAnnotationPresent(AutoJobPostMapping.class)) {
log.debug(
"[CREDIT-DEBUG] Handler not eligible for credit validation (no @AutoJobPostMapping)");
return true;
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
log.debug(
"[CREDIT-DEBUG] Authentication: {}",
auth != null ? auth.getClass().getSimpleName() : "null");
User currentUser = null;
// API key authentication always needs credit validation
if (auth instanceof ApiKeyAuthenticationToken) {
// API key users - proceed with normal credit validation
currentUser = (User) auth.getPrincipal();
} else if (auth != null && auth.isAuthenticated()) {
// JWT users - get user from authentication details
// JwtAuthenticationToken.getPrincipal() might not be a User object
// so we need to look up the user by the Supabase ID from auth.getName()
String supabaseId = AuthenticationUtils.extractSupabaseId(auth);
log.debug("[CREDIT-DEBUG] JWT authentication detected, Supabase ID: {}", supabaseId);
// Look up the User object that should exist (authentication succeeded)
try {
java.util.UUID supabaseUuid = java.util.UUID.fromString(supabaseId);
java.util.Optional<User> userOpt = userRepository.findBySupabaseId(supabaseUuid);
if (userOpt.isEmpty()) {
log.error(
"[CREDIT-DEBUG] JWT authenticated but no User found for Supabase ID: {}",
supabaseId);
response.setStatus(500);
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"USER_NOT_FOUND\",\"message\":\"Authenticated user not found in database\",\"status\":500}");
return false;
}
currentUser = userOpt.get();
if (shouldApplyCreditsToJwtUser(currentUser)) {
// Anonymous users or other limited JWT users should consume credits
log.debug(
"[CREDIT-DEBUG] JWT user {} subject to credit validation due to limited role",
currentUser.getUsername());
} else {
jwtBypassCounter.increment();
log.debug(
"[CREDIT-DEBUG] JWT user {} bypassing credit validation (unlimited role)",
currentUser.getUsername());
return true;
}
} catch (IllegalArgumentException e) {
log.error("[CREDIT-DEBUG] Invalid Supabase ID format: {}", supabaseId);
response.setStatus(400);
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"INVALID_USER_ID\",\"message\":\"Invalid user identifier format\",\"status\":400}");
return false;
}
} else {
// SECURITY: Block all non-authenticated requests
log.warn(
"[CREDIT-DEBUG] Non-authenticated request blocked - authentication required for credit-controlled endpoints");
response.setStatus(401); // 401 Unauthorized
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"AUTHENTICATION_REQUIRED\",\"message\":\"Authentication required to access this endpoint\",\"status\":401}");
return false;
}
// Extract resource weight from annotation
AutoJobPostMapping annotation = hm.getMethod().getAnnotation(AutoJobPostMapping.class);
int resourceWeight =
Math.max(1, Math.min(100, annotation.resourceWeight())); // Clamp to 1-100
String apiKey = getApiKeyForUser(auth, currentUser);
String maskedApiKey = maskApiKey(apiKey);
log.debug(
"[CREDIT-DEBUG] Credit validation for user: {}, API key: {}, resource weight: {}",
currentUser.getUsername(),
maskedApiKey,
resourceWeight);
// Track that we're performing credit validation
creditsCheckedCounter.increment();
// Check if user has SUFFICIENT credits for this operation (with timing)
Timer.Sample sample = Timer.start();
boolean hasSufficientCredits;
int availableCredits = 0;
// Check if user is a limited API user (anonymous, extra limited)
// Limited API users always use personal credits, never team credits
boolean isLimitedApiUser =
currentUser.getAuthorities().stream()
.anyMatch(
authority ->
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|| "ROLE_EXTRA_LIMITED_API_USER"
.equals(authority.getAuthority()));
if (auth instanceof ApiKeyAuthenticationToken) {
// API key auth - get credit balance
java.util.Optional<UserCredit> userCreditsOpt =
creditService.getUserCreditsByApiKey(apiKey);
availableCredits = userCreditsOpt.map(UserCredit::getTotalAvailableCredits).orElse(0);
hasSufficientCredits = availableCredits >= resourceWeight;
} else {
// JWT user - check team credits if user is in a non-personal team, otherwise personal
// credits
Long teamId = null;
if (!isLimitedApiUser
&& currentUser.getTeam() != null
&& !saasTeamExtensionService.isPersonal(currentUser.getTeam())) {
teamId = currentUser.getTeam().getId();
}
if (teamId != null) {
// User is in a non-personal team - check team credits + leader overage billing
java.util.Optional<TeamCredit> teamCredits =
teamCreditService.getTeamCredits(teamId);
availableCredits = teamCredits.map(TeamCredit::getTotalAvailableCredits).orElse(0);
// Check if sufficient credits OR team leader has metered billing
boolean hasTeamCredits = availableCredits >= resourceWeight;
boolean leaderHasMetered = checkTeamLeaderMeteredBilling(currentUser.getTeam());
hasSufficientCredits = hasTeamCredits || leaderHasMetered;
log.debug(
"[CREDIT-DEBUG] Checking team {} credits for user {}: available={}"
+ " required={} hasCredits={} leaderMetered={} sufficient={}",
teamId,
currentUser.getUsername(),
availableCredits,
resourceWeight,
hasTeamCredits,
leaderHasMetered,
hasSufficientCredits);
} else {
// Personal team or no team - check personal credits
UserCredit userCredits = creditService.getOrCreateUserCredits(currentUser);
availableCredits = userCredits.getTotalAvailableCredits();
hasSufficientCredits = availableCredits >= resourceWeight;
log.debug(
"[CREDIT-DEBUG] Checking personal credits for user {}: available={} required={} sufficient={}",
currentUser.getUsername(),
availableCredits,
resourceWeight,
hasSufficientCredits);
}
}
sample.stop(creditCheckTimer);
// Check if user has metered billing enabled (they can use overage credits even with
// insufficient free credits)
boolean hasMeteredBilling = saasUserExtensionService.isMeteredBillingEnabled(currentUser);
if (!hasSufficientCredits && !hasMeteredBilling) {
creditsRejectedCounter.increment();
// Enhanced message for team members
// Note: Limited API users always use personal credits, so they get personal message
String message;
if (!isLimitedApiUser
&& currentUser.getTeam() != null
&& !saasTeamExtensionService.isPersonal(currentUser.getTeam())) {
message =
"Insufficient team credits. Team leader must enable overage billing for"
+ " uninterrupted service.";
} else {
message =
"Insufficient API credits. Please purchase more credits or wait for your"
+ " monthly cycle credits to reset.";
}
log.warn(
"[CREDIT-DEBUG] Credit validation rejected - Method: {}, URI: {}, IP: {},"
+ " User-Agent: {}, User: {}, Supabase ID: {}, Reason: {}",
request.getMethod(),
request.getRequestURI(),
getClientIpAddress(request),
request.getHeader("User-Agent"),
currentUser.getUsername(),
currentUser.getSupabaseId(),
message);
response.setStatus(429); // 429 Too Many Requests
response.setContentType("application/json");
response.getWriter()
.write(
String.format(
"{\"error\":\"INSUFFICIENT_CREDITS\",\"message\":\"%s\",\"status\":429}",
message));
response.getWriter().flush();
return false;
}
// Log when metered billing users are using overage credits
if (!hasSufficientCredits && hasMeteredBilling) {
log.info(
"[CREDIT-DEBUG] Metered billing user {} proceeding with insufficient free credits (have: {}, need: {}) - will use overage credits (billed monthly)",
currentUser.getUsername(),
availableCredits,
resourceWeight);
}
// Mark request as eligible for credit consumption
request.setAttribute(ATTR_CREDIT_ELIGIBLE, Boolean.TRUE);
request.setAttribute(ATTR_API_KEY, apiKey);
request.setAttribute(ATTR_RESOURCE_WEIGHT, resourceWeight);
// Store whether this is API key or JWT authentication for advice classes
boolean isApiKeyAuth = auth instanceof ApiKeyAuthenticationToken;
request.setAttribute("IS_API_KEY_AUTH", isApiKeyAuth);
// Store IS_API_REQUEST for waterfall logic (API key requests always consume credits)
request.setAttribute("IS_API_REQUEST", isApiKeyAuth);
log.debug(
"[CREDIT-DEBUG] Credit validation passed - request marked as eligible for consumption (will consume after success/error)");
return true;
}
@Override
public void postHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView)
throws Exception {
// Success path now handled by CreditSuccessAdvice - no spending in postHandle anymore
log.debug("[CREDIT-DEBUG] postHandle: Success path will be handled by CreditSuccessAdvice");
}
@Override
public void afterCompletion(
HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception {
// Error path now handled by CreditErrorAdvice - no spending in afterCompletion anymore
if (ex != null) {
log.debug(
"[CREDIT-DEBUG] afterCompletion: Error path will be handled by CreditErrorAdvice: {}",
ex.getClass().getSimpleName());
} else {
log.debug(
"[CREDIT-DEBUG] afterCompletion: Success path already handled by CreditSuccessAdvice");
}
}
@Override
public void afterConcurrentHandlingStarted(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
// For async requests (Callable, DeferredResult, etc.), prevent duplicate processing
// The actual postHandle/afterCompletion will be called when async processing completes
log.debug(
"[CREDIT-DEBUG] afterConcurrentHandlingStarted: Async processing started - skipping interceptor logic");
}
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.length() < 8) {
return "***";
}
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
}
private String getClientIpAddress(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
String xRealIp = request.getHeader("X-Real-IP");
if (xRealIp != null && !xRealIp.isEmpty()) {
return xRealIp;
}
return request.getRemoteAddr();
}
/**
* Determines if credit limits should apply to a JWT user.
*
* <p>Rules:
*
* <ul>
* <li>Metered billing users: always consume (free tier first, then report overage to Stripe)
* <li>Anonymous users: consume credits (web/API)
* <li>Regular users: consume credits (web/API)
* <li>Pro users: unlimited on web UI (waterfall logic handles this), but subject to checks
* <li>API users: always consume credits
* <li>Internal API users: unlimited everywhere
* <li>Admin users: unlimited everywhere
* </ul>
*/
private boolean shouldApplyCreditsToJwtUser(User user) {
String roles = user.getRolesAsString();
// Internal API users are unlimited everywhere (for backend internal operations)
if (roles.contains("STIRLING-PDF-BACKEND-API-USER")) {
log.debug("[CREDIT-DEBUG] Internal API user {} - unlimited usage", user.getUsername());
return false;
}
// Pro users: Let them through to waterfall logic
// (Pro gets unlimited UI but API still consumes credits)
if (roles.contains("ROLE_PRO_USER")) {
log.debug(
"[CREDIT-DEBUG] Pro user {} - will be handled by waterfall logic",
user.getUsername());
return true; // Changed from false - let waterfall handle Pro exemption
}
// Admin users are unlimited everywhere
if (roles.contains("ROLE_ADMIN")) {
log.debug("[CREDIT-DEBUG] Admin user {} - unlimited usage", user.getUsername());
return false;
}
// All other users (anonymous, regular, limited API users, metered billing) consume credits
log.debug(
"[CREDIT-DEBUG] User {} with roles {} - subject to credit limits",
user.getUsername(),
roles);
return true;
}
/**
* Gets the identifier for credit consumption. For API key users, use their actual API key. For
* JWT users, use the Supabase ID as identifier (auth.getName() returns Supabase ID).
*/
private String getApiKeyForUser(Authentication auth, User user) {
if (auth instanceof ApiKeyAuthenticationToken) {
return user.getApiKey();
} else {
// For JWT users, return Supabase ID as the credit consumption identifier
return AuthenticationUtils.extractSupabaseId(auth);
}
}
/**
* Check if team leader has metered billing enabled. This allows teams to use overage billing
* when team credits are exhausted.
*
* @param team the team to check
* @return true if team leader has metered billing enabled
*/
private boolean checkTeamLeaderMeteredBilling(stirling.software.proprietary.model.Team team) {
if (team == null || team.getId() == null) {
return false;
}
try {
java.util.List<stirling.software.saas.model.TeamMembership> leaders =
membershipRepository.findByTeamIdAndRole(
team.getId(),
stirling.software.common.model.enumeration.TeamRole.LEADER);
if (leaders.isEmpty()) {
return false;
}
User leader = leaders.get(0).getUser();
return saasUserExtensionService.isMeteredBillingEnabled(leader);
} catch (Exception e) {
log.error("Error checking team leader metered billing: {}", e.getMessage());
return false;
}
}
}
@@ -0,0 +1,30 @@
package stirling.software.saas.model;
/**
* Supabase JWT {@code amr} claim values: the authentication methods Supabase records when a user
* logs in. Used during anonymous-to-authenticated upgrades to record how the user upgraded.
*/
public enum AmrMethod {
OAUTH("oauth"),
PASSWORD("password"),
OTP("otp"),
TOTP("totp"),
RECOVERY("recovery"),
INVITE("invite"),
SSO_SAML("sso/saml"),
MAGICLINK("magiclink"),
EMAIL_SIGNUP("email/signup"),
EMAIL_CHANGE("email_change"),
TOKEN_REFRESH("token_refresh"),
ANONYMOUS("anonymous");
private final String method;
AmrMethod(String method) {
this.method = method;
}
public String getMethod() {
return method;
}
}
@@ -0,0 +1,69 @@
package stirling.software.saas.model;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* Result of a credit consumption attempt with explicit waterfall logic. Indicates whether the
* operation succeeded and which credit source was used.
*/
@Data
@AllArgsConstructor
public class CreditConsumptionResult {
/** Whether the credit consumption succeeded */
private boolean success;
/**
* The credit source used for this operation. Possible values: "PRO_PLAN" (Pro user with
* unlimited UI access, no credits consumed); "CYCLE_CREDITS" (free monthly cycle credit
* allocation); "BOUGHT_CREDITS" (one-time purchased credits); "METERED_SUBSCRIPTION"
* (pay-what-you-use metered billing, reported to Stripe); null (operation failed; see message
* for reason).
*/
private String source;
/** Human-readable message about the result */
private String message;
/**
* Creates a successful result for unlimited access (Pro plan UI requests).
*
* @param source The credit source (typically "PRO_PLAN")
* @return CreditConsumptionResult indicating unlimited access
*/
public static CreditConsumptionResult unlimited(String source) {
return new CreditConsumptionResult(true, source, "Unlimited access");
}
/**
* Creates a successful result for credit consumption.
*
* @param source The credit source used
* @return CreditConsumptionResult indicating success
*/
public static CreditConsumptionResult success(String source) {
return new CreditConsumptionResult(true, source, "Credits consumed");
}
/**
* Creates a failure result.
*
* @param reason The reason for failure
* @return CreditConsumptionResult indicating failure
*/
public static CreditConsumptionResult failure(String reason) {
return new CreditConsumptionResult(false, null, reason);
}
/**
* Creates a failure result with custom message.
*
* @param reason The reason code
* @param message Custom human-readable message
* @return CreditConsumptionResult indicating failure
*/
public static CreditConsumptionResult failure(String reason, String message) {
return new CreditConsumptionResult(false, null, message != null ? message : reason);
}
}
@@ -0,0 +1,139 @@
package stirling.software.saas.model;
public enum ProcessingErrorType {
/**
* Validation errors. should never cost credits. Examples: missing parameters, invalid file
* types, size limits exceeded, malformed requests, authentication failures
*/
VALIDATION_ERROR,
/**
* Processing errors. should cost credits after 3rd attempt per user/endpoint. Examples: corrupt
* PDF files, unsupported PDF features, memory issues during processing, OCR failures on valid
* PDFs, conversion errors on valid files
*/
PROCESSING_ERROR,
/**
* System errors. should not cost credits (our fault). Examples: database connection issues,
* filesystem problems, service unavailable, internal server errors
*/
SYSTEM_ERROR;
/** Determine error type from exception and HTTP status */
public static ProcessingErrorType classifyError(
Throwable throwable, int httpStatus, String endpoint) {
if (throwable == null) {
return classifyByHttpStatus(httpStatus);
}
String errorMessage = throwable.getMessage();
String exceptionClass = throwable.getClass().getSimpleName();
// Validation errors (client-side issues)
if (httpStatus == 400 || httpStatus == 422) {
if (isValidationError(errorMessage, exceptionClass)) {
return VALIDATION_ERROR;
}
}
// Authentication/Authorization errors
if (httpStatus == 401 || httpStatus == 403) {
return VALIDATION_ERROR;
}
// Rate limiting
if (httpStatus == 429) {
return VALIDATION_ERROR;
}
// System errors (our fault)
if (httpStatus >= 500 || isSystemError(errorMessage, exceptionClass)) {
return SYSTEM_ERROR;
}
// Processing errors (user's data issue but valid request)
if (isProcessingError(errorMessage, exceptionClass, endpoint)) {
return PROCESSING_ERROR;
}
// Default to validation error to be safe
return VALIDATION_ERROR;
}
private static ProcessingErrorType classifyByHttpStatus(int httpStatus) {
if (httpStatus >= 400 && httpStatus < 500) {
return VALIDATION_ERROR;
} else if (httpStatus >= 500) {
return SYSTEM_ERROR;
}
return VALIDATION_ERROR;
}
private static boolean isValidationError(String errorMessage, String exceptionClass) {
if (errorMessage == null && exceptionClass == null) return false;
String[] validationKeywords = {
"validation", "invalid parameter", "missing parameter", "malformed",
"bad request", "illegal argument", "file too large", "unsupported file type",
"empty file", "no file provided", "invalid format"
};
String[] validationExceptions = {
"IllegalArgumentException",
"ValidationException",
"BindException",
"MethodArgumentNotValidException",
"MissingServletRequestParameterException",
"HttpMessageNotReadableException",
"MaxUploadSizeExceededException"
};
return containsAny(errorMessage, validationKeywords)
|| containsAny(exceptionClass, validationExceptions);
}
private static boolean isSystemError(String errorMessage, String exceptionClass) {
if (errorMessage == null && exceptionClass == null) return false;
String[] systemExceptions = {
"SQLException",
"IOException",
"OutOfMemoryError",
"TimeoutException",
"ConnectException",
"UnknownHostException",
"ServiceUnavailableException"
};
return containsAny(exceptionClass, systemExceptions);
}
private static boolean isProcessingError(
String errorMessage, String exceptionClass, String endpoint) {
if (errorMessage == null && exceptionClass == null) return false;
String[] processingExceptions = {
"PDFException", "COSVisitorException", "InvalidPDFException",
"ConversionException", "OCRException", "ParseException"
};
// If we're checking errors for an endpoint, it's already been identified as a tracked
// endpoint
// through @AutoJobPostMapping annotation, so we can assume it's a PDF processing endpoint
return containsAny(exceptionClass, processingExceptions)
|| (endpoint != null && !isValidationError(errorMessage, exceptionClass));
}
private static boolean containsAny(String text, String[] keywords) {
if (text == null) return false;
String lowerText = text.toLowerCase();
for (String keyword : keywords) {
if (lowerText.contains(keyword.toLowerCase())) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,120 @@
package stirling.software.saas.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapsId;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.model.Team;
/**
* Saas-only sidecar that holds the seat / billing / personal-team metadata for a {@link Team}.
* Keeping these off the proprietary {@link Team} entity prevents the {@code teams} table from
* acquiring saas-only columns under OSS Hibernate {@code ddl-auto=update}.
*
* <p>1:1 with {@link Team}; team_id is both PK and FK. Created lazily on first saas-mode access via
* {@code SaasTeamExtensionService.getOrCreate(Team)}.
*
* <p>{@link Version} column on this entity lets seat increments stay atomic without a row lock.
*/
@Entity
@Table(name = "saas_team_extensions")
@NoArgsConstructor
@Getter
@Setter
public class SaasTeamExtensions implements Serializable {
private static final long serialVersionUID = 1L;
public static final String TEAM_TYPE_PERSONAL = "PERSONAL";
public static final String TEAM_TYPE_STANDARD = "STANDARD";
@Id
@Column(name = "team_id")
private Long teamId;
// @MapsId binds this side's PK to the Team PK, so Hibernate populates teamId from the
// team reference on persist (no manual setter race). insertable/updatable=false is no
// longer needed because @MapsId owns the column.
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "team_id")
@OnDelete(action = OnDeleteAction.CASCADE)
private Team team;
@Column(name = "team_type", nullable = false)
private String teamType = TEAM_TYPE_STANDARD;
@Column(name = "is_personal", nullable = false)
private Boolean isPersonal = Boolean.FALSE;
@Column(name = "seat_count", nullable = false)
private Integer seatCount = 1;
@Column(name = "seats_used", nullable = false)
private Integer seatsUsed = 0;
@Column(name = "max_seats", nullable = false)
private Integer maxSeats = 1;
@Column(name = "created_by_user_id")
private Long createdByUserId;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Version
@Column(name = "version")
private Long version;
public SaasTeamExtensions(Team team) {
this.team = team;
this.teamId = team.getId();
}
/** Convenience boolean accessor. */
public boolean isPersonal() {
return Boolean.TRUE.equals(isPersonal);
}
/**
* Whether this team has unused seats. Personal teams enforce a 1-seat limit; standard teams are
* unlimited.
*/
public boolean hasAvailableSeats() {
if (isPersonal()) {
return seatsUsed != null && maxSeats != null && seatsUsed < maxSeats;
}
return true;
}
/**
* Whether this team accepts new invitations. Personal teams (1 seat, owned by one user) never
* do; standard teams always do.
*/
public boolean canInviteMembers() {
return !isPersonal();
}
}
@@ -0,0 +1,78 @@
package stirling.software.saas.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapsId;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
/**
* Saas-only sidecar that holds user-level fields irrelevant to OSS / proprietary deployments
* (metered-billing flag, API-key first-use audit timestamp). Keeping these off the proprietary
* {@link User} entity prevents the {@code users} table from acquiring saas-only columns under OSS
* Hibernate {@code ddl-auto=update}.
*
* <p>1:1 with {@link User}; user_id is both PK and FK. Created lazily on first saas-mode access via
* {@code SaasUserExtensionService.getOrCreate(User)}.
*/
@Entity
@Table(name = "saas_user_extensions")
@NoArgsConstructor
@Getter
@Setter
public class SaasUserExtensions implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "user_id")
private Long userId;
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "user_id")
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;
@Column(name = "has_metered_billing_enabled", nullable = false)
private Boolean hasMeteredBillingEnabled = Boolean.FALSE;
@Column(name = "api_key_first_used_at")
private LocalDateTime apiKeyFirstUsedAt;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public SaasUserExtensions(User user) {
// @MapsId derives userId from user.id at persist time; setting both keeps in-memory
// reads consistent before flush.
this.user = user;
this.userId = user.getId();
}
public boolean isMeteredBillingEnabled() {
return Boolean.TRUE.equals(hasMeteredBillingEnabled);
}
}
@@ -0,0 +1,34 @@
package stirling.software.saas.model;
import java.time.LocalDateTime;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
/** Read-only mirror of Supabase's {@code auth.users} table. */
@Data
@Entity
@Table(name = "users", schema = "auth")
public class SupabaseUser {
@Id
@Column(name = "id", unique = true, nullable = false, updatable = false)
private UUID id;
@Column(name = "email", unique = true)
private String email;
@Column(name = "is_sso_user")
private boolean isSSOUser;
@Column(name = "is_anonymous")
private boolean isAnonymous;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
}
@@ -0,0 +1,131 @@
package stirling.software.saas.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.model.Team;
/** Shared credit pool for multi-member teams; see {@link UserCredit} for the per-user variant. */
@Entity
@Table(name = "team_credits")
@NoArgsConstructor
@Getter
@Setter
public class TeamCredit implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "credit_id")
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id", nullable = false, unique = true)
@OnDelete(action = OnDeleteAction.CASCADE)
private Team team;
@Column(name = "cycle_credits_remaining")
private Integer cycleCreditsRemaining = 0;
@Column(name = "cycle_credits_allocated")
private Integer cycleCreditsAllocated = 0;
@Column(name = "bought_credits_remaining")
private Integer boughtCreditsRemaining = 0;
@Column(name = "total_bought_credits")
private Integer totalBoughtCredits = 0;
@Column(name = "last_cycle_reset_at")
private LocalDateTime lastCycleResetAt;
@Column(name = "last_api_usage")
private LocalDateTime lastApiUsage;
@Column(name = "total_api_calls_made")
private Long totalApiCallsMade = 0L;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Version
@Column(name = "version")
private Long version;
public TeamCredit(Team team) {
this.team = team;
}
public int getTotalAvailableCredits() {
return (cycleCreditsRemaining != null ? cycleCreditsRemaining : 0)
+ (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0);
}
public boolean hasCreditsAvailable() {
return getTotalAvailableCredits() > 0;
}
/**
* Consume a credit from the team pool. Consumes cycle credits first, then bought credits.
*
* @return true if a credit was consumed, false if no credits available
*/
public boolean consumeCredit() {
if (cycleCreditsRemaining != null && cycleCreditsRemaining > 0) {
cycleCreditsRemaining--;
totalApiCallsMade++;
lastApiUsage = LocalDateTime.now();
return true;
} else if (boughtCreditsRemaining != null && boughtCreditsRemaining > 0) {
boughtCreditsRemaining--;
totalApiCallsMade++;
lastApiUsage = LocalDateTime.now();
return true;
}
return false;
}
public void addBoughtCredits(int credits) {
if (credits > 0) {
boughtCreditsRemaining =
(boughtCreditsRemaining != null ? boughtCreditsRemaining : 0) + credits;
totalBoughtCredits = (totalBoughtCredits != null ? totalBoughtCredits : 0) + credits;
}
}
public void resetCycleCredits(int cycleAllocation, LocalDateTime resetTime) {
this.cycleCreditsAllocated = cycleAllocation;
this.cycleCreditsRemaining = cycleAllocation;
this.lastCycleResetAt = resetTime;
}
public boolean isCycleResetDue(LocalDateTime lastScheduledReset) {
return lastCycleResetAt == null || lastCycleResetAt.isBefore(lastScheduledReset);
}
}
@@ -0,0 +1,115 @@
package stirling.software.saas.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.*;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import stirling.software.common.model.enumeration.InvitationStatus;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
/**
* Team invitation entity tracking email-based team invitations with unique tokens. Invitations
* expire after 7 days and can be in PENDING, ACCEPTED, REJECTED, or EXPIRED status.
*/
@Entity
@Table(name = "team_invitations")
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class TeamInvitation implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "invitation_id")
@EqualsAndHashCode.Include
@ToString.Include
private Long invitationId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id", nullable = false)
private Team team;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "inviter_user_id", nullable = false)
private User inviter;
@Column(name = "invitee_email", nullable = false)
@ToString.Include
private String inviteeEmail;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "invitee_user_id")
private User inviteeUser;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
@ToString.Include
private InvitationStatus status = InvitationStatus.PENDING;
@Column(name = "invitation_token", unique = true, nullable = false)
@EqualsAndHashCode.Include
@ToString.Include
private String invitationToken;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* Check if the invitation has expired
*
* @return true if current time is after expiration time
*/
public boolean isExpired() {
return expiresAt != null && LocalDateTime.now().isAfter(expiresAt);
}
/**
* Check if the invitation is still pending and not expired
*
* @return true if status is PENDING and not expired
*/
public boolean isPending() {
return status == InvitationStatus.PENDING && !isExpired();
}
/**
* Check if the invitation was accepted
*
* @return true if status is ACCEPTED
*/
public boolean isAccepted() {
return status == InvitationStatus.ACCEPTED;
}
/**
* Check if the invitation was rejected
*
* @return true if status is REJECTED
*/
public boolean isRejected() {
return status == InvitationStatus.REJECTED;
}
}
@@ -0,0 +1,83 @@
package stirling.software.saas.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.*;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
/**
* Team membership entity tracking which users belong to which teams and their roles. Supports
* LEADER (can invite/remove members, manage settings) and MEMBER (regular user) roles.
*/
@Entity
@Table(
name = "team_memberships",
uniqueConstraints = {@UniqueConstraint(columnNames = {"team_id", "user_id"})})
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class TeamMembership implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "membership_id")
@EqualsAndHashCode.Include
@ToString.Include
private Long membershipId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id", nullable = false)
private Team team;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false)
@ToString.Include
private TeamRole role = TeamRole.MEMBER;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "invited_by_user_id")
private User invitedBy;
@Column(name = "invited_at", nullable = false)
private LocalDateTime invitedAt;
@Column(name = "accepted_at")
private LocalDateTime acceptedAt;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public boolean isLeader() {
return role == TeamRole.LEADER;
}
public boolean isMember() {
return role == TeamRole.MEMBER;
}
}
@@ -0,0 +1,133 @@
package stirling.software.saas.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
/**
* Per-user credit pool. Layers a renewable monthly cycle pool ({@code cycleCreditsRemaining}) over
* a non-expiring purchased pool ({@code boughtCreditsRemaining}); cycle credits consume first.
*/
@Entity
@Table(name = "user_credits")
@NoArgsConstructor
@Getter
@Setter
public class UserCredit implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "credit_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;
@Column(name = "cycle_credits_remaining")
private Integer cycleCreditsRemaining = 0;
@Column(name = "cycle_credits_allocated")
private Integer cycleCreditsAllocated = 0;
@Column(name = "bought_credits_remaining")
private Integer boughtCreditsRemaining = 0;
@Column(name = "total_bought_credits")
private Integer totalBoughtCredits = 0;
@Column(name = "last_cycle_reset_at")
private LocalDateTime lastCycleResetAt;
@Column(name = "last_api_usage")
private LocalDateTime lastApiUsage;
@Column(name = "total_api_calls_made")
private Long totalApiCallsMade = 0L;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Version
@Column(name = "version")
private Long version;
public UserCredit(User user) {
this.user = user;
// Cycle credits are initialized by CreditService after this object is created,
// typically during user registration or at the start of a new billing cycle,
// using values from the application configuration.
}
public int getTotalAvailableCredits() {
return (cycleCreditsRemaining != null ? cycleCreditsRemaining : 0)
+ (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0);
}
public boolean hasCreditsAvailable() {
return getTotalAvailableCredits() > 0;
}
public boolean consumeCredit() {
// Consume cycle credits first, then bought credits.
if (cycleCreditsRemaining != null && cycleCreditsRemaining > 0) {
cycleCreditsRemaining--;
totalApiCallsMade++;
lastApiUsage = LocalDateTime.now();
return true;
} else if (boughtCreditsRemaining != null && boughtCreditsRemaining > 0) {
boughtCreditsRemaining--;
totalApiCallsMade++;
lastApiUsage = LocalDateTime.now();
return true;
}
return false;
}
public void addBoughtCredits(int credits) {
if (credits > 0) {
boughtCreditsRemaining =
(boughtCreditsRemaining != null ? boughtCreditsRemaining : 0) + credits;
totalBoughtCredits = (totalBoughtCredits != null ? totalBoughtCredits : 0) + credits;
}
}
public void resetCycleCredits(int cycleAllocation, LocalDateTime resetTime) {
this.cycleCreditsAllocated = cycleAllocation;
this.cycleCreditsRemaining = cycleAllocation;
this.lastCycleResetAt = resetTime;
}
public boolean isCycleResetDue(LocalDateTime lastScheduledReset) {
return lastCycleResetAt == null || lastCycleResetAt.isBefore(lastScheduledReset);
}
}
@@ -0,0 +1,95 @@
package stirling.software.saas.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(name = "user_error_tracker")
@NoArgsConstructor
@Getter
@Setter
public class UserErrorTracker implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "error_tracker_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "endpoint")
private String endpoint;
@Column(name = "processing_error_count")
private Integer processingErrorCount = 0;
@Column(name = "last_processing_error")
private LocalDateTime lastProcessingError;
@Column(name = "reset_after")
private LocalDateTime resetAfter;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public UserErrorTracker(User user, String endpoint, int ttlMinutes) {
this.user = user;
this.endpoint = endpoint;
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
}
public boolean shouldChargeForProcessingError(int freeProcessingErrors) {
return processingErrorCount != null && processingErrorCount > freeProcessingErrors;
}
public void recordProcessingError(int ttlMinutes) {
this.processingErrorCount = (processingErrorCount != null ? processingErrorCount : 0) + 1;
this.lastProcessingError = LocalDateTime.now();
// Refresh TTL on each error
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
}
public void resetErrorCount(int ttlMinutes) {
this.processingErrorCount = 0;
this.lastProcessingError = null;
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
}
public boolean isExpired() {
return resetAfter != null && LocalDateTime.now().isAfter(resetAfter);
}
public int getErrorsUntilCharged(int freeProcessingErrors) {
int current = processingErrorCount != null ? processingErrorCount : 0;
return Math.max(0, freeProcessingErrors + 1 - current);
}
}
@@ -0,0 +1,14 @@
package stirling.software.saas.model.exception;
import org.springframework.security.core.AuthenticationException;
public class AuthenticationFailureException extends AuthenticationException {
public AuthenticationFailureException(String message) {
super(message);
}
public AuthenticationFailureException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,8 @@
package stirling.software.saas.model.exception;
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String message) {
super(message);
}
}
@@ -0,0 +1,5 @@
/**
* Stirling-PDF SaaS module: Supabase-backed authentication, Stripe metered billing, and SaaS-only
* audit/aspect components.
*/
package stirling.software.saas;
@@ -0,0 +1,36 @@
package stirling.software.saas.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.saas.model.SaasTeamExtensions;
@Repository
public interface SaasTeamExtensionsRepository extends JpaRepository<SaasTeamExtensions, Long> {
Optional<SaasTeamExtensions> findByTeamId(Long teamId);
/**
* Atomic seat increment. Personal teams enforce a strict {@code seatsUsed &lt; maxSeats}
* ceiling; standard (non-personal) teams have no cap (mirrors {@link
* SaasTeamExtensions#hasAvailableSeats()}). Returns 1 on success, 0 if the cap was hit.
*/
@Modifying
@Query(
"UPDATE SaasTeamExtensions e SET e.seatsUsed = e.seatsUsed + 1 "
+ "WHERE e.teamId = :teamId AND "
+ "(e.isPersonal = TRUE AND e.seatsUsed < e.maxSeats OR e.isPersonal = FALSE)")
int incrementSeatsUsed(@Param("teamId") Long teamId);
/** Atomic seat decrement. Floor at 0. Returns 1 on a real decrement, 0 if already at 0. */
@Modifying
@Query(
"UPDATE SaasTeamExtensions e SET e.seatsUsed = e.seatsUsed - 1 "
+ "WHERE e.teamId = :teamId AND e.seatsUsed > 0")
int decrementSeatsUsed(@Param("teamId") Long teamId);
}
@@ -0,0 +1,20 @@
package stirling.software.saas.repository;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.saas.model.SaasUserExtensions;
@Repository
public interface SaasUserExtensionsRepository extends JpaRepository<SaasUserExtensions, Long> {
Optional<SaasUserExtensions> findByUserId(Long userId);
@Query("SELECT e FROM SaasUserExtensions e WHERE e.user.supabaseId = :supabaseId")
Optional<SaasUserExtensions> findBySupabaseId(@Param("supabaseId") UUID supabaseId);
}
@@ -0,0 +1,31 @@
package stirling.software.saas.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.saas.model.SupabaseUser;
@Repository
public interface SupabaseUserRepository extends JpaRepository<SupabaseUser, UUID> {
/**
* Anonymous users created before the cut-off date. Used by the cleanup job to drop stale
* anonymous sessions in batch (avoids long-running transactions on a single big delete).
*/
@Query(
"SELECT s.id FROM SupabaseUser s WHERE s.isAnonymous = true AND s.createdAt < :cutoffDate")
Stream<UUID> findByCreatedAtBeforeAndIsAnonymousTrue(
@Param("cutoffDate") LocalDateTime cutoffDate);
@Modifying(clearAutomatically = true)
@Query("DELETE FROM SupabaseUser u WHERE u.id IN :ids")
void deleteAllByIdInBatch(@Param("ids") List<UUID> ids);
}
@@ -0,0 +1,68 @@
package stirling.software.saas.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.saas.model.TeamCredit;
@Repository
public interface TeamCreditRepository extends JpaRepository<TeamCredit, Long> {
/** Find team credits by team ID. */
@Query("SELECT tc FROM TeamCredit tc WHERE tc.team.id = :teamId")
Optional<TeamCredit> findByTeamId(@Param("teamId") Long teamId);
/**
* Atomically consume credits from the team pool. Uses the {@code @Version} column on {@link
* TeamCredit} for optimistic locking - concurrent attempts will fail-fast rather than
* over-deduct. Returns 1 on success, 0 if insufficient balance or version conflict.
*/
@Modifying
@Query(
value =
"""
UPDATE team_credits
SET cycle_credits_remaining = CASE
WHEN cycle_credits_remaining >= :amount THEN cycle_credits_remaining - :amount
WHEN cycle_credits_remaining > 0 AND bought_credits_remaining >= (:amount - cycle_credits_remaining)
THEN 0
ELSE cycle_credits_remaining
END,
bought_credits_remaining = CASE
WHEN cycle_credits_remaining >= :amount THEN bought_credits_remaining
WHEN cycle_credits_remaining > 0 AND bought_credits_remaining >= (:amount - cycle_credits_remaining)
THEN bought_credits_remaining - (:amount - cycle_credits_remaining)
WHEN cycle_credits_remaining = 0 AND bought_credits_remaining >= :amount
THEN bought_credits_remaining - :amount
ELSE bought_credits_remaining
END,
total_api_calls_made = total_api_calls_made + :amount,
last_api_usage = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP,
version = version + 1
WHERE team_id = :teamId
AND (cycle_credits_remaining + bought_credits_remaining) >= :amount
""",
nativeQuery = true)
int consumeCredit(@Param("teamId") Long teamId, @Param("amount") int amount);
@Query(
"SELECT CASE WHEN COUNT(tc) > 0 THEN true ELSE false END FROM TeamCredit tc WHERE tc.team.id = :teamId")
boolean existsByTeamId(@Param("teamId") Long teamId);
@Modifying
@Query("DELETE FROM TeamCredit tc WHERE tc.team.id = :teamId")
void deleteByTeamId(@Param("teamId") Long teamId);
@Query(
"SELECT tc FROM TeamCredit tc WHERE tc.lastCycleResetAt IS NULL OR tc.lastCycleResetAt < :lastScheduledReset")
List<TeamCredit> findCreditsNeedingCycleReset(
@Param("lastScheduledReset") LocalDateTime lastScheduledReset);
}
@@ -0,0 +1,111 @@
package stirling.software.saas.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.common.model.enumeration.InvitationStatus;
import stirling.software.saas.model.TeamInvitation;
@Repository
public interface TeamInvitationRepository extends JpaRepository<TeamInvitation, Long> {
/**
* Find invitation by unique token
*
* @param token the invitation token
* @return Optional of TeamInvitation if found
*/
@Query(
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team JOIN FETCH ti.inviter WHERE ti.invitationToken = :token")
Optional<TeamInvitation> findByInvitationToken(@Param("token") String token);
/**
* Find all invitations sent to an email address
*
* @param email the invitee email
* @return List of invitations
*/
@Query(
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team JOIN FETCH ti.inviter WHERE ti.inviteeEmail = :email")
List<TeamInvitation> findByInviteeEmail(@Param("email") String email);
/**
* Find pending invitations for an email address (not expired)
*
* @param email the invitee email
* @return List of pending invitations
*/
@Query(
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team JOIN FETCH ti.inviter "
+ "WHERE ti.inviteeEmail = :email AND ti.status = 'PENDING' AND ti.expiresAt > :now")
List<TeamInvitation> findPendingInvitationsByEmail(
@Param("email") String email, @Param("now") LocalDateTime now);
/**
* Find all invitations for a team
*
* @param teamId the team ID
* @return List of invitations
*/
@Query("SELECT ti FROM TeamInvitation ti JOIN FETCH ti.inviter WHERE ti.team.id = :teamId")
List<TeamInvitation> findByTeamId(@Param("teamId") Long teamId);
/**
* Find all invitations sent by a user
*
* @param inviterUserId the inviter user ID
* @return List of invitations
*/
@Query(
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team WHERE ti.inviter.id = :inviterUserId")
List<TeamInvitation> findByInviterUserId(@Param("inviterUserId") Long inviterUserId);
/**
* Mark expired invitations as EXPIRED
*
* @param now current timestamp
* @return number of invitations marked as expired
*/
@Modifying
@Query(
"UPDATE TeamInvitation ti SET ti.status = 'EXPIRED' WHERE ti.status = 'PENDING' AND ti.expiresAt < :now")
int markExpiredInvitations(@Param("now") LocalDateTime now);
/**
* Check if there's already a pending invitation for this email to this team
*
* @param teamId the team ID
* @param email the invitee email
* @return true if pending invitation exists
*/
@Query(
"SELECT COUNT(ti) > 0 FROM TeamInvitation ti WHERE ti.team.id = :teamId "
+ "AND ti.inviteeEmail = :email AND ti.status = 'PENDING'")
boolean existsPendingInvitationByTeamIdAndEmail(
@Param("teamId") Long teamId, @Param("email") String email);
/**
* Find invitations by status
*
* @param status the invitation status
* @return List of invitations with given status
*/
List<TeamInvitation> findByStatus(InvitationStatus status);
/**
* Find invitations by status that expired before a given date
*
* @param status the invitation status
* @param cutoffDate the cutoff expiration date
* @return List of invitations
*/
List<TeamInvitation> findByStatusAndExpiresAtBefore(
InvitationStatus status, LocalDateTime cutoffDate);
}
@@ -0,0 +1,81 @@
package stirling.software.saas.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.saas.model.TeamMembership;
@Repository
public interface TeamMembershipRepository extends JpaRepository<TeamMembership, Long> {
/**
* Find team membership by team ID and user ID
*
* @param teamId the team ID
* @param userId the user ID
* @return Optional of TeamMembership if found
*/
Optional<TeamMembership> findByTeamIdAndUserId(Long teamId, Long userId);
/**
* Find all memberships for a team
*
* @param teamId the team ID
* @return List of team memberships
*/
@Query("SELECT tm FROM TeamMembership tm JOIN FETCH tm.user WHERE tm.team.id = :teamId")
List<TeamMembership> findByTeamId(@Param("teamId") Long teamId);
/**
* Find all memberships for a user (typically just one for personal team, but can be multiple if
* invited to other teams)
*
* @param userId the user ID
* @return List of team memberships
*/
@Query("SELECT tm FROM TeamMembership tm JOIN FETCH tm.team WHERE tm.user.id = :userId")
List<TeamMembership> findByUserId(@Param("userId") Long userId);
/**
* Find all members with a specific role in a team
*
* @param teamId the team ID
* @param role the team role (LEADER or MEMBER)
* @return List of team memberships
*/
@Query(
"SELECT tm FROM TeamMembership tm JOIN FETCH tm.user WHERE tm.team.id = :teamId AND tm.role = :role")
List<TeamMembership> findByTeamIdAndRole(
@Param("teamId") Long teamId, @Param("role") TeamRole role);
/**
* Check if a user is a member of a team
*
* @param teamId the team ID
* @param userId the user ID
* @return true if user is a member
*/
boolean existsByTeamIdAndUserId(Long teamId, Long userId);
/**
* Count members in a team
*
* @param teamId the team ID
* @return number of members
*/
long countByTeamId(Long teamId);
/**
* Delete membership by team ID and user ID
*
* @param teamId the team ID
* @param userId the user ID
*/
void deleteByTeamIdAndUserId(Long teamId, Long userId);
}
@@ -0,0 +1,148 @@
package stirling.software.saas.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.UserCredit;
/**
* JPA repository for {@link UserCredit}. Includes JPQL queries for the common read paths and native
* SQL for atomic credit-consumption updates (avoids select-then-update races).
*
* <p>Native queries reference {@code user_credits} and {@code users} unqualified — they pick up
* Hibernate's {@code default_schema} (set to {@code stirling_pdf} in {@code
* application-saas.properties}). Keeping the schema out of the SQL means a future schema rename is
* a one-property change instead of a sweep of native SQL.
*/
@Repository
public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
Optional<UserCredit> findByUser(User user);
Optional<UserCredit> findByUserId(Long userId);
@Query(
"SELECT uc FROM UserCredit uc WHERE uc.lastCycleResetAt IS NULL OR uc.lastCycleResetAt < :lastScheduledReset")
List<UserCredit> findCreditsNeedingCycleReset(
@Param("lastScheduledReset") LocalDateTime lastScheduledReset);
@Query("SELECT SUM(uc.totalApiCallsMade) FROM UserCredit uc")
Long getTotalApiCallsAcrossAllUsers();
@Query("SELECT SUM(uc.cycleCreditsRemaining + uc.boughtCreditsRemaining) FROM UserCredit uc")
Long getTotalAvailableCreditsAcrossAllUsers();
@Query("SELECT uc FROM UserCredit uc WHERE uc.user.apiKey = :apiKey")
Optional<UserCredit> findByUserApiKey(@Param("apiKey") String apiKey);
@Query("SELECT uc FROM UserCredit uc WHERE uc.user.supabaseId = :supabaseId")
Optional<UserCredit> findBySupabaseId(@Param("supabaseId") UUID supabaseId);
@Query("SELECT COUNT(uc) FROM UserCredit uc WHERE uc.lastApiUsage >= :since")
Long countActiveUsersInPeriod(@Param("since") LocalDateTime since);
@Modifying
@Query(
value =
"UPDATE user_credits "
+ "SET "
+ " cycle_credits_remaining = "
+ " CASE "
+ " WHEN cycle_credits_remaining >= :creditAmount THEN cycle_credits_remaining - :creditAmount "
+ " ELSE 0 "
+ " END, "
+ " bought_credits_remaining = "
+ " CASE "
+ " WHEN cycle_credits_remaining < :creditAmount "
+ " THEN GREATEST(0, bought_credits_remaining - (:creditAmount - cycle_credits_remaining)) "
+ " ELSE bought_credits_remaining "
+ " END, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT user_id FROM users WHERE api_key = :apiKey) "
+ " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)",
nativeQuery = true)
int consumeCredit(@Param("apiKey") String apiKey, @Param("creditAmount") int creditAmount);
@Modifying
@Query(
value =
"UPDATE user_credits "
+ "SET "
+ " cycle_credits_remaining = "
+ " CASE "
+ " WHEN cycle_credits_remaining >= :creditAmount THEN cycle_credits_remaining - :creditAmount "
+ " ELSE 0 "
+ " END, "
+ " bought_credits_remaining = "
+ " CASE "
+ " WHEN cycle_credits_remaining < :creditAmount "
+ " THEN GREATEST(0, bought_credits_remaining - (:creditAmount - cycle_credits_remaining)) "
+ " ELSE bought_credits_remaining "
+ " END, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
+ " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)",
nativeQuery = true)
int consumeCreditBySupabaseId(
@Param("supabaseId") UUID supabaseId, @Param("creditAmount") int creditAmount);
/**
* Consumes ONLY cycle credits (does not touch bought credits). Used in explicit waterfall
* logic.
*/
@Modifying
@Query(
value =
"UPDATE user_credits "
+ "SET "
+ " cycle_credits_remaining = cycle_credits_remaining - :amount, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
+ " AND cycle_credits_remaining >= :amount",
nativeQuery = true)
int consumeCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
/** Consumes ONLY bought credits (does not touch cycle credits). */
@Modifying
@Query(
value =
"UPDATE user_credits "
+ "SET "
+ " bought_credits_remaining = bought_credits_remaining - :amount, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
+ " AND bought_credits_remaining >= :amount",
nativeQuery = true)
int consumeBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
/** Checks if user has sufficient cycle credits (does NOT consume them). */
@Query(
value =
"SELECT CASE WHEN uc.cycle_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
+ "FROM user_credits uc "
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId)",
nativeQuery = true)
Boolean hasCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
/** Checks if user has sufficient bought credits (does NOT consume them). */
@Query(
value =
"SELECT CASE WHEN uc.bought_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
+ "FROM user_credits uc "
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId)",
nativeQuery = true)
Boolean hasBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
}
@@ -0,0 +1,41 @@
package stirling.software.saas.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.UserErrorTracker;
public interface UserErrorTrackerRepository extends JpaRepository<UserErrorTracker, Long> {
Optional<UserErrorTracker> findByUserAndEndpoint(User user, String endpoint);
Optional<UserErrorTracker> findByUserIdAndEndpoint(Long userId, String endpoint);
@Query(
"SELECT uet FROM UserErrorTracker uet WHERE uet.user.apiKey = :apiKey AND uet.endpoint = :endpoint")
Optional<UserErrorTracker> findByUserApiKeyAndEndpoint(
@Param("apiKey") String apiKey, @Param("endpoint") String endpoint);
@Query("SELECT uet FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime")
List<UserErrorTracker> findExpiredErrorTrackers(
@Param("currentDateTime") LocalDateTime currentDateTime);
@Modifying
@Query("DELETE FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime")
int deleteExpiredErrorTrackers(@Param("currentDateTime") LocalDateTime currentDateTime);
@Query(
"SELECT uet FROM UserErrorTracker uet WHERE uet.user = :user AND uet.processingErrorCount >= 3")
List<UserErrorTracker> findHighErrorCountForUser(@Param("user") User user);
@Query(
"SELECT COUNT(uet) FROM UserErrorTracker uet WHERE uet.processingErrorCount >= :threshold")
Long countUsersWithHighErrorCount(@Param("threshold") int threshold);
}
@@ -0,0 +1,46 @@
package stirling.software.saas.security;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
/**
* JWT auth token that exposes the Supabase subject UUID and email alongside the standard claims, so
* downstream code (audit, credit accounting) can avoid re-parsing the JWT every request.
*/
public class EnhancedJwtAuthenticationToken extends JwtAuthenticationToken {
private final String supabaseId;
private final String email;
public EnhancedJwtAuthenticationToken(
Jwt jwt,
Collection<? extends GrantedAuthority> authorities,
String email,
String supabaseId) {
super(jwt, authorities, email);
this.email = email;
this.supabaseId = supabaseId;
}
public String getSupabaseId() {
return supabaseId;
}
public String getEmail() {
return email;
}
@Override
public String toString() {
return "EnhancedJwtAuthenticationToken[email="
+ email
+ ", supabaseId="
+ supabaseId
+ ", authorities="
+ getAuthorities()
+ "]";
}
}
@@ -0,0 +1,429 @@
package stirling.software.saas.security;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static stirling.software.common.model.enumeration.Role.LIMITED_API_USER;
import static stirling.software.common.model.enumeration.Role.USER;
import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint;
import static stirling.software.common.util.RequestUriUtils.isStaticResource;
import static stirling.software.proprietary.security.model.AuthenticationType.ANONYMOUS;
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
import static stirling.software.proprietary.security.model.AuthenticationType.WEB;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.model.AmrMethod;
import stirling.software.saas.model.SupabaseUser;
import stirling.software.saas.model.exception.AuthenticationFailureException;
import stirling.software.saas.model.exception.UserNotFoundException;
import stirling.software.saas.service.SaasTeamService;
import stirling.software.saas.service.SupabaseUserService;
import stirling.software.saas.util.LogRedactionUtils;
/** Stateless JWT authentication filter for the saas profile. */
@Slf4j
public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
public static final String BEARER_PREFIX = "Bearer ";
public static final String ANON_PREFIX = "anon_";
private final TeamService teamService;
private final UserService userService;
private final SupabaseUserService supabaseUserService;
private final stirling.software.saas.service.CreditService creditService;
private final SaasTeamService saasTeamService;
private final JwtDecoder jwtDecoder;
private final AuthenticationEntryPoint authenticationEntryPoint =
new BearerTokenAuthenticationEntryPoint();
public SupabaseAuthenticationFilter(
TeamService teamService,
UserService userService,
SupabaseUserService supabaseUserService,
stirling.software.saas.service.CreditService creditService,
SaasTeamService saasTeamService,
JwtDecoder jwtDecoder) {
this.teamService = teamService;
this.userService = userService;
this.supabaseUserService = supabaseUserService;
this.creditService = creditService;
this.saasTeamService = saasTeamService;
this.jwtDecoder = jwtDecoder;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (isStaticResource(request.getContextPath(), request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
if (isPublicAuthEndpoint(request.getRequestURI(), request.getContextPath())) {
filterChain.doFilter(request, response);
return;
}
Authentication existingAuth = SecurityContextHolder.getContext().getAuthentication();
if (existingAuth != null && existingAuth.isAuthenticated()) {
filterChain.doFilter(request, response);
return;
}
try {
if (apiKeyAuthenticated(request)) {
filterChain.doFilter(request, response);
return;
}
processJwtAuthentication(request);
} catch (AuthenticationException e) {
SecurityContextHolder.clearContext();
authenticationEntryPoint.commence(request, response, e);
return;
}
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
if ("GET".equalsIgnoreCase(request.getMethod())
|| "HEAD".equalsIgnoreCase(request.getMethod())) {
if (RequestUriUtils.isStaticResource(contextPath, uri)
|| RequestUriUtils.isFrontendRoute(contextPath, uri)) {
return true;
}
}
return isPublicAuthEndpoint(uri, contextPath);
}
private void processJwtAuthentication(HttpServletRequest request)
throws AuthenticationException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
return;
}
String token = authHeader.substring(BEARER_PREFIX.length()).trim();
try {
Jwt jwt = jwtDecoder.decode(token);
String supabaseId = jwt.getSubject();
if (!validateRequiredClaims(jwt)) {
throw new InvalidBearerTokenException("Invalid JWT: missing required claims");
}
User user = getOrCreateUser(jwt);
EnhancedJwtAuthenticationToken authToken =
new EnhancedJwtAuthenticationToken(
jwt, user.getAuthorities(), user.getUsername(), supabaseId);
SecurityContextHolder.getContext().setAuthentication(authToken);
// Hot path: runs on every authenticated request (>10 per page on a typical SPA),
// so keep at DEBUG to avoid log-stream spam. Redact identifiers regardless so they
// don't leak even when an operator dials logging up. The same outcome (auth granted)
// is observable from the request/response logs of the controller chain.
if (log.isDebugEnabled()) {
log.debug(
"User {} authenticated via JWT (principal: {})",
LogRedactionUtils.redactSupabaseId(supabaseId),
LogRedactionUtils.redactEmail(user.getUsername()));
}
} catch (JwtException e) {
throw new InvalidBearerTokenException("Invalid JWT", e);
}
}
private boolean validateRequiredClaims(Jwt jwt) {
boolean isAnonymous = isAnonymous(jwt);
if (!isAnonymous && isBlank(jwt.getClaimAsString("email"))) {
return false;
}
String[] requiredClaims = {"iss", "aud", "exp", "iat", "sub", "role", "aal", "session_id"};
for (String claim : requiredClaims) {
switch (claim) {
case "iss", "sub", "role", "aal", "session_id" -> {
if (isBlank(jwt.getClaimAsString(claim))) {
return false;
}
}
case "aud" -> {
List<String> audience = jwt.getClaimAsStringList(claim);
if (audience == null || audience.isEmpty()) {
return false;
}
}
case "exp", "iat" -> {
Instant timestamp = jwt.getClaimAsInstant(claim);
if (timestamp == null) {
return false;
}
}
}
}
return true;
}
private User getOrCreateUser(Jwt jwt) throws AuthenticationException {
UUID supabaseId = UUID.fromString(jwt.getSubject());
String email = jwt.getClaimAsString("email");
Object metaObj = jwt.getClaims().get("app_metadata");
@SuppressWarnings("unchecked")
Map<String, Object> appMetadata =
(metaObj instanceof Map<?, ?>) ? (Map<String, Object>) metaObj : null;
try {
// First confirm the SupabaseUser row exists (this is the auth.users mirror).
// If not present, the JWT references a Supabase user this server hasn't synced.
SupabaseUser supabaseUser = supabaseUserService.getUser(supabaseId);
// Resolve to a local User by supabase_id.
Optional<User> linkedUser = userService.findBySupabaseId(supabaseId);
if (linkedUser.isPresent()) {
User user = linkedUser.get();
if (ANONYMOUS.toString().equalsIgnoreCase(user.getAuthenticationType())
&& !supabaseUser.isAnonymous()) {
user = upgradeAnonymousUser(user, supabaseUser, jwt);
}
return user;
}
return createUser(jwt, supabaseId, email, appMetadata);
} catch (UserNotFoundException e) {
throw new InvalidBearerTokenException("User not found", e);
} catch (InvalidBearerTokenException e) {
throw e;
} catch (IllegalArgumentException e) {
throw new InvalidBearerTokenException(
"Invalid authentication method: " + e.getMessage(), e);
} catch (Exception e) {
log.error("Failed to process user authentication for {}", supabaseId, e);
throw new AuthenticationFailureException("Failed to process user authentication", e);
}
}
/** Promote a local anonymous user to the real provider+email carried on the JWT. */
@Transactional
protected User upgradeAnonymousUser(User user, SupabaseUser supabaseUser, Jwt jwt) {
AuthenticationType newType = resolveUpgradedAuthType(jwt);
log.info(
"Upgrading anonymous user {} to {} (Supabase email: {})",
user.getId(),
newType,
LogRedactionUtils.redactEmail(supabaseUser.getEmail()));
user.setAuthenticationType(newType);
if (isNotBlank(supabaseUser.getEmail())) {
user.setEmail(supabaseUser.getEmail());
user.setUsername(supabaseUser.getEmail());
}
try {
return userService.saveUser(user);
} catch (DataIntegrityViolationException e) {
log.warn(
"Email collision upgrading anonymous user {} to {}: {}",
user.getId(),
LogRedactionUtils.redactEmail(supabaseUser.getEmail()),
e.getMessage());
throw new AuthenticationFailureException(
"Cannot upgrade anonymous account: email already in use", e);
}
}
/** Maps Supabase's {@code amr} claim to an {@link AuthenticationType}; defaults to WEB. */
private AuthenticationType resolveUpgradedAuthType(Jwt jwt) {
try {
Object raw = jwt.getClaims().get("amr");
if (!(raw instanceof List<?> amrList) || amrList.isEmpty()) {
return WEB;
}
Object first = amrList.get(0);
if (!(first instanceof Map<?, ?> entry)) {
return WEB;
}
Object methodObj = entry.get("method");
if (methodObj == null) {
return WEB;
}
String method = methodObj.toString().toLowerCase(Locale.ROOT);
for (AmrMethod amr : AmrMethod.values()) {
if (amr.getMethod().equals(method)) {
return switch (amr) {
case OAUTH, SSO_SAML -> OAUTH2;
default -> WEB;
};
}
}
return WEB;
} catch (Exception e) {
log.debug("Could not resolve upgraded auth type from amr claim; defaulting to WEB", e);
return WEB;
}
}
@Transactional
protected User createUser(
Jwt jwt, UUID supabaseId, String email, Map<String, Object> appMetadata) {
User newUser = new User();
AuthenticationType authenticationType = WEB;
String roleId = USER.getRoleId();
if (isAnonymous(jwt)) {
email = ANON_PREFIX + jwt.getSubject();
authenticationType = ANONYMOUS;
roleId = LIMITED_API_USER.getRoleId();
} else {
if (appMetadata == null || !appMetadata.containsKey("provider")) {
throw new AuthenticationFailureException(
"Missing provider in app_metadata for non-anonymous user");
}
String provider = String.valueOf(appMetadata.get("provider"));
// "email" is the password / magic-link flow; everything else Supabase exposes is an
// external IdP (OAuth or SAML). Treat unknown non-email providers as OAUTH2 rather
// than silently downgrading to WEB.
if (provider != null && !provider.isBlank() && !"email".equalsIgnoreCase(provider)) {
authenticationType = OAUTH2;
}
if (isNotBlank(email)) {
newUser.setEmail(email);
}
if (email != null && email.startsWith(ANON_PREFIX)) {
throw new AuthenticationFailureException(
"Invalid email format for non-anonymous user");
}
}
newUser.setUsername(email);
newUser.setEnabled(true);
newUser.setFirstLogin(true);
newUser.setRoleName(roleId);
newUser.setTeam(teamService.getOrCreateDefaultTeam());
newUser.setAuthenticationType(authenticationType);
newUser.setSupabaseId(supabaseId);
newUser.addAuthority(new Authority(roleId, newUser));
// Create or fetch the auth.users mirror row.
try {
boolean isAnon = isAnonymous(jwt);
supabaseUserService.createSupabaseUser(supabaseId, isAnon ? null : email, isAnon);
} catch (DataIntegrityViolationException ignored) {
// Concurrent creation; fall through, the row exists.
} catch (Exception e) {
throw new AuthenticationFailureException("Failed to create SupabaseUser", e);
}
User savedUser;
boolean weCreatedThisUser = true;
try {
savedUser = userService.saveUser(newUser);
} catch (DataIntegrityViolationException dup) {
// Parallel filter won the race; fetch the winning row.
weCreatedThisUser = false;
savedUser =
userService
.findBySupabaseId(supabaseId)
.orElseThrow(
() ->
new AuthenticationFailureException(
"User creation conflict, but unable to find existing user",
dup));
}
// Only the DB-race winner runs first-time init; the losers skip it.
if (weCreatedThisUser) {
try {
creditService.getOrCreateUserCredits(savedUser);
} catch (Exception e) {
log.warn(
"Failed to initialize credits for new user {} ({}): {}",
LogRedactionUtils.redactSupabaseId(supabaseId),
LogRedactionUtils.redactEmail(savedUser.getUsername()),
e.getMessage());
}
try {
saasTeamService.createPersonalTeam(savedUser);
savedUser = userService.findBySupabaseId(supabaseId).orElse(savedUser);
} catch (Exception e) {
log.warn(
"Failed to create personal team for new user {} ({}): {}",
LogRedactionUtils.redactSupabaseId(supabaseId),
LogRedactionUtils.redactEmail(savedUser.getUsername()),
e.getMessage());
}
}
return savedUser;
}
private boolean apiKeyAuthenticated(HttpServletRequest request) throws AuthenticationException {
Authentication existing = SecurityContextHolder.getContext().getAuthentication();
if (existing != null && existing.isAuthenticated()) {
return true;
}
String apiKey = request.getHeader("X-API-KEY");
if (isBlank(apiKey)) {
return false;
}
Optional<User> user = userService.getUserByApiKey(apiKey);
if (user.isEmpty()) {
throw new InvalidBearerTokenException("Invalid API Key.");
}
userService.trackApiKeyFirstUse(user.get());
ApiKeyAuthenticationToken authToken =
new ApiKeyAuthenticationToken(user.get(), apiKey, user.get().getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
return true;
}
private static boolean isAnonymous(Jwt jwt) {
return Boolean.TRUE.equals(jwt.getClaimAsBoolean("is_anonymous"));
}
}
@@ -0,0 +1,338 @@
package stirling.software.saas.security;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.SaasTeamService;
import stirling.software.saas.service.SupabaseUserService;
/** Stateless Supabase-JWT security chain. */
@Slf4j
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@Profile("saas")
@Order(1)
@RequiredArgsConstructor
public class SupabaseSecurityConfig {
private final UserService userService;
private final TeamService teamService;
private final SupabaseUserService supabaseUserService;
private final CreditService creditService;
private final SaasTeamService saasTeamService;
private final ApplicationProperties applicationProperties;
@Value("${app.supabase.issuer:}")
private String issuer;
/** Optional audience claim to enforce. Empty means do not validate the {@code aud} claim. */
@Value("${app.supabase.expected-aud:}")
private String expectedAud;
/** Clock skew tolerance (seconds) applied to the {@code exp} claim. */
@Value("${app.supabase.clock-skew-seconds:120}")
private long clockSkewSeconds;
@Bean
SecurityFilterChain saasSecurityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder)
throws Exception {
// CSRF protection intentionally disabled: this chain is bearer-token only (Supabase JWT in
// Authorization header / X-API-KEY) with SessionCreationPolicy.STATELESS, so there is no
// cookie- or session-bound credential a cross-site request could ride on. Re-enabling CSRF
// would require synchronizer tokens which don't make sense for a stateless JSON API.
// lgtm[java/spring-disabled-csrf-protection]
http.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(
auth ->
auth.requestMatchers(HttpMethod.OPTIONS, "/**")
.permitAll()
.requestMatchers("/actuator/health", "/api/v1/config/**")
.permitAll()
.requestMatchers(
req ->
RequestUriUtils.isStaticResource(
req.getContextPath(),
req.getRequestURI())
|| RequestUriUtils
.isPublicAuthEndpoint(
req.getRequestURI(),
req
.getContextPath())
|| RequestUriUtils.isFrontendRoute(
req.getContextPath(),
req.getRequestURI()))
.permitAll()
.anyRequest()
.authenticated())
.addFilterBefore(
new SupabaseAuthenticationFilter(
teamService,
userService,
supabaseUserService,
creditService,
saasTeamService,
jwtDecoder),
BearerTokenAuthenticationFilter.class)
.exceptionHandling(
ex ->
ex.authenticationEntryPoint(
new BearerTokenAuthenticationEntryPoint())
.accessDeniedHandler(new BearerTokenAccessDeniedHandler()))
.oauth2ResourceServer(
oauth ->
oauth.jwt(
jwt ->
jwt.decoder(jwtDecoder)
.jwtAuthenticationConverter(
SupabaseSecurityConfig
::toAuthentication)));
return http.build();
}
@Bean
JwtDecoder jwtDecoder() {
String issuerError = validateIssuer(issuer);
if (issuerError != null) {
log.warn(
"{} saas profile is active but JWTs cannot be validated. Set SAAS_DB_PROJECT_REF"
+ " (or app.supabase.issuer) in application-saas.properties or via env.",
issuerError);
// Build a decoder that will reject every token; failing closed is safer than failing
// open when configuration is incomplete.
final String reason = issuerError;
return token -> {
throw new org.springframework.security.oauth2.jwt.JwtException(reason);
};
}
String jwks = issuer + "/.well-known/jwks.json";
log.info("Configuring JWT decoder with JWKS: {}", jwks);
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
// Defence-in-depth: signature verification already binds the token to this Supabase
// project's JWKS, but enforcing iss/exp/aud explicitly catches tokens that smuggle
// through (e.g. JWKS reuse across projects, clock-skew abuse, missing aud).
decoder.setJwtValidator(
new SupabaseTokenValidator(
issuer, expectedAud, Duration.ofSeconds(clockSkewSeconds)));
if (expectedAud == null || expectedAud.isBlank()) {
log.info(
"JWT validation: enforcing issuer='{}' and exp (skew={}s); aud check disabled",
issuer,
clockSkewSeconds);
} else {
log.info(
"JWT validation: enforcing issuer='{}', aud='{}', and exp (skew={}s)",
issuer,
expectedAud,
clockSkewSeconds);
}
return decoder;
}
/** Returns {@code null} if the issuer URL is usable, otherwise a short reason string. */
static String validateIssuer(String issuer) {
if (issuer == null || issuer.isBlank()) {
return "app.supabase.issuer is not set;";
}
URI uri;
try {
uri = new URI(issuer);
} catch (URISyntaxException e) {
return "app.supabase.issuer is not a valid URI (" + issuer + ");";
}
String host = uri.getHost();
if (host == null || host.isBlank() || host.startsWith(".")) {
return "app.supabase.issuer has an empty host ("
+ issuer
+ "); likely SAAS_DB_PROJECT_REF is unset;";
}
String scheme = uri.getScheme();
if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) {
return "app.supabase.issuer must be http(s) (" + issuer + ");";
}
return null;
}
/** Validates iss, exp (with clock-skew) and optionally aud on a decoded Supabase JWT. */
static final class SupabaseTokenValidator implements OAuth2TokenValidator<Jwt> {
private final String expectedIssuer;
private final String expectedAudienceOrNull;
private final Duration skew;
SupabaseTokenValidator(
String expectedIssuer, String expectedAudienceOrNull, Duration skew) {
this.expectedIssuer = Objects.requireNonNull(expectedIssuer, "expectedIssuer");
this.expectedAudienceOrNull =
(expectedAudienceOrNull != null && !expectedAudienceOrNull.isBlank())
? expectedAudienceOrNull
: null;
this.skew = Objects.requireNonNull(skew, "skew");
}
@Override
public OAuth2TokenValidatorResult validate(Jwt token) {
List<OAuth2Error> errors = new ArrayList<>();
String iss = token.getIssuer() != null ? token.getIssuer().toString() : null;
if (iss == null || !iss.equals(expectedIssuer)) {
errors.add(new OAuth2Error("invalid_token", "Invalid issuer: " + iss, null));
}
Instant exp = token.getExpiresAt();
if (exp == null) {
errors.add(new OAuth2Error("invalid_token", "Missing exp claim", null));
} else if (exp.isBefore(Instant.now().minus(skew))) {
errors.add(new OAuth2Error("invalid_token", "Token expired at " + exp, null));
}
if (expectedAudienceOrNull != null) {
List<String> aud = token.getAudience();
if (aud == null || !aud.contains(expectedAudienceOrNull)) {
errors.add(
new OAuth2Error(
"invalid_token",
"Missing/invalid audience: " + expectedAudienceOrNull,
null));
}
}
return errors.isEmpty()
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(errors);
}
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
boolean operatorOverride =
applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty();
List<String> origins =
operatorOverride
? applicationProperties.getSystem().getCorsAllowedOrigins()
: List.of(
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:8080",
"https://stirling.com",
"https://app.stirling.com",
"https://api.stirling.com");
if (origins.stream().anyMatch(o -> o.contains("*"))) {
log.warn(
"CORS origins contain a wildcard paired with allowCredentials=true: {}."
+ " Wildcard subdomains can be taken over by an attacker (lapsed DNS,"
+ " abandoned vhost) and would receive credentialed responses. Pin to"
+ " specific hostnames.",
origins);
}
cfg.setAllowedOriginPatterns(origins);
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY"));
cfg.setExposedHeaders(List.of("WWW-Authenticate", "X-Credits-Remaining"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
/**
* Maps Supabase JWT claims onto Spring Security authorities. Package-private static so unit
* tests can call it directly without instantiating the full security config.
*/
static AbstractAuthenticationToken toAuthentication(Jwt jwt) {
List<GrantedAuthority> authorities = new ArrayList<>();
// Transient (non-persisted) authorities for the JWT principal. Use
// SimpleGrantedAuthority rather than the @Entity Authority class.
boolean isAnonymous = Boolean.TRUE.equals(jwt.getClaimAsBoolean("is_anonymous"));
String supabaseRole = jwt.getClaimAsString("role");
if (supabaseRole != null && !supabaseRole.isBlank()) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + supabaseRole));
}
String appRole = jwt.getClaimAsString("app_role");
if (appRole != null && !appRole.isBlank()) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + appRole.toUpperCase(Locale.ROOT)));
}
authorities.add(
new SimpleGrantedAuthority(isAnonymous ? "ROLE_LIMITED_API_USER" : "ROLE_USER"));
List<String> perms = jwt.getClaimAsStringList("permissions");
if (perms != null) {
perms.stream()
.filter(p -> p != null && !p.isBlank())
.map(p -> new SimpleGrantedAuthority("PERM_" + p))
.forEach(authorities::add);
}
String email = jwt.getClaimAsString("email");
String supabaseId = jwt.getSubject();
if (log.isDebugEnabled()) {
log.debug(
"JWT accepted: email='{}', supabaseId='{}', authorities='{}'",
email,
supabaseId,
authorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(",")));
}
return new EnhancedJwtAuthenticationToken(jwt, authorities, email, supabaseId);
}
}
@@ -0,0 +1,82 @@
package stirling.software.saas.security;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.repository.TeamMembershipRepository;
/**
* Security expressions for team-based authorization in saas mode. Wired into
* {@code @PreAuthorize("@teamSecurity.isTeamLeader(#teamId)")} annotations on {@code
* SaasTeamController} endpoints.
*
* <p>The current-user resolution uses the saas-mode authentication primitives ({@link
* EnhancedJwtAuthenticationToken} from a Supabase JWT, or our existing API-key path) and looks the
* local {@link User} row up via {@link UserService#findBySupabaseId(UUID)}.
*/
@Component("teamSecurity")
@Profile("saas")
@RequiredArgsConstructor
public class TeamSecurityExpressions {
private final TeamMembershipRepository membershipRepository;
private final UserService userService;
/** Whether the current authenticated user is a {@code LEADER} of the given team. */
public boolean isTeamLeader(Long teamId) {
User currentUser = getCurrentUser();
if (currentUser == null) {
return false;
}
return membershipRepository
.findByTeamIdAndUserId(teamId, currentUser.getId())
.map(membership -> membership.getRole() == TeamRole.LEADER)
.orElse(false);
}
/** Whether the current authenticated user is any kind of member of the given team. */
public boolean isTeamMember(Long teamId) {
User currentUser = getCurrentUser();
if (currentUser == null) {
return false;
}
return membershipRepository.existsByTeamIdAndUserId(teamId, currentUser.getId());
}
/** Resolve the current user from a saas-mode JWT or API-key authentication. */
private User getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return null;
}
if (authentication instanceof EnhancedJwtAuthenticationToken jwt) {
try {
UUID supabaseId = UUID.fromString(jwt.getSupabaseId());
return userService.findBySupabaseId(supabaseId).orElse(null);
} catch (IllegalArgumentException e) {
return null;
}
}
// API-key path: the principal is the User entity itself.
Object principal = authentication.getPrincipal();
if (principal instanceof User user) {
return user;
}
// Username fallback.
if (principal instanceof String username) {
Optional<User> byUsername = userService.findByUsername(username);
return byUsername.orElse(null);
}
return null;
}
}
@@ -0,0 +1,86 @@
package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.saas.repository.SupabaseUserRepository;
/**
* Service to periodically clean up anonymous users older than 30 days. Based on Supabase's
* recommendation for anonymous user management.
*/
@Slf4j
@Service
@Profile("saas")
@RequiredArgsConstructor
public class AnonymousUserCleanupService {
@Value("${app.auth.anonymous.enabled:true}")
private boolean anonEnabled;
@Value("${app.auth.anonymous.retention-days:30}")
private int retentionDays;
@Value("${app.auth.anonymous.cleanup-batch-size:100}")
private int batchSize;
private final UserRepository userRepository;
private final SupabaseUserRepository supabaseUserRepository;
/**
* Scheduled task that runs daily to clean up anonymous users based on configured retention
* policy. This follows Supabase's recommendation for anonymous user cleanup.
*/
@Transactional
@Scheduled(cron = "0 0 2 * * ?")
public void cleanup() {
if (!anonEnabled) {
return;
}
if (retentionDays <= 0) {
return;
}
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(retentionDays);
log.info("Removing accounts created before {}", cutoffDate);
batchDeleteSupabaseUsers(cutoffDate, batchSize);
batchDeleteUsers(cutoffDate, batchSize);
}
private void batchDeleteSupabaseUsers(LocalDateTime cutoffDate, int batchSize) {
try (Stream<UUID> idStream =
supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(cutoffDate)) {
AtomicInteger counter = new AtomicInteger();
idStream.collect(Collectors.groupingBy(id -> counter.getAndIncrement() / batchSize))
.values()
.forEach(supabaseUserRepository::deleteAllByIdInBatch);
}
}
private void batchDeleteUsers(LocalDateTime cutoffDate, int batchSize) {
try (Stream<Long> idStream =
userRepository.findByUsernameIsNullAndCreatedAtBefore(cutoffDate)) {
AtomicInteger counter = new AtomicInteger();
idStream.collect(Collectors.groupingBy(id -> counter.getAndIncrement() / batchSize))
.values()
.forEach(userRepository::deleteAllByIdInBatch);
}
}
}
@@ -0,0 +1,78 @@
package stirling.software.saas.service;
import java.util.List;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
/**
* ApplicationRunner that backfills user_credits table for existing users who don't have credit rows
* yet. This prevents existing users from being hard-blocked when the credit system is enabled.
*
* <p>This runs once at application startup after the database schema is ready.
*/
@Component
@Profile("saas")
@ConditionalOnProperty(name = "credits.enabled", havingValue = "true", matchIfMissing = true)
@RequiredArgsConstructor
@Slf4j
public class CreditBackfillRunner implements ApplicationRunner {
private final UserRepository userRepository;
private final CreditService creditService;
@Override
@Transactional
public void run(ApplicationArguments args) {
try {
backfillUserCredits();
} catch (Exception e) {
log.error("Failed to backfill user credits", e);
// Don't throw; this shouldn't prevent app startup
}
}
private void backfillUserCredits() {
log.info("Starting user credits backfill for existing users...");
List<User> usersNeedingCredits = userRepository.findUsersWithApiKeyButNoCredits();
if (usersNeedingCredits.isEmpty()) {
log.info(
"No users need credit backfill; all users with API keys already have credit rows");
return;
}
log.info("Found {} users with API keys that need credit rows", usersNeedingCredits.size());
int backfilled = 0;
for (User user : usersNeedingCredits) {
try {
// Use the existing getOrCreateUserCredits method which handles proper allocation
creditService.getOrCreateUserCredits(user);
backfilled++;
if (backfilled % 100 == 0) {
log.info("Backfilled credits for {} users so far...", backfilled);
}
} catch (Exception e) {
log.warn(
"Failed to create credits for user {}: {}",
user.getUsername(),
e.getMessage());
}
}
log.info("Successfully backfilled user_credits for {} existing users", backfilled);
}
}
@@ -0,0 +1,107 @@
package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.TemporalAdjusters;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.config.CreditsProperties;
@Service
@Profile("saas")
@Slf4j
@RequiredArgsConstructor
public class CreditResetScheduler {
private final CreditService creditService;
private final CreditsProperties creditsProperties;
/**
* Reset cycle credits for all users and teams on the 1st of each month at 2 AM UTC This runs
* monthly, resetting credits based on user roles and team seats
*/
@Scheduled(cron = "${credits.reset.cron:0 0 2 1 * *}", zone = "${credits.reset.zone:UTC}")
public void resetCycleCredits() {
log.info(
"Starting monthly credit reset for all users and teams (schedule: {}, zone: {})",
creditsProperties.getReset().getCron(),
creditsProperties.getReset().getZone());
try {
ZoneId configuredZone = ZoneId.of(creditsProperties.getReset().getZone());
LocalDateTime resetTime = LocalDateTime.now(configuredZone);
creditService.resetCycleCreditsForAllUsers(resetTime);
creditService.resetCycleCreditsForAllTeams(resetTime);
log.info("Monthly credit reset completed successfully at {}", resetTime);
} catch (Exception e) {
log.error("Error during monthly credit reset", e);
}
}
/** Check for missed resets on application startup */
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
try {
ZoneId configuredZone = ZoneId.of(creditsProperties.getReset().getZone());
LocalDateTime now = LocalDateTime.now(configuredZone);
LocalDateTime lastScheduledReset = getMostRecentScheduledReset(now, configuredZone);
log.info(
"Checking for missed cycle credit resets. Last scheduled: {}, Current: {}",
lastScheduledReset,
now);
creditService.resetCycleCreditsForAllUsers(lastScheduledReset);
creditService.resetCycleCreditsForAllTeams(lastScheduledReset);
log.info("Catch-up cycle credit reset completed");
} catch (Exception e) {
log.error("Error during catch-up credit reset", e);
}
}
/** Get the most recent scheduled reset time based on configured schedule and zone */
private LocalDateTime getMostRecentScheduledReset(LocalDateTime now, ZoneId configuredZone) {
ZonedDateTime zonedNow = now.atZone(configuredZone);
// Find the 1st of the current month at the configured time (default 02:00)
ZonedDateTime firstOfMonth =
zonedNow.with(TemporalAdjusters.firstDayOfMonth())
.withHour(2)
.withMinute(0)
.withSecond(0)
.withNano(0);
// If it's the 1st and before the reset hour, or if current time is before the 1st at 2 AM,
// go to previous month's 1st
if (zonedNow.isBefore(firstOfMonth)) {
firstOfMonth = firstOfMonth.minusMonths(1);
}
return firstOfMonth.toLocalDateTime();
}
/**
* Cleanup and maintenance task; runs daily at 3 AM UTC. Performs maintenance tasks like
* cleaning up old data.
*/
@Scheduled(cron = "0 0 3 * * *", zone = "UTC")
public void performDailyMaintenance() {
log.debug("Starting daily credit system maintenance");
try {
// API call history cleanup is no longer needed; audit system handles this
log.debug("Daily credit system maintenance completed");
} catch (Exception e) {
log.error("Error during daily credit system maintenance", e);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,315 @@
package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
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.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.model.ProcessingErrorType;
import stirling.software.saas.model.UserErrorTracker;
import stirling.software.saas.repository.UserErrorTrackerRepository;
@Service
@Profile("saas")
@Slf4j
@Transactional
public class ErrorTrackingService {
private final UserErrorTrackerRepository errorTrackerRepository;
private final UserRepository userRepository;
private final CreditsProperties creditsProperties;
/**
* Local cache for error counts to reduce database chatter.
*
* <p>This cache is used to temporarily store error counts for each API key and endpoint,
* reducing the frequency of database writes and lookups.
*
* <p><b>Nullability:</b> This field may be {@code null} if local caching is disabled via {@link
* CreditsProperties#getCache()#isLocalEnabled()}. All usages must check for null before
* accessing or invoking methods on this cache.
*
* <p><b>Lifecycle:</b> The cache is initialized in the constructor based on configuration and
* remains unchanged for the lifetime of this service instance.
*
* <p><b>Thread-safety:</b> The underlying Caffeine cache is thread-safe.
*/
private final Cache<String, ErrorCountCache> errorCountCache;
public ErrorTrackingService(
UserErrorTrackerRepository errorTrackerRepository,
UserRepository userRepository,
CreditsProperties creditsProperties) {
this.errorTrackerRepository = errorTrackerRepository;
this.userRepository = userRepository;
this.creditsProperties = creditsProperties;
// Initialize cache based on configuration
this.errorCountCache =
creditsProperties.getCache().isLocalEnabled()
? Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(
creditsProperties.getErrors().getTtlMinutes(),
TimeUnit.MINUTES)
.build()
: null;
}
/**
* Record an error and determine if credits should be consumed
*
* @param apiKey User's API key
* @param endpoint The endpoint that failed
* @param throwable The exception that occurred
* @param httpStatus HTTP response status
* @return true if credits should be consumed for this error
*/
public boolean recordErrorAndShouldConsumeCredit(
String apiKey, String endpoint, Throwable throwable, int httpStatus) {
ProcessingErrorType errorType =
ProcessingErrorType.classifyError(throwable, httpStatus, endpoint);
// Never charge for validation errors or system errors
if (errorType != ProcessingErrorType.PROCESSING_ERROR) {
log.debug(
"Error classified as {}, no credit consumption for API key: {}, endpoint: {}",
errorType,
maskApiKey(apiKey),
endpoint);
return false;
}
String cacheKey = apiKey + "|" + endpoint;
if (errorCountCache != null) {
// Use cache for fast tracking
ErrorCountCache cachedCount = errorCountCache.get(cacheKey, k -> new ErrorCountCache());
cachedCount.incrementErrorCount();
boolean shouldCharge =
cachedCount.getErrorCount()
> creditsProperties.getErrors().getFreeProcessingErrors();
// Persist to DB when crossing the charging threshold or on first error
if (shouldCharge
&& cachedCount.getErrorCount()
== creditsProperties.getErrors().getFreeProcessingErrors() + 1) {
persistErrorToDatabase(apiKey, endpoint);
}
log.info(
"Processing error recorded (cached) for API key: {}, endpoint: {}, error count: {}, will charge: {}",
maskApiKey(apiKey),
endpoint,
cachedCount.getErrorCount(),
shouldCharge);
return shouldCharge;
} else {
// Fallback to direct DB tracking
return recordErrorDirectToDatabase(apiKey, endpoint);
}
}
private boolean recordErrorDirectToDatabase(String apiKey, String endpoint) {
Optional<User> userOpt = userRepository.findByApiKey(apiKey);
if (userOpt.isEmpty()) {
log.warn("User not found for API key: {}", maskApiKey(apiKey));
return false;
}
User user = userOpt.get();
UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint);
tracker.recordProcessingError(creditsProperties.getErrors().getTtlMinutes());
errorTrackerRepository.save(tracker);
boolean shouldCharge =
tracker.shouldChargeForProcessingError(
creditsProperties.getErrors().getFreeProcessingErrors());
log.info(
"Processing error recorded (DB) for user: {}, endpoint: {}, error count: {}, will charge: {}",
user.getUsername(),
endpoint,
tracker.getProcessingErrorCount(),
shouldCharge);
return shouldCharge;
}
private void persistErrorToDatabase(String apiKey, String endpoint) {
try {
Optional<User> userOpt = userRepository.findByApiKey(apiKey);
if (userOpt.isPresent()) {
User user = userOpt.get();
UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint);
// Set to threshold + 1 to indicate charging has started
tracker.setProcessingErrorCount(
creditsProperties.getErrors().getFreeProcessingErrors() + 1);
tracker.setLastProcessingError(LocalDateTime.now());
tracker.setResetAfter(
LocalDateTime.now()
.plusMinutes(creditsProperties.getErrors().getTtlMinutes()));
errorTrackerRepository.save(tracker);
log.debug(
"Persisted error threshold crossing to DB for API key: {}, endpoint: {}",
maskApiKey(apiKey),
endpoint);
}
} catch (Exception e) {
log.error(
"Failed to persist error to database for API key: {}, endpoint: {}",
maskApiKey(apiKey),
endpoint,
e);
}
}
/** Check if a user has high error counts that might indicate abuse */
public boolean hasHighErrorCount(String apiKey, String endpoint) {
Optional<UserErrorTracker> trackerOpt =
errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint);
return trackerOpt
.map(
t ->
t.shouldChargeForProcessingError(
creditsProperties.getErrors().getFreeProcessingErrors()))
.orElse(false);
}
/** Get error information for a user and endpoint */
public ErrorInfo getErrorInfo(String apiKey, String endpoint) {
String cacheKey = apiKey + "|" + endpoint;
if (errorCountCache != null) {
// Check cache first
ErrorCountCache cachedCount = errorCountCache.getIfPresent(cacheKey);
if (cachedCount != null) {
int currentCount = cachedCount.getErrorCount();
int freeErrors = creditsProperties.getErrors().getFreeProcessingErrors();
return new ErrorInfo(
currentCount,
Math.max(0, freeErrors - currentCount),
currentCount > freeErrors,
cachedCount.getLastErrorTime());
}
}
// Fallback to DB
Optional<UserErrorTracker> trackerOpt =
errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint);
if (trackerOpt.isEmpty()) {
return new ErrorInfo(
0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null);
}
UserErrorTracker tracker = trackerOpt.get();
// Reset if expired
if (tracker.isExpired()) {
tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes());
errorTrackerRepository.save(tracker);
return new ErrorInfo(
0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null);
}
return new ErrorInfo(
tracker.getProcessingErrorCount(),
tracker.getErrorsUntilCharged(
creditsProperties.getErrors().getFreeProcessingErrors()),
tracker.shouldChargeForProcessingError(
creditsProperties.getErrors().getFreeProcessingErrors()),
tracker.getLastProcessingError());
}
private UserErrorTracker getOrCreateErrorTracker(User user, String endpoint) {
Optional<UserErrorTracker> existing =
errorTrackerRepository.findByUserAndEndpoint(user, endpoint);
if (existing.isPresent()) {
UserErrorTracker tracker = existing.get();
// Reset if expired
if (tracker.isExpired()) {
tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes());
}
return tracker;
}
// Create new tracker
return new UserErrorTracker(user, endpoint, creditsProperties.getErrors().getTtlMinutes());
}
/** Clean up expired error trackers every hour */
@Scheduled(cron = "0 0 * * * *")
public void cleanupExpiredErrorTrackers() {
try {
int deleted = errorTrackerRepository.deleteExpiredErrorTrackers(LocalDateTime.now());
if (deleted > 0) {
log.debug("Cleaned up {} expired error trackers", deleted);
}
} catch (Exception e) {
log.error("Error cleaning up expired error trackers", e);
}
}
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.length() < 8) {
return "***";
}
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
}
/** Information about user's error status for an endpoint */
public static class ErrorInfo {
public final int currentErrorCount;
public final int errorsUntilCharged;
public final boolean isChargingForErrors;
public final LocalDateTime lastError;
public ErrorInfo(
int currentErrorCount,
int errorsUntilCharged,
boolean isChargingForErrors,
LocalDateTime lastError) {
this.currentErrorCount = currentErrorCount;
this.errorsUntilCharged = errorsUntilCharged;
this.isChargingForErrors = isChargingForErrors;
this.lastError = lastError;
}
}
/** Cache entry for tracking error counts in memory */
private static class ErrorCountCache {
private int errorCount = 0;
private LocalDateTime lastErrorTime = LocalDateTime.now();
public void incrementErrorCount() {
errorCount++;
lastErrorTime = LocalDateTime.now();
}
public int getErrorCount() {
return errorCount;
}
public LocalDateTime getLastErrorTime() {
return lastErrorTime;
}
}
}
@@ -0,0 +1,55 @@
package stirling.software.saas.service;
import java.util.Collections;
import java.util.List;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.FileInfo;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
/** Saas-profile {@link DatabaseServiceInterface}: every method is a safe no-op. */
@Service
@Profile("saas")
@Slf4j
public class NoOpDatabaseService implements DatabaseServiceInterface {
@Override
public void exportDatabase() {
log.debug("[saas] exportDatabase() skipped");
}
@Override
public void importDatabase() {
log.debug("[saas] importDatabase() skipped");
}
@Override
public boolean hasBackup() {
return false;
}
@Override
public List<FileInfo> getBackupList() {
return Collections.emptyList();
}
@Override
public List<Pair<FileInfo, Boolean>> deleteAllBackups() {
return Collections.emptyList();
}
@Override
public List<Pair<FileInfo, Boolean>> deleteLastBackup() {
return Collections.emptyList();
}
@Override
public String getH2Version() {
return "N/A (managed Postgres)";
}
}
@@ -0,0 +1,169 @@
package stirling.software.saas.service;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Simple in-memory rate limiting service. Tracks attempts per key (e.g., user ID or team ID) and
* enforces limits.
*/
@Service
@Profile("saas")
@Slf4j
public class RateLimitService {
// Rate limit configurations
private static final int INVITATION_LIMIT_PER_HOUR = 50;
private static final int INVITATION_LIMIT_PER_DAY = 150;
// In-memory storage: key -> (count, resetTime)
private final ConcurrentHashMap<String, RateLimitBucket> hourlyLimits =
new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, RateLimitBucket> dailyLimits =
new ConcurrentHashMap<>();
/**
* Check if an invitation attempt is allowed for a team.
*
* @param teamId the team ID
* @return true if allowed, false if rate limit exceeded
*/
public boolean allowInvitation(Long teamId) {
String key = "team:" + teamId;
// Check hourly limit
if (!checkAndIncrement(hourlyLimits, key, INVITATION_LIMIT_PER_HOUR, Duration.ofHours(1))) {
log.warn("Team {} exceeded hourly invitation limit", teamId);
return false;
}
// Check daily limit
if (!checkAndIncrement(dailyLimits, key, INVITATION_LIMIT_PER_DAY, Duration.ofDays(1))) {
log.warn("Team {} exceeded daily invitation limit", teamId);
// Decrement hourly counter since we're rejecting
decrementCounter(hourlyLimits, key);
return false;
}
log.debug("Team {} invitation allowed", teamId);
return true;
}
/**
* Get remaining invitation quota for a team.
*
* @param teamId the team ID
* @return remaining invitations allowed this hour
*/
public int getRemainingInvitations(Long teamId) {
String key = "team:" + teamId;
RateLimitBucket bucket = hourlyLimits.get(key);
if (bucket == null || bucket.isExpired()) {
return INVITATION_LIMIT_PER_HOUR;
}
return Math.max(0, INVITATION_LIMIT_PER_HOUR - bucket.getCount());
}
/** Check rate limit and increment counter if allowed. */
private boolean checkAndIncrement(
ConcurrentHashMap<String, RateLimitBucket> storage,
String key,
int limit,
Duration window) {
long now = System.currentTimeMillis();
long resetTime = now + window.toMillis();
RateLimitBucket bucket =
storage.compute(
key,
(k, existing) -> {
if (existing == null || existing.isExpired()) {
return new RateLimitBucket(1, resetTime);
} else {
existing.increment();
return existing;
}
});
return bucket.getCount() <= limit;
}
/** Decrement counter (for rollback scenarios). */
private void decrementCounter(ConcurrentHashMap<String, RateLimitBucket> storage, String key) {
storage.computeIfPresent(
key,
(k, bucket) -> {
bucket.decrement();
return bucket;
});
}
/** Cleanup expired buckets every hour. */
@Scheduled(fixedRate = 3600000) // 1 hour
public void cleanupExpiredBuckets() {
long now = System.currentTimeMillis();
int hourlyRemoved =
(int)
hourlyLimits.entrySet().stream()
.filter(e -> e.getValue().getResetTime() < now)
.peek(e -> hourlyLimits.remove(e.getKey()))
.count();
int dailyRemoved =
(int)
dailyLimits.entrySet().stream()
.filter(e -> e.getValue().getResetTime() < now)
.peek(e -> dailyLimits.remove(e.getKey()))
.count();
if (hourlyRemoved + dailyRemoved > 0) {
log.debug(
"Cleaned up {} expired rate limit buckets (hourly: {}, daily: {})",
hourlyRemoved + dailyRemoved,
hourlyRemoved,
dailyRemoved);
}
}
/** Internal class to track rate limit counts and reset times. */
private static class RateLimitBucket {
private final AtomicInteger count;
private final long resetTime;
public RateLimitBucket(int initialCount, long resetTime) {
this.count = new AtomicInteger(initialCount);
this.resetTime = resetTime;
}
public int getCount() {
return count.get();
}
public void increment() {
count.incrementAndGet();
}
public void decrement() {
count.decrementAndGet();
}
public long getResetTime() {
return resetTime;
}
public boolean isExpired() {
return System.currentTimeMillis() > resetTime;
}
}
}
@@ -0,0 +1,128 @@
package stirling.software.saas.service;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.Team;
import stirling.software.saas.model.SaasTeamExtensions;
import stirling.software.saas.repository.SaasTeamExtensionsRepository;
/**
* Read/write access to {@link SaasTeamExtensions}. Reads return safe defaults (non-personal team,
* seat fields zeroed) when no row exists; writes create the row lazily.
*/
@Service
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class SaasTeamExtensionService {
private final SaasTeamExtensionsRepository repository;
public SaasTeamExtensions getOrCreate(Team team) {
return repository
.findByTeamId(team.getId())
.orElseGet(() -> repository.save(new SaasTeamExtensions(team)));
}
/** Whether the team is personal (1-seat owned by a single user). Defaults to false. */
public boolean isPersonal(Team team) {
if (team == null || team.getId() == null) {
return false;
}
return repository
.findByTeamId(team.getId())
.map(SaasTeamExtensions::isPersonal)
.orElse(false);
}
/** Team type string ("PERSONAL" or "STANDARD"). Defaults to STANDARD. */
public String getTeamType(Team team) {
if (team == null || team.getId() == null) {
return SaasTeamExtensions.TEAM_TYPE_STANDARD;
}
return repository
.findByTeamId(team.getId())
.map(SaasTeamExtensions::getTeamType)
.orElse(SaasTeamExtensions.TEAM_TYPE_STANDARD);
}
public int getSeatsUsed(Team team) {
return repository
.findByTeamId(team.getId())
.map(SaasTeamExtensions::getSeatsUsed)
.orElse(0);
}
public int getMaxSeats(Team team) {
return repository.findByTeamId(team.getId()).map(SaasTeamExtensions::getMaxSeats).orElse(1);
}
public Long getCreatedByUserId(Team team) {
return repository
.findByTeamId(team.getId())
.map(SaasTeamExtensions::getCreatedByUserId)
.orElse(null);
}
/** Whether the team has unused seats. See {@link SaasTeamExtensions#hasAvailableSeats()}. */
public boolean hasAvailableSeats(Team team) {
return repository
.findByTeamId(team.getId())
.map(SaasTeamExtensions::hasAvailableSeats)
.orElse(true);
}
/**
* Whether the team accepts new invitations. See {@link SaasTeamExtensions#canInviteMembers()}.
*/
public boolean canInviteMembers(Team team) {
return repository
.findByTeamId(team.getId())
.map(SaasTeamExtensions::canInviteMembers)
.orElse(true);
}
/** Atomic seat increment with personal-team cap enforcement. */
@Transactional
public int incrementSeatsUsed(Team team) {
getOrCreate(team);
return repository.incrementSeatsUsed(team.getId());
}
/** Atomic seat decrement, floored at 0. */
@Transactional
public int decrementSeatsUsed(Team team) {
return repository.decrementSeatsUsed(team.getId());
}
@Transactional
public void setPersonal(Team team, boolean personal) {
SaasTeamExtensions ext = getOrCreate(team);
ext.setIsPersonal(personal);
ext.setTeamType(
personal
? SaasTeamExtensions.TEAM_TYPE_PERSONAL
: SaasTeamExtensions.TEAM_TYPE_STANDARD);
repository.save(ext);
}
@Transactional
public void setSeats(Team team, int seatCount, int maxSeats) {
SaasTeamExtensions ext = getOrCreate(team);
ext.setSeatCount(seatCount);
ext.setMaxSeats(maxSeats);
repository.save(ext);
}
@Transactional
public void setCreatedByUserId(Team team, Long createdByUserId) {
SaasTeamExtensions ext = getOrCreate(team);
ext.setCreatedByUserId(createdByUserId);
repository.save(ext);
}
}
@@ -0,0 +1,865 @@
package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.InvitationStatus;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.config.SupabaseConfigurationProperties;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.TeamInvitation;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.repository.SaasTeamExtensionsRepository;
import stirling.software.saas.repository.TeamCreditRepository;
import stirling.software.saas.repository.TeamInvitationRepository;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.repository.UserCreditRepository;
/** SaaS-only team management: invitations, personal teams, seat caps, paid-subscription gating. */
@Service
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class SaasTeamService {
private final TeamRepository teamRepository;
private final TeamMembershipRepository membershipRepository;
private final TeamInvitationRepository invitationRepository;
private final UserRepository userRepository;
private final UserCreditRepository userCreditRepository;
private final BillingSubscriptionRepository billingSubscriptionRepository;
private final TeamCreditService teamCreditService;
private final TeamCreditRepository teamCreditRepository;
private final CreditsProperties creditsProperties;
private final RestTemplate restTemplate;
private final RateLimitService rateLimitService;
private final SupabaseConfigurationProperties supabaseConfig;
private final UserRoleService userRoleService;
private final SaasTeamExtensionService saasTeamExtensionService;
private final SaasTeamExtensionsRepository saasTeamExtensionsRepository;
private final stirling.software.proprietary.security.service.UserService userService;
public static final String DEFAULT_TEAM_NAME = "Default";
public static final String INTERNAL_TEAM_NAME = "Internal";
/**
* Create personal team for new user during signup or migrate existing user from Default team
*
* @param user the user
* @return created Team
*/
@Transactional
public Team createPersonalTeam(User user) {
final long userId = user.getId();
// Refetch so the entity is managed by the current session.
user =
userRepository
.findById(userId)
.orElseThrow(
() -> new IllegalArgumentException("User not found: " + userId));
String personalTeamName = "My Team";
Team team = new Team();
team.setName(personalTeamName);
Team savedTeam = teamRepository.save(team);
saasTeamExtensionService.setPersonal(savedTeam, true);
saasTeamExtensionService.setSeats(savedTeam, 1, 1);
saasTeamExtensionService.setCreatedByUserId(savedTeam, user.getId());
saasTeamExtensionsRepository.incrementSeatsUsed(savedTeam.getId());
// Create membership
TeamMembership membership = new TeamMembership();
membership.setTeam(savedTeam);
membership.setUser(user);
membership.setRole(TeamRole.LEADER);
membership.setInvitedAt(LocalDateTime.now());
membership.setAcceptedAt(LocalDateTime.now());
membershipRepository.save(membership);
// Update user's team_id to point to personal team
Team oldTeam = user.getTeam();
user.setTeam(savedTeam);
userRepository.save(user);
// Initialize team credits
teamCreditService.initializeTeamCredits(savedTeam, user);
// Clean up old Default/Internal team membership
if (oldTeam != null
&& (DEFAULT_TEAM_NAME.equals(oldTeam.getName())
|| INTERNAL_TEAM_NAME.equals(oldTeam.getName()))) {
membershipRepository.deleteByTeamIdAndUserId(oldTeam.getId(), user.getId());
// Note: We intentionally leave the Default/Internal team in the database even if empty
// Deleting it within the same transaction causes Hibernate session management issues
// Empty system teams are harmless and can be cleaned up manually if needed
}
log.debug("Created personal team {} for user {}", savedTeam.getId(), user.getId());
return savedTeam;
}
/**
* Invite user to team (sends email via Supabase Edge Function)
*
* @param teamId the team ID
* @param inviteeEmail the invitee's email
* @param inviter the user sending the invitation
* @return created TeamInvitation
*/
@Transactional
public TeamInvitation inviteUserToTeam(Long teamId, String inviteeEmail, User inviter) {
Team team =
teamRepository
.findById(teamId)
.orElseThrow(() -> new IllegalArgumentException("Team not found"));
// Validate: inviter is team leader
TeamMembership inviterMembership =
membershipRepository
.findByTeamIdAndUserId(teamId, inviter.getId())
.orElseThrow(
() -> new SecurityException("You are not a member of this team"));
if (!inviterMembership.isLeader()) {
throw new SecurityException("Only team leaders can invite members");
}
// Auto-convert personal team to non-personal team for Pro users
if (saasTeamExtensionService.isPersonal(team)) {
log.info(
"Converting personal team {} to non-personal team for first invitation by {}",
team.getName(),
inviter.getUsername());
saasTeamExtensionService.setPersonal(team, false);
// Unlimited seats once converted to standard
saasTeamExtensionService.setSeats(team, Integer.MAX_VALUE, Integer.MAX_VALUE);
}
// Validate: team can invite (not personal, has available seats)
if (!saasTeamExtensionService.canInviteMembers(team)) {
throw new IllegalArgumentException(
"Cannot invite members: personal team or no available seats");
}
// Check rate limit (10 invitations per hour, 50 per day)
if (!rateLimitService.allowInvitation(teamId)) {
int remaining = rateLimitService.getRemainingInvitations(teamId);
throw new IllegalStateException(
String.format(
"Rate limit exceeded. Please try again later. (Remaining: %d)",
remaining));
}
// Check if there's already a pending invitation
if (invitationRepository.existsPendingInvitationByTeamIdAndEmail(teamId, inviteeEmail)) {
throw new IllegalArgumentException("Pending invitation already exists for this email");
}
// Check if invitee already exists and has active paid subscription
userRepository
.findByEmail(inviteeEmail)
.ifPresent(
existingUser -> {
if (hasPaidSubscription(existingUser)) {
throw new IllegalArgumentException(
"Cannot invite paid users to teams. Only team leaders manage billing.");
}
// Check if already a member
if (membershipRepository.existsByTeamIdAndUserId(
teamId, existingUser.getId())) {
throw new IllegalArgumentException("User is already a team member");
}
});
// Create invitation
TeamInvitation invitation = new TeamInvitation();
invitation.setTeam(team);
invitation.setInviter(inviter);
invitation.setInviteeEmail(inviteeEmail);
invitation.setStatus(InvitationStatus.PENDING);
invitation.setInvitationToken(UUID.randomUUID().toString());
invitation.setExpiresAt(LocalDateTime.now().plusDays(7));
userRepository.findByEmail(inviteeEmail).ifPresent(invitation::setInviteeUser);
TeamInvitation savedInvitation = invitationRepository.save(invitation);
// Send invitation email
sendInvitationEmail(savedInvitation);
log.info(
"User {} invited {} to team {}",
inviter.getUsername(),
inviteeEmail,
team.getName());
return savedInvitation;
}
/**
* Accept an invitation and grant PRO role in the same transaction. If the role grant fails the
* membership write is rolled back so we never end up with a member sitting on a paid team
* without the matching role (regression #18).
*/
@Transactional
public void acceptInvitationAndGrantRole(String invitationToken, User acceptingUser)
throws java.sql.SQLException,
stirling.software.common.model.exception.UnsupportedProviderException {
acceptInvitation(invitationToken, acceptingUser);
// Re-read the user post-accept; acceptInvitation refetches+saves them.
User user = userRepository.findById(acceptingUser.getId()).orElse(acceptingUser);
Team userTeam = user.getTeam();
if (userTeam == null || !hasActivePaidSubscription(userTeam)) {
log.warn(
"User {} joined team {} but team has no active subscription - not granting PRO role",
user.getUsername(),
userTeam != null ? userTeam.getName() : "null");
return;
}
String currentRole = user.getRolesAsString();
if (stirling.software.common.model.enumeration.Role.PRO_USER
.getRoleId()
.equals(currentRole)) {
return;
}
log.info(
"Granting ROLE_PRO_USER to user {} joining team {} with active subscription",
user.getUsername(),
userTeam.getName());
userService.changeRole(
user, stirling.software.common.model.enumeration.Role.PRO_USER.getRoleId());
}
/**
* Accept an invitation. The user's individual subscription stays active but is suspended in
* favour of the team's shared pool; it resumes if they leave the team.
*/
@Transactional
public void acceptInvitation(String invitationToken, User acceptingUser) {
// Refetch via id rather than reattach: supabase_auth_id is insertable/updatable=false and
// gets cleared on detach.
final long userId = acceptingUser.getId();
acceptingUser =
userRepository
.findById(userId)
.orElseThrow(
() -> new IllegalArgumentException("User not found: " + userId));
TeamInvitation invitation =
invitationRepository
.findByInvitationToken(invitationToken)
.orElseThrow(() -> new IllegalArgumentException("Invitation not found"));
// Force lazy-load inviter early to ensure it's in managed state
User inviter = invitation.getInviter();
// Validate invitation status
if (invitation.getStatus() != InvitationStatus.PENDING) {
throw new IllegalStateException(
"Invitation already processed: " + invitation.getStatus());
}
if (invitation.isExpired()) {
invitation.setStatus(InvitationStatus.EXPIRED);
invitationRepository.save(invitation);
throw new IllegalStateException("Invitation expired");
}
// Validate: email matches
if (!invitation.getInviteeEmail().equalsIgnoreCase(acceptingUser.getEmail())) {
throw new SecurityException("Invitation email mismatch");
}
// Validate: user doesn't have paid subscription
if (hasPaidSubscription(acceptingUser)) {
throw new IllegalArgumentException(
"Cannot join team with active paid subscription. Cancel your subscription first.");
}
// Validate: team has available seats
Team team = invitation.getTeam();
if (!saasTeamExtensionService.hasAvailableSeats(team)) {
throw new IllegalStateException("Team has no available seats");
}
// User can only be in one team . leave existing teams before joining new one
List<TeamMembership> existingMemberships =
membershipRepository.findByUserId(acceptingUser.getId());
List<Team> teamsToDelete = new java.util.ArrayList<>();
for (TeamMembership existingMembership : existingMemberships) {
Team oldTeam = existingMembership.getTeam();
membershipRepository.delete(existingMembership);
saasTeamExtensionsRepository.decrementSeatsUsed(oldTeam.getId());
// Mark personal team for deletion if it's now empty (user was the only member)
if (saasTeamExtensionService.isPersonal(oldTeam)
&& membershipRepository.countByTeamId(oldTeam.getId()) == 0) {
teamsToDelete.add(oldTeam);
}
log.info(
"User {} left team {} to join team {}",
acceptingUser.getUsername(),
oldTeam.getName(),
team.getName());
}
// Native query: avoids Hibernate touching the read-only supabase_auth_id column.
userRepository.updateUserTeamId(acceptingUser.getId(), team.getId());
acceptingUser.setTeam(team);
log.info(
"User {} team reference updated to team {}",
acceptingUser.getUsername(),
team.getName());
// Now safe to delete empty personal teams
for (Team teamToDelete : teamsToDelete) {
log.info(
"Deleting empty personal team {} after user {} joined another team",
teamToDelete.getId(),
acceptingUser.getUsername());
teamRepository.delete(teamToDelete);
}
// Create team membership
TeamMembership membership = new TeamMembership();
membership.setTeam(team);
membership.setUser(acceptingUser);
membership.setRole(TeamRole.MEMBER);
membership.setInvitedBy(inviter);
membership.setInvitedAt(invitation.getCreatedAt());
membership.setAcceptedAt(LocalDateTime.now());
membershipRepository.save(membership);
log.info(
"User {} added to team {} with role MEMBER",
acceptingUser.getUsername(),
team.getName());
// incrementSeatsUsed enforces the seat cap atomically; rowsUpdated==0 means at capacity.
int rowsUpdated = saasTeamExtensionsRepository.incrementSeatsUsed(team.getId());
if (rowsUpdated == 0) {
throw new IllegalStateException("Team has no available seats");
}
log.info("Team {} seats_used incremented", team.getName());
// Don't set inviteeUser; acceptance is recorded via status + TeamMembership row.
invitation.setStatus(InvitationStatus.ACCEPTED);
invitationRepository.save(invitation);
log.info(
"User {} accepted invitation to team {}",
acceptingUser.getUsername(),
team.getName());
}
/**
* Remove team member (only by team leader)
*
* @param teamId the team ID
* @param memberUserId the user ID to remove
* @param remover the user performing the removal
*/
@Transactional
public void removeTeamMember(Long teamId, Long memberUserId, User remover) {
// Validate: remover is team leader
TeamMembership removerMembership =
membershipRepository
.findByTeamIdAndUserId(teamId, remover.getId())
.orElseThrow(
() -> new SecurityException("You are not a member of this team"));
if (!removerMembership.isLeader()) {
throw new SecurityException("Only team leaders can remove members");
}
// Cannot remove yourself if you're the only leader
List<TeamMembership> leaders =
membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER);
if (removerMembership.getUser().getId().equals(memberUserId) && leaders.size() == 1) {
throw new IllegalStateException(
"Cannot remove the last team leader. Transfer leadership first.");
}
TeamMembership memberToRemove =
membershipRepository
.findByTeamIdAndUserId(teamId, memberUserId)
.orElseThrow(() -> new IllegalArgumentException("User not found in team"));
User userToRemove = memberToRemove.getUser();
membershipRepository.delete(memberToRemove);
// Atomically decrement team seats_used (prevents race condition)
saasTeamExtensionsRepository.decrementSeatsUsed(teamId);
// Fetch team for downstream checks
Team team = teamRepository.findById(teamId).orElseThrow();
// Create new personal team for removed user
createPersonalTeam(userToRemove);
// Downgrade user to FREE tier after leaving team
// They either had a trial (which was cancelled) or had an existing subscription
// Either way, they should be FREE after leaving
downgradeUserToFree(userToRemove);
// Delete non-personal team if it's now empty
if (!saasTeamExtensionService.isPersonal(team)
&& membershipRepository.countByTeamId(teamId) == 0) {
log.info("Deleting empty non-personal team {} after last member removed", teamId);
teamRepository.delete(team);
}
log.info(
"User {} removed user {} from team {} and created new personal team",
remover.getId(),
memberUserId,
teamId);
}
/**
* Leave team (self-removal)
*
* @param teamId the team ID
* @param user the user leaving
*/
@Transactional
public void leaveTeam(Long teamId, User user) {
TeamMembership membership =
membershipRepository
.findByTeamIdAndUserId(teamId, user.getId())
.orElseThrow(
() -> new IllegalArgumentException("Not a member of this team"));
// Cannot leave if you're the only leader
if (membership.isLeader()) {
List<TeamMembership> leaders =
membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER);
if (leaders.size() == 1) {
throw new IllegalStateException(
"Cannot leave as the last team leader. Transfer leadership first.");
}
}
membershipRepository.delete(membership);
// Atomically decrement team seats_used (prevents race condition)
saasTeamExtensionsRepository.decrementSeatsUsed(teamId);
// Fetch team for downstream checks
Team team = teamRepository.findById(teamId).orElseThrow();
// Create new personal team for user who left
createPersonalTeam(user);
// Check if user should be downgraded after leaving team
// If user has an active subscription (including trial), they keep PRO access
// Otherwise, downgrade to FREE tier
downgradeUserToFree(user);
// Delete non-personal team if it's now empty
if (!saasTeamExtensionService.isPersonal(team)
&& membershipRepository.countByTeamId(teamId) == 0) {
log.info("Deleting empty non-personal team {} after last member left", teamId);
teamRepository.delete(team);
}
log.info("User {} left team {} and created new personal team", user.getId(), teamId);
}
/**
* Check if user has active paid subscription.
*
* <p>Queries the billing_subscriptions table to determine if the user has an active, trialing,
* or past_due subscription. This prevents inviting users who are already paying for their own
* subscription, which would create billing conflicts.
*
* @param user the user to check
* @return true if user has active paid subscription
*/
private boolean hasPaidSubscription(User user) {
if (user.getSupabaseId() == null) {
log.debug(
"User {} has no Supabase ID, cannot check billing subscription", user.getId());
return false;
}
try {
// Check for active PAID subscriptions only (excludes trialing users)
// Trialing users and users with scheduled cancellations can be invited to teams
boolean hasActivePaidSubscription =
billingSubscriptionRepository.existsActivePaidSubscriptionForUser(
user.getSupabaseId());
if (hasActivePaidSubscription) {
log.info(
"User {} (supabase: {}) has active paid subscription",
user.getId(),
user.getSupabaseId());
}
return hasActivePaidSubscription;
} catch (Exception e) {
log.error(
"Error checking billing subscription for user {}: {}",
user.getId(),
e.getMessage(),
e);
// Fail safe: treat as if they DO have a subscription to avoid double billing
// Better to block invitation and require manual check than risk inviting paying
// customer
return true;
}
}
/**
* Check if team has an active paid subscription. This determines if new team members should be
* granted ROLE_PRO_USER.
*
* @param team the team to check
* @return true if team has active paid subscription
*/
public boolean hasActivePaidSubscription(Team team) {
if (team == null || team.getId() == null) {
log.debug("Team is null or has no ID, cannot check subscription");
return false;
}
try {
boolean hasActiveSubscription =
billingSubscriptionRepository.existsActiveSubscriptionForTeam(team.getId());
if (hasActiveSubscription) {
log.info("Team {} has active paid subscription", team.getId());
}
return hasActiveSubscription;
} catch (Exception e) {
log.error(
"Error checking billing subscription for team {}: {}",
team.getId(),
e.getMessage(),
e);
// Fail safe: assume no subscription on error
return false;
}
}
/**
* Send invitation email via Supabase Edge Function
*
* @param invitation the invitation
*/
private void sendInvitationEmail(TeamInvitation invitation) {
try {
// Check if Supabase is configured
if (!supabaseConfig.isEdgeFunctionConfigured()) {
log.warn(
"Supabase integration not configured, skipping email send. "
+ "Please configure supabase.edgeFunctionUrl and supabase.edgeFunctionSecret "
+ "in application properties.");
return;
}
String url = supabaseConfig.getEdgeFunctionUrl() + "/team-invitation-email";
String edgeFunctionSecret = supabaseConfig.getEdgeFunctionSecret();
var requestBody =
new EmailInvitationRequest(
invitation.getInviteeEmail(),
invitation.getTeam().getName(),
invitation.getInviter().getEmail(),
invitation.getInvitationToken());
// Create headers with authorization
org.springframework.http.HttpHeaders headers =
new org.springframework.http.HttpHeaders();
headers.set("Authorization", "Bearer " + edgeFunctionSecret);
headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
org.springframework.http.HttpEntity<EmailInvitationRequest> entity =
new org.springframework.http.HttpEntity<>(requestBody, headers);
restTemplate.postForEntity(url, entity, String.class);
log.info(
"Sent invitation email to {} for team {}",
invitation.getInviteeEmail(),
invitation.getTeam().getName());
} catch (Exception e) {
log.error("Failed to send invitation email", e);
// Don't throw . invitation is already saved
}
}
/** Request body for edge function email */
private record EmailInvitationRequest(
String invitee_email, String team_name, String inviter_name, String invitation_token) {}
/**
* Update team seat allocation (called by billing webhooks)
*
* @param teamId the team ID
* @param maxSeats new maximum seat count
*/
@Transactional
public void updateTeamSeats(Long teamId, Integer maxSeats) {
if (maxSeats == null || maxSeats < 1) {
throw new IllegalArgumentException("maxSeats must be at least 1");
}
Team team =
teamRepository
.findById(teamId)
.orElseThrow(() -> new IllegalArgumentException("Team not found"));
// Handle seat reduction: automatically remove excess members if reducing below current
// usage
int currentSeatsUsed = saasTeamExtensionService.getSeatsUsed(team);
int currentMaxSeats = saasTeamExtensionService.getMaxSeats(team);
if (maxSeats < currentSeatsUsed) {
int excessMembers = currentSeatsUsed - maxSeats;
log.warn(
"Team {} reducing seats from {} to {} with {} current members. Removing {} excess members.",
teamId,
currentMaxSeats,
maxSeats,
currentSeatsUsed,
excessMembers);
// Get all team members, sorted by priority (non-leaders first, most recently joined
// first)
List<TeamMembership> allMembers = membershipRepository.findByTeamId(teamId);
// Sort: MEMBER role first (non-leaders), then by accepted date descending (most recent
// first)
List<TeamMembership> membersToRemove =
allMembers.stream()
.sorted(
(m1, m2) -> {
// Leaders (LEADER role) come last (lower priority for
// removal)
if (m1.getRole() != m2.getRole()) {
return m1.getRole() == TeamRole.LEADER ? 1 : -1;
}
// Among same role, remove most recently joined first
// (descending accepted date)
LocalDateTime date1 =
m1.getAcceptedAt() != null
? m1.getAcceptedAt()
: m1.getInvitedAt();
LocalDateTime date2 =
m2.getAcceptedAt() != null
? m2.getAcceptedAt()
: m2.getInvitedAt();
if (date1 == null && date2 == null) return 0;
if (date1 == null) return 1;
if (date2 == null) return -1;
return date2.compareTo(
date1); // Descending (most recent first)
})
.limit(excessMembers)
.collect(java.util.stream.Collectors.toList());
// Remove excess members
for (TeamMembership membership : membersToRemove) {
User userToRemove = membership.getUser();
log.info(
"Removing user {} ({}) from team {} due to seat reduction",
userToRemove.getId(),
userToRemove.getUsername(),
teamId);
// Delete membership
membershipRepository.delete(membership);
// Atomically decrement seats_used count (prevents race condition)
saasTeamExtensionsRepository.decrementSeatsUsed(teamId);
// Create new personal team for removed user
createPersonalTeam(userToRemove);
// Downgrade user to FREE tier
downgradeUserToFree(userToRemove);
log.info(
"User {} removed from team {} and migrated to personal team due to seat reduction",
userToRemove.getId(),
teamId);
}
log.info(
"Completed seat reduction for team {}: removed {} members",
teamId,
excessMembers);
}
saasTeamExtensionService.setSeats(team, maxSeats, maxSeats);
// Personal team with maxSeats > 1 is really a standard team.
boolean wasPersonal = saasTeamExtensionService.isPersonal(team);
if (wasPersonal && maxSeats > 1) {
saasTeamExtensionService.setPersonal(team, false);
log.info(
"Team {} converted from PERSONAL to STANDARD (maxSeats increased to {})",
teamId,
maxSeats);
}
// Revert to personal when downgrading to 1 seat
else if (!wasPersonal && maxSeats == 1) {
saasTeamExtensionService.setPersonal(team, true);
log.info("Team {} converted from STANDARD to PERSONAL (maxSeats reduced to 1)", teamId);
}
teamRepository.save(team);
Optional<TeamCredit> creditOpt = teamCreditRepository.findByTeamId(teamId);
int fixedAllocation =
creditsProperties.getCycle().getAllocations().getOrDefault("ROLE_PRO_USER", 500);
if (creditOpt.isPresent()) {
TeamCredit credit = creditOpt.get();
int oldAllocation =
credit.getCycleCreditsAllocated() != null
? credit.getCycleCreditsAllocated()
: 0;
if (oldAllocation != fixedAllocation) {
int currentRemaining =
credit.getCycleCreditsRemaining() != null
? credit.getCycleCreditsRemaining()
: 0;
int allocationDifference = fixedAllocation - oldAllocation;
credit.setCycleCreditsAllocated(fixedAllocation);
int newRemaining = Math.max(0, currentRemaining + allocationDifference);
credit.setCycleCreditsRemaining(newRemaining);
teamCreditRepository.save(credit);
log.info(
"Updated team {} credit allocation: {} -> {} (fixed PRO amount). Remaining: {} -> {}",
teamId,
oldAllocation,
fixedAllocation,
currentRemaining,
newRemaining);
} else {
log.debug(
"Team {} already has fixed allocation of {} credits, no update needed",
teamId,
fixedAllocation);
}
} else {
log.warn("Team {} missing credit record; creating with fixed allocation", teamId);
TeamCredit credit = new TeamCredit(team);
credit.setCycleCreditsAllocated(fixedAllocation);
credit.setCycleCreditsRemaining(fixedAllocation);
credit.setBoughtCreditsRemaining(0);
credit.setTotalBoughtCredits(0);
credit.setTotalApiCallsMade(0L);
credit.setLastCycleResetAt(LocalDateTime.now());
teamCreditRepository.save(credit);
log.info(
"Created team_credits record for team {} with {} fixed credits (unlimited seats model)",
teamId,
fixedAllocation);
}
log.info(
"Team {} seat allocation updated: maxSeats={}, seatsUsed={}, isPersonal={}",
teamId,
maxSeats,
saasTeamExtensionService.getSeatsUsed(team),
saasTeamExtensionService.isPersonal(team));
}
/**
* Downgrade user to FREE tier (called when leaving team)
*
* <p>Note: Uses UserRoleService to avoid code duplication. Refetches user to avoid stale entity
* issues (createPersonalTeam refetches and saves the user).
*
* @param user the user to downgrade
*/
private void downgradeUserToFree(User user) {
// Refetch user to ensure we have the latest managed entity
// (createPersonalTeam refetches and saves the user, making our reference stale)
final long userId = user.getId();
final User refetchedUser =
userRepository
.findById(userId)
.orElseThrow(
() -> new IllegalArgumentException("User not found: " + userId));
String currentRole = refetchedUser.getRolesAsString();
if (!Role.PRO_USER.getRoleId().equals(currentRole)) {
log.debug(
"User {} already on FREE tier, no downgrade needed",
refetchedUser.getUsername());
return;
}
// Check if user has an active subscription (including trial)
// If they do, keep their PRO access . don't downgrade
if (refetchedUser.getSupabaseId() != null) {
try {
boolean hasActiveSubscription =
billingSubscriptionRepository.existsActiveSubscriptionForUser(
refetchedUser.getSupabaseId());
if (hasActiveSubscription) {
log.info(
"User {} has active subscription (trial or paid), maintaining PRO access after leaving team",
refetchedUser.getUsername());
return; // Keep PRO access
}
} catch (Exception e) {
log.error(
"Error checking subscription for user {} after leaving team: {}",
refetchedUser.getUsername(),
e.getMessage(),
e);
// On error, proceed with downgrade to be safe
}
}
log.info(
"Downgrading user {} from PRO to FREE after leaving team (no active subscription)",
refetchedUser.getUsername());
// Delegate to UserRoleService for consistent downgrade logic
userRoleService.downgradeToFree(refetchedUser);
}
}
@@ -0,0 +1,195 @@
package stirling.software.saas.service;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.model.SupabaseUser;
import stirling.software.saas.util.LogRedactionUtils;
/** Saas user lifecycle operations driven by Stripe/Supabase webhooks. */
@Service
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class SaasUserAccountService {
private final UserService userService;
private final UserRepository userRepository;
private final UserRoleService userRoleService;
private final SupabaseUserService supabaseUserService;
private final SaasUserExtensionService saasUserExtensionService;
private final SaasTeamExtensionService saasTeamExtensionService;
/**
* Resolve a local {@link User} from a Supabase UUID string. Throws if the ID format is invalid
* or no matching local user row exists.
*/
public User getUserBySupabaseId(String supabaseId) {
UUID supabaseUserId;
try {
supabaseUserId = UUID.fromString(supabaseId);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid Supabase ID format: " + supabaseId, e);
}
return userService
.findBySupabaseId(supabaseUserId)
.orElseThrow(
() ->
new IllegalArgumentException(
"User not found for Supabase ID: " + supabaseId));
}
/**
* Promote a user from {@code ROLE_USER} (free) to {@code ROLE_PRO_USER} (paid). Called by the
* Stripe-webhook {@code UserRoleWebhookController} after a successful subscription.
*
* @return true if a promotion happened, false if the user was already on PRO or higher
*/
@Transactional
public boolean handleUpgrade(String supabaseId) {
User user = getUserBySupabaseId(supabaseId);
String currentRole = user.getRolesAsString();
if (Role.USER.getRoleId().equals(currentRole)) {
userRoleService.upgradeToPro(user);
return true;
}
log.info(
"User {} already has role {}, no upgrade needed",
LogRedactionUtils.redactEmail(user.getUsername()),
currentRole);
return false;
}
/**
* Demote a user from PRO back to FREE. Multi-member teams keep their PRO access via team
* membership even if the personal subscription cancels, so users on non-personal teams are left
* at PRO.
*
* @return true if a downgrade happened, false if user was already FREE or kept PRO via team
*/
@Transactional
public boolean handleDowngrade(String supabaseId) {
User user = getUserBySupabaseId(supabaseId);
String currentRole = user.getRolesAsString();
if (Role.PRO_USER.getRoleId().equals(currentRole)) {
Team userTeam = user.getTeam();
if (userTeam != null && !saasTeamExtensionService.isPersonal(userTeam)) {
log.info(
"User {} is on team {} - keeping PRO access through team membership",
LogRedactionUtils.redactEmail(user.getUsername()),
userTeam.getName());
return false;
}
userRoleService.downgradeToFree(user);
return true;
}
log.info(
"User {} has role {}, no downgrade needed",
LogRedactionUtils.redactEmail(user.getUsername()),
currentRole);
return false;
}
/**
* Turn on metered billing so the user can pay for overage. Compatible with PRO (a PRO user
* still uses credits via the metered fallback for API usage above the cycle pool).
*/
@Transactional
public boolean enableMeteredBilling(String supabaseId) {
User user = getUserBySupabaseId(supabaseId);
if (saasUserExtensionService.isMeteredBillingEnabled(user)) {
log.info(
"User {} already has metered billing enabled",
LogRedactionUtils.redactEmail(user.getUsername()));
return false;
}
saasUserExtensionService.setMeteredBillingEnabled(user, true);
log.info(
"Enabled metered billing for user {}",
LogRedactionUtils.redactEmail(user.getUsername()));
return true;
}
/** Turn off metered billing. Called when the metered subscription is canceled in Stripe. */
@Transactional
public boolean disableMeteredBilling(String supabaseId) {
User user = getUserBySupabaseId(supabaseId);
if (!saasUserExtensionService.isMeteredBillingEnabled(user)) {
log.info(
"User {} does not have metered billing enabled",
LogRedactionUtils.redactEmail(user.getUsername()));
return false;
}
saasUserExtensionService.setMeteredBillingEnabled(user, false);
log.info(
"Disabled metered billing for user {}",
LogRedactionUtils.redactEmail(user.getUsername()));
return true;
}
/** Anonymous -> authenticated upgrade triggered from the frontend after Supabase accepts it. */
@Transactional
public User synchronizeUserUpgrade(SupabaseUser supabaseUser, String email, String authMethod) {
log.debug(
"Synchronizing user upgrade for SupabaseId: {}, Email: {}",
LogRedactionUtils.redactSupabaseId(supabaseUser.getId()),
LogRedactionUtils.redactEmail(email));
User user =
userService
.findBySupabaseId(supabaseUser.getId())
.orElseThrow(
() ->
new IllegalStateException(
"No local user linked to Supabase ID "
+ supabaseUser.getId()));
// Flip is_anonymous on the auth.users mirror.
if (supabaseUser.isAnonymous()) {
supabaseUser.setAnonymous(false);
supabaseUserService.save(supabaseUser);
}
// Promote the local row's auth type and email if currently anonymous.
if (AuthenticationType.ANONYMOUS.name().equalsIgnoreCase(user.getAuthenticationType())) {
AuthenticationType newType = mapAuthMethodToType(authMethod);
user.setAuthenticationType(newType);
if (email != null && !email.isBlank()) {
user.setEmail(email);
user.setUsername(email);
}
user = userService.saveUser(user);
log.info(
"Upgraded anonymous user {} to {} ({})",
user.getId(),
newType,
LogRedactionUtils.redactEmail(email));
}
return user;
}
private static AuthenticationType mapAuthMethodToType(String authMethod) {
if (authMethod == null) {
return AuthenticationType.WEB;
}
return switch (authMethod) {
case "google", "github", "apple", "azure", "linkedin_oidc", "oauth" ->
AuthenticationType.OAUTH2;
default -> AuthenticationType.WEB;
};
}
}
@@ -0,0 +1,64 @@
package stirling.software.saas.service;
import java.time.LocalDateTime;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.SaasUserExtensions;
import stirling.software.saas.repository.SaasUserExtensionsRepository;
/**
* Read/write access to {@link SaasUserExtensions}. Reads return safe defaults when no row exists
* for the user; writes create the row lazily.
*/
@Service
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class SaasUserExtensionService {
private final SaasUserExtensionsRepository repository;
public SaasUserExtensions getOrCreate(User user) {
return repository
.findByUserId(user.getId())
.orElseGet(() -> repository.save(new SaasUserExtensions(user)));
}
public boolean isMeteredBillingEnabled(User user) {
return repository
.findByUserId(user.getId())
.map(SaasUserExtensions::isMeteredBillingEnabled)
.orElse(false);
}
@Transactional
public void setMeteredBillingEnabled(User user, boolean enabled) {
SaasUserExtensions ext = getOrCreate(user);
ext.setHasMeteredBillingEnabled(enabled);
repository.save(ext);
}
public LocalDateTime getApiKeyFirstUsedAt(User user) {
return repository
.findByUserId(user.getId())
.map(SaasUserExtensions::getApiKeyFirstUsedAt)
.orElse(null);
}
/** Idempotent first-use marker. Records the first time this user's API key fired a request. */
@Transactional
public void trackApiKeyFirstUse(User user) {
SaasUserExtensions ext = getOrCreate(user);
if (ext.getApiKeyFirstUsedAt() == null) {
ext.setApiKeyFirstUsedAt(LocalDateTime.now());
repository.save(ext);
}
}
}
@@ -0,0 +1,45 @@
package stirling.software.saas.service;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.saas.model.SupabaseUser;
import stirling.software.saas.model.exception.UserNotFoundException;
import stirling.software.saas.repository.SupabaseUserRepository;
/**
* CRUD over Supabase's {@code auth.users} mirror. Exists only under the saas profile because the
* {@code auth} schema is Supabase-specific.
*/
@Service
@Profile("saas")
@RequiredArgsConstructor
public class SupabaseUserService {
private final SupabaseUserRepository supabaseUserRepository;
public SupabaseUser getUser(UUID supabaseId) {
return supabaseUserRepository
.findById(supabaseId)
.orElseThrow(
() ->
new UserNotFoundException(
"Supabase user " + supabaseId + " not found"));
}
public SupabaseUser createSupabaseUser(UUID supabaseId, String email, boolean isAnonymous) {
SupabaseUser supabaseUser = new SupabaseUser();
supabaseUser.setId(supabaseId);
supabaseUser.setEmail(email);
supabaseUser.setAnonymous(isAnonymous);
return supabaseUserRepository.save(supabaseUser);
}
public SupabaseUser save(SupabaseUser supabaseUser) {
return supabaseUserRepository.save(supabaseUser);
}
}
@@ -0,0 +1,279 @@
package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.billing.service.StripeUsageReportingService;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.model.CreditConsumptionResult;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.repository.TeamCreditRepository;
import stirling.software.saas.repository.TeamMembershipRepository;
/**
* Service for managing team credit pools. Handles credit initialization, consumption, and cycle
* resets for teams.
*/
@Service
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class TeamCreditService {
private final TeamCreditRepository teamCreditRepository;
private final TeamMembershipRepository membershipRepository;
private final CreditsProperties creditsProperties;
private final StripeUsageReportingService stripeUsageReportingService;
private final SaasUserExtensionService saasUserExtensionService;
/** Initialise a fixed PRO credit allocation for a new team. */
@Transactional
public TeamCredit initializeTeamCredits(Team team, User primaryUser) {
Optional<TeamCredit> existing = teamCreditRepository.findByTeamId(team.getId());
if (existing.isPresent()) {
log.debug("Team credits already exist for team {}", team.getId());
return existing.get();
}
TeamCredit credits = new TeamCredit(team);
// Fixed PRO allocation; seat-independent.
int proAllocation =
creditsProperties.getCycle().getAllocations().getOrDefault("ROLE_PRO_USER", 500);
int totalCycleAllocation = proAllocation;
credits.setCycleCreditsAllocated(totalCycleAllocation);
credits.setCycleCreditsRemaining(totalCycleAllocation);
credits.setLastCycleResetAt(LocalDateTime.now());
TeamCredit saved = teamCreditRepository.save(credits);
log.info(
"Initialized team credits for team {} with {} cycle credits (fixed PRO amount)",
team.getId(),
totalCycleAllocation);
return saved;
}
/**
* Check if team has credits available
*
* @param teamId the team ID
* @return true if team has credits available
*/
public boolean hasCreditsAvailable(Long teamId) {
return teamCreditRepository
.findByTeamId(teamId)
.map(TeamCredit::hasCreditsAvailable)
.orElse(false);
}
/**
* Atomically consume credits from team pool
*
* @param teamId the team ID
* @param amount number of credits to consume
* @return true if credits were consumed, false if insufficient credits or version conflict
*/
@Transactional
public boolean consumeCredit(Long teamId, int amount) {
int rowsUpdated = teamCreditRepository.consumeCredit(teamId, amount);
if (rowsUpdated == 0) {
log.warn(
"Failed to consume {} credits for team {} (insufficient credits or version conflict)",
amount,
teamId);
return false;
}
log.debug("Consumed {} credits for team {}", amount, teamId);
return true;
}
/**
* Get team credit summary for a user's team.
*
* @param user the user
* @return Optional of TeamCredit for the user's team
*/
public Optional<TeamCredit> getCreditSummaryForUser(User user) {
if (user.getTeam() == null) {
log.warn("User {} has no team assigned", user.getId());
return Optional.empty();
}
Long teamId = user.getTeam().getId();
log.debug("Using user's team {} for credit summary", teamId);
return teamCreditRepository.findByTeamId(teamId);
}
/**
* Get team credits by team ID
*
* @param teamId the team ID
* @return Optional of TeamCredit
*/
public Optional<TeamCredit> getTeamCredits(Long teamId) {
return teamCreditRepository.findByTeamId(teamId);
}
/**
* Add bought credits to team pool
*
* @param teamId the team ID
* @param credits number of credits to add
*/
@Transactional
public void addBoughtCredits(Long teamId, int credits) {
TeamCredit teamCredit =
teamCreditRepository
.findByTeamId(teamId)
.orElseThrow(() -> new IllegalArgumentException("Team credits not found"));
teamCredit.addBoughtCredits(credits);
teamCreditRepository.save(teamCredit);
log.info("Added {} bought credits to team {}", credits, teamId);
}
/**
* Reset cycle credits for team
*
* @param teamId the team ID
* @param cycleAllocation new cycle allocation
* @param resetTime reset timestamp
*/
@Transactional
public void resetCycleCredits(Long teamId, int cycleAllocation, LocalDateTime resetTime) {
TeamCredit teamCredit =
teamCreditRepository
.findByTeamId(teamId)
.orElseThrow(() -> new IllegalArgumentException("Team credits not found"));
teamCredit.resetCycleCredits(cycleAllocation, resetTime);
teamCreditRepository.save(teamCredit);
log.info("Reset cycle credits for team {} to {}", teamId, cycleAllocation);
}
/**
* Consume from the team credit pool; falls through to the team leader's metered Stripe billing
* when the pool is exhausted.
*/
@Transactional
public CreditConsumptionResult consumeCreditWithWaterfall(Long teamId, int amount) {
log.debug("[TEAM-CREDIT] Starting consumption for team {} - amount: {}", teamId, amount);
// Step 1: Try consuming from team credit pool
int rowsUpdated = teamCreditRepository.consumeCredit(teamId, amount);
if (rowsUpdated == 1) {
log.info("[TEAM-CREDIT] Consumed {} credits from team {} pool", amount, teamId);
return CreditConsumptionResult.success("TEAM_CREDITS");
}
log.warn("[TEAM-CREDIT] Team {} credit pool exhausted; checking leader overage", teamId);
// Step 2: Get team leader
Optional<User> leaderOpt = getTeamLeader(teamId);
if (leaderOpt.isEmpty()) {
log.error("[TEAM-CREDIT] Team {} has no leader; cannot use overage billing", teamId);
return CreditConsumptionResult.failure("NO_TEAM_LEADER");
}
User teamLeader = leaderOpt.get();
// Step 3: Check if team leader has metered billing enabled
if (!saasUserExtensionService.isMeteredBillingEnabled(teamLeader)) {
log.warn(
"[TEAM-CREDIT] Team {} leader {} does not have metered billing enabled",
teamId,
teamLeader.getUsername());
return CreditConsumptionResult.failure(
"TEAM_CREDITS_EXHAUSTED_NO_OVERAGE",
"Team credits exhausted. Team leader must enable overage billing for"
+ " uninterrupted service.");
}
// Step 4: Report overage to Stripe via team leader's metered billing
String leaderSupabaseId =
teamLeader.getSupabaseId() != null ? teamLeader.getSupabaseId().toString() : null;
if (leaderSupabaseId == null) {
log.error("[TEAM-CREDIT] Team leader {} has no Supabase ID", teamLeader.getUsername());
return CreditConsumptionResult.failure("LEADER_NO_SUPABASE_ID");
}
try {
String operationId = org.slf4j.MDC.get("requestId");
if (operationId == null || operationId.isBlank()) {
operationId = java.util.UUID.randomUUID().toString();
}
String idempotencyKey =
stripeUsageReportingService.generateIdempotencyKey(
leaderSupabaseId, amount, operationId);
log.info(
"[TEAM-CREDIT] Reporting {} overage credits to Stripe for team {} leader {}",
amount,
teamId,
teamLeader.getUsername());
boolean reported =
stripeUsageReportingService.reportUsageToStripe(
leaderSupabaseId, amount, idempotencyKey);
if (reported) {
log.info(
"[TEAM-CREDIT] Successfully reported {} overage credits for team {} via"
+ " leader {}",
amount,
teamId,
teamLeader.getUsername());
return CreditConsumptionResult.success("TEAM_LEADER_METERED");
} else {
log.error("[TEAM-CREDIT] Failed to report overage to Stripe for team {}", teamId);
return CreditConsumptionResult.failure(
"STRIPE_REPORTING_FAILED",
"Unable to report usage to Stripe. Please try again.");
}
} catch (Exception e) {
log.error(
"[TEAM-CREDIT] Exception reporting overage for team {}: {}",
teamId,
e.getMessage(),
e);
return CreditConsumptionResult.failure(
"STRIPE_REPORTING_ERROR", "Error reporting usage: " + e.getMessage());
}
}
/** Returns the team's LEADER (first one if multiple exist) for overage-billing routing. */
private Optional<User> getTeamLeader(Long teamId) {
List<TeamMembership> leaders =
membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER);
if (leaders.isEmpty()) {
log.warn("Team {} has no leaders", teamId);
return Optional.empty();
}
// Return first leader (typically only one leader per team)
TeamMembership leader = leaders.get(0);
User leaderUser = leader.getUser();
log.debug(
"Found team {} leader: {} (user ID: {})",
teamId,
leaderUser.getUsername(),
leaderUser.getId());
return Optional.of(leaderUser);
}
}
@@ -0,0 +1,72 @@
package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.InvitationStatus;
import stirling.software.saas.model.TeamInvitation;
import stirling.software.saas.repository.TeamInvitationRepository;
/**
* Scheduled service for cleaning up expired team invitations. Runs daily to mark invitations that
* have passed their expiration date as EXPIRED.
*/
@Service
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class TeamInvitationCleanupService {
private final TeamInvitationRepository invitationRepository;
/** Mark expired invitations as EXPIRED. Runs every day at 2:00 AM. */
@Scheduled(cron = "0 0 2 * * *")
@Transactional
public void markExpiredInvitations() {
try {
log.info("Starting invitation expiration cleanup job");
int expiredCount = invitationRepository.markExpiredInvitations(LocalDateTime.now());
if (expiredCount > 0) {
log.info("Marked {} invitations as expired", expiredCount);
} else {
log.debug("No invitations to expire");
}
} catch (Exception e) {
log.error("Error during invitation cleanup", e);
}
}
/** Delete old expired invitations (older than 30 days). Runs monthly on the 1st at 3:00 AM. */
@Scheduled(cron = "0 0 3 1 * *")
@Transactional
public void deleteOldExpiredInvitations() {
try {
log.info("Starting cleanup of old expired invitations");
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(30);
List<TeamInvitation> oldInvitations =
invitationRepository.findByStatusAndExpiresAtBefore(
InvitationStatus.EXPIRED, cutoffDate);
if (!oldInvitations.isEmpty()) {
invitationRepository.deleteAll(oldInvitations);
log.info("Deleted {} old expired invitations", oldInvitations.size());
} else {
log.debug("No old expired invitations to delete");
}
} catch (Exception e) {
log.error("Error during old invitation cleanup", e);
}
}
}
@@ -0,0 +1,127 @@
package stirling.software.saas.service;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.util.LogRedactionUtils;
/** Changes user roles and refreshes their credit allocation. */
@Service
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class UserRoleService {
private final UserRepository userRepository;
private final AuthorityRepository authorityRepository;
private final CreditService creditService;
private final CreditsProperties creditsProperties;
/**
* Change a user's role
*
* @param user the user to change
* @param newRole the new role ID (e.g., "ROLE_USER", "ROLE_PRO_USER")
*/
@Transactional
public void changeRole(User user, String newRole) {
log.debug(
"Changing role for user {} from {} to {}",
user.getUsername(),
user.getRolesAsString(),
newRole);
Authority userAuthority = authorityRepository.findByUserId(user.getId());
userAuthority.setAuthority(newRole);
authorityRepository.save(userAuthority);
// Update denormalized roleName column in User table
user.setRoleName(newRole);
userRepository.save(user);
log.info(
"Changed role for user {} to {}",
LogRedactionUtils.redactEmail(user.getUsername()),
newRole);
}
/**
* Downgrade a user to FREE tier (ROLE_USER)
*
* <p>Changes role from PRO_USER to USER and resets cycle credit allocation to FREE tier.
*
* @param user the user to downgrade
*/
@Transactional
public void downgradeToFree(User user) {
log.info(
"Downgrading user {} to FREE tier",
LogRedactionUtils.redactEmail(user.getUsername()));
changeRole(user, Role.USER.getRoleId());
// Reset credits to FREE tier allocation
int freeAllocation =
creditsProperties
.getCycle()
.getAllocations()
.getOrDefault(Role.USER.getRoleId(), 25);
creditService.resetCycleAllocationForRoleChange(user.getId(), freeAllocation);
log.info(
"Successfully downgraded user {} to FREE with {} cycle credits",
LogRedactionUtils.redactEmail(user.getUsername()),
freeAllocation);
}
/**
* Upgrade a user to PRO tier (ROLE_PRO_USER)
*
* <p>Changes role from USER to PRO_USER and resets cycle credit allocation to PRO tier.
*
* @param user the user to upgrade
*/
@Transactional
public void upgradeToPro(User user) {
log.info(
"Upgrading user {} to PRO tier", LogRedactionUtils.redactEmail(user.getUsername()));
changeRole(user, Role.PRO_USER.getRoleId());
// Reset credits to PRO tier allocation
int proAllocation =
creditsProperties
.getCycle()
.getAllocations()
.getOrDefault(Role.PRO_USER.getRoleId(), 100);
creditService.resetCycleAllocationForRoleChange(user.getId(), proAllocation);
log.info(
"Successfully upgraded user {} to PRO with {} cycle credits",
LogRedactionUtils.redactEmail(user.getUsername()),
proAllocation);
}
/**
* Get credit allocation for a specific role
*
* @param roleId the role ID (e.g., "ROLE_USER", "ROLE_PRO_USER")
* @return the cycle credit allocation for that role
*/
public int getCreditAllocationForRole(String roleId) {
return creditsProperties
.getCycle()
.getAllocations()
.getOrDefault(roleId, Role.USER.getRoleId().equals(roleId) ? 25 : 100);
}
}
@@ -0,0 +1,107 @@
package stirling.software.saas.util;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
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.security.EnhancedJwtAuthenticationToken;
/**
* Utility class for extracting information from authentication objects. Provides consistent access
* to user identifiers across different authentication types.
*/
public class AuthenticationUtils {
private AuthenticationUtils() {
// Utility class - no instances
}
/**
* Extract the Supabase ID from an authentication object for credit operations and user lookups.
*
* @param authentication The authentication object
* @return The Supabase ID for JWT users, or API key for API key users
*/
public static String extractSupabaseId(Authentication authentication) {
if (authentication instanceof EnhancedJwtAuthenticationToken enhancedJwt) {
return enhancedJwt.getSupabaseId();
} else if (authentication instanceof ApiKeyAuthenticationToken) {
// For API key authentication, the name is the API key
return authentication.getName();
}
// Fallback for other authentication types
return authentication.getName();
}
/**
* Extract the email from an authentication object for audit and display purposes.
*
* @param authentication The authentication object
* @return The user's email or fallback identifier
*/
public static String extractEmail(Authentication authentication) {
if (authentication instanceof EnhancedJwtAuthenticationToken enhancedJwt) {
return enhancedJwt.getEmail();
}
// For other types, getName() should return the appropriate identifier
return authentication.getName();
}
/**
* Get the current User from an authentication object, looking it up in the database if needed.
*
* @param authentication The authentication object
* @param userRepository Repository to look up users
* @return The authenticated User
* @throws SecurityException if user cannot be resolved
*/
public static User getCurrentUser(
Authentication authentication, UserRepository userRepository) {
if (authentication == null) {
throw new SecurityException("Not authenticated");
}
Object principal = authentication.getPrincipal();
// Direct User object (from custom filter)
if (principal instanceof User) {
return (User) principal;
}
// Handle EnhancedJwtAuthenticationToken (includes anonymous users)
// Use Supabase ID lookup which works for all JWT users
if (authentication instanceof EnhancedJwtAuthenticationToken) {
String supabaseId = extractSupabaseId(authentication);
try {
java.util.UUID supabaseUuid = java.util.UUID.fromString(supabaseId);
return userRepository
.findBySupabaseId(supabaseUuid)
.orElseThrow(() -> new SecurityException("User not found"));
} catch (IllegalArgumentException e) {
throw new SecurityException("Invalid Supabase ID format: " + supabaseId);
}
}
// String username/email
if (principal instanceof String) {
return userRepository
.findByUsername((String) principal)
.orElseThrow(() -> new SecurityException("User not found"));
}
// Jwt principal from oauth2ResourceServer
if (principal instanceof Jwt jwt) {
String email = jwt.getClaimAsString("email");
if (email != null) {
return userRepository
.findByUsername(email)
.orElseThrow(() -> new SecurityException("User not found"));
}
}
throw new SecurityException(
"Invalid authentication principal: " + principal.getClass().getName());
}
}
@@ -0,0 +1,97 @@
package stirling.software.saas.util;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.UserCredit;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.TeamCreditService;
/**
* Resolves the user's remaining credit balance. Uses the team pool for non-personal team members,
* otherwise the user's individual credits (looked up by Supabase ID or API key).
*/
@Component
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class CreditHeaderUtils {
private final SaasTeamExtensionService saasTeamExtensionService;
/**
* Get the remaining credits for a user, checking team credits first (non-personal teams only).
*
* @param user The user whose credits to check
* @param creditService The credit service to fetch user credits
* @param teamCreditService The team credit service to fetch team credits
* @return The remaining credit balance, or -1 if credits cannot be determined
*/
public int getRemainingCredits(
User user, CreditService creditService, TeamCreditService teamCreditService) {
try {
// Limited-API users always read personal credits.
boolean isLimitedApiUser =
user.getAuthorities().stream()
.anyMatch(
authority ->
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|| "ROLE_EXTRA_LIMITED_API_USER"
.equals(authority.getAuthority()));
Long targetTeamId = null;
if (!isLimitedApiUser
&& user.getTeam() != null
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
targetTeamId = user.getTeam().getId();
}
if (targetTeamId != null) {
return teamCreditService
.getTeamCredits(targetTeamId)
.map(TeamCredit::getTotalAvailableCredits)
.orElse(-1);
} else {
log.debug(
"[CREDIT-HEADER] Getting personal credits - SupabaseId: {}, ApiKey: {}, Username: {}",
user.getSupabaseId(),
user.getApiKey() != null ? "present" : "null",
user.getUsername());
Optional<UserCredit> credits;
if (user.getSupabaseId() != null) {
credits =
creditService.getUserCreditsBySupabaseId(
user.getSupabaseId().toString());
log.debug(
"[CREDIT-HEADER] Looked up by SupabaseId - Found: {}",
credits.isPresent());
} else if (user.getApiKey() != null) {
credits = creditService.getUserCreditsByApiKey(user.getApiKey());
log.debug(
"[CREDIT-HEADER] Looked up by ApiKey - Found: {}", credits.isPresent());
} else {
log.warn(
"[CREDIT-HEADER] No SupabaseId or ApiKey for user: {}",
user.getUsername());
return -1;
}
int remaining = credits.map(UserCredit::getTotalAvailableCredits).orElse(-1);
log.debug("[CREDIT-HEADER] Returning credits: {}", remaining);
return remaining;
}
} catch (Exception e) {
log.warn("[CREDIT-HEADER] Could not get remaining credits: {}", e.getMessage(), e);
return -1;
}
}
}
@@ -0,0 +1,34 @@
package stirling.software.saas.util;
import java.util.UUID;
/** PII redaction helpers for log lines. */
public final class LogRedactionUtils {
private LogRedactionUtils() {}
/** Mask an email as {@code j***@stirling.com}; non-email input is returned unchanged. */
public static String redactEmail(String email) {
if (email == null || email.isBlank()) {
return email;
}
int at = email.indexOf('@');
if (at <= 0 || at == email.length() - 1) {
return email;
}
return email.charAt(0) + "***" + email.substring(at);
}
/** Mask a Supabase UUID as {@code 12345678-***-abcd}; short input is returned unchanged. */
public static String redactSupabaseId(String supabaseId) {
if (supabaseId == null || supabaseId.length() < 12) {
return supabaseId;
}
return supabaseId.substring(0, 8) + "-***-" + supabaseId.substring(supabaseId.length() - 4);
}
/** UUID overload. */
public static String redactSupabaseId(UUID supabaseId) {
return supabaseId == null ? null : redactSupabaseId(supabaseId.toString());
}
}
@@ -0,0 +1,25 @@
# SaaS dev profile. Points at the dev Supabase project.
# Boot: java -jar stirling-pdf.jar --spring.profiles.include=dev
spring.config.import=optional:classpath:application-dev-local.properties
app.supabase.project-ref=qacaivhsjtftfwtgjvva
stirling.supabase.url=https://qacaivhsjtftfwtgjvva.supabase.co
stirling.supabase.publishable-key=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow
spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.qacaivhsjtftfwtgjvva.supabase.co:5432/postgres?ApplicationName=stirling-consolidation-${user.name}}
spring.datasource.username=${SAAS_DEV_DB_USERNAME:postgres}
# Password not committed; export SAAS_DEV_DB_PASSWORD or pass --spring.datasource.password=...
spring.datasource.password=${SAAS_DEV_DB_PASSWORD:}
# Conservative dev pool sizing.
spring.datasource.hikari.maximum-pool-size=2
spring.datasource.hikari.minimum-idle=1
spring.datasource.hikari.idle-timeout=60000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.keepalive-time=300000
spring.datasource.hikari.data-source-properties.ApplicationName=stirling-consolidation-${user.name}
logging.level.stirling.software.saas=DEBUG
logging.level.org.springframework.security.oauth2.jwt=WARN
logging.level.org.springframework.security.oauth2.server.resource=WARN
@@ -0,0 +1,41 @@
# Stirling-PDF SaaS profile. Pure multi-tenant cloud.
# Activated when the :saas module is on the classpath.
# ---------- Datasource ----------
system.datasource.enableCustomDatabase=true
system.datasource.customDatabaseUrl=${SAAS_DB_URL:}
system.datasource.username=${SAAS_DB_USERNAME:postgres}
system.datasource.password=${SAAS_DB_PASSWORD:}
system.datasource.type=postgresql
# Override application.properties' default H2 URL so missing creds fail clearly.
spring.datasource.url=${SAAS_DB_URL:}
spring.datasource.username=${SAAS_DB_USERNAME:postgres}
spring.datasource.password=${SAAS_DB_PASSWORD:}
# ---------- DB schema / migrations ----------
spring.jpa.properties.hibernate.default_schema=stirling_pdf
spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true
spring.jpa.hibernate.ddl-auto=update
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=true
spring.flyway.locations=classpath:db/migration,classpath:db/migration/saas
spring.flyway.schemas=stirling_pdf
spring.flyway.default-schema=stirling_pdf
# ---------- Supabase JWT auth ----------
# Required: set SAAS_DB_PROJECT_REF via env.
app.supabase.project-ref=${SAAS_DB_PROJECT_REF:}
app.supabase.issuer=https://${app.supabase.project-ref}.supabase.co/auth/v1
app.supabase.expected-aud=${app.jwt.expected-aud:authenticated}
app.supabase.clock-skew-seconds=${app.jwt.clock-skew-seconds:120}
# Edge Function admin endpoint (used by billing/license rollup).
app.supabase.edge-function-url=https://${app.supabase.project-ref}.supabase.co/functions/v1
app.supabase.edge-function-secret=${SUPABASE_EDGE_FUNCTION_SECRET:}
supabase.url=https://${app.supabase.project-ref}.supabase.co
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://${app.supabase.project-ref}.supabase.co/auth/v1/.well-known/jwks.json
spring.security.oauth2.resourceserver.jwt.audiences=${app.supabase.expected-aud}
@@ -0,0 +1,7 @@
-- SaaS-only column additions on top of the OSS users schema. Idempotent.
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS supabase_id UUID;
CREATE UNIQUE INDEX IF NOT EXISTS uk_users_email ON users (email);
CREATE UNIQUE INDEX IF NOT EXISTS uk_users_supabase_id ON users (supabase_id);
@@ -0,0 +1,16 @@
-- Stripe billing subscription mirror, populated by Supabase webhooks.
CREATE TABLE IF NOT EXISTS billing_subscriptions (
id VARCHAR(255) PRIMARY KEY,
user_id UUID NOT NULL,
team_id BIGINT,
status VARCHAR(64) NOT NULL,
price_id VARCHAR(255),
current_period_end TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_user_id ON billing_subscriptions (user_id);
CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_team_id ON billing_subscriptions (team_id);
CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_status ON billing_subscriptions (status);
@@ -0,0 +1,39 @@
-- Per-user and per-team credit pools.
CREATE TABLE IF NOT EXISTS user_credits (
credit_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
cycle_credits_remaining INTEGER NOT NULL DEFAULT 0,
cycle_credits_allocated INTEGER NOT NULL DEFAULT 0,
bought_credits_remaining INTEGER NOT NULL DEFAULT 0,
total_bought_credits INTEGER NOT NULL DEFAULT 0,
last_cycle_reset_at TIMESTAMP,
last_api_usage TIMESTAMP,
total_api_calls_made BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT uk_user_credits_user_id UNIQUE (user_id)
);
CREATE INDEX IF NOT EXISTS idx_user_credits_user_id ON user_credits (user_id);
CREATE INDEX IF NOT EXISTS idx_user_credits_last_reset ON user_credits (last_cycle_reset_at);
CREATE INDEX IF NOT EXISTS idx_user_credits_last_usage ON user_credits (last_api_usage);
CREATE TABLE IF NOT EXISTS team_credits (
credit_id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL UNIQUE REFERENCES teams(id) ON DELETE CASCADE,
cycle_credits_remaining INTEGER NOT NULL DEFAULT 0,
cycle_credits_allocated INTEGER NOT NULL DEFAULT 0,
bought_credits_remaining INTEGER NOT NULL DEFAULT 0,
total_bought_credits INTEGER NOT NULL DEFAULT 0,
last_cycle_reset_at TIMESTAMP,
last_api_usage TIMESTAMP,
total_api_calls_made BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_team_credits_team_id ON team_credits (team_id);
CREATE INDEX IF NOT EXISTS idx_team_credits_last_reset ON team_credits (last_cycle_reset_at);
@@ -0,0 +1,40 @@
-- Team memberships and email-based team invitations.
CREATE TABLE IF NOT EXISTS team_memberships (
membership_id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL DEFAULT 'MEMBER',
invited_by_user_id BIGINT REFERENCES users(user_id) ON DELETE SET NULL,
invited_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
accepted_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_team_memberships_team_user UNIQUE (team_id, user_id),
CONSTRAINT chk_team_memberships_role CHECK (role IN ('LEADER', 'MEMBER'))
);
CREATE INDEX IF NOT EXISTS idx_team_memberships_team ON team_memberships (team_id);
CREATE INDEX IF NOT EXISTS idx_team_memberships_user ON team_memberships (user_id);
CREATE INDEX IF NOT EXISTS idx_team_memberships_team_role ON team_memberships (team_id, role);
CREATE TABLE IF NOT EXISTS team_invitations (
invitation_id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
inviter_user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
invitee_email VARCHAR(255) NOT NULL,
invitee_user_id BIGINT REFERENCES users(user_id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
invitation_token VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_team_invitations_status CHECK (
status IN ('PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'EXPIRED')
)
);
CREATE INDEX IF NOT EXISTS idx_team_invitations_team ON team_invitations (team_id);
CREATE INDEX IF NOT EXISTS idx_team_invitations_email ON team_invitations (invitee_email);
CREATE INDEX IF NOT EXISTS idx_team_invitations_token ON team_invitations (invitation_token);
CREATE INDEX IF NOT EXISTS idx_team_invitations_status ON team_invitations (status);
@@ -0,0 +1,15 @@
-- Per-user processing-error tracker.
CREATE TABLE IF NOT EXISTS user_error_tracker (
error_tracker_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
endpoint VARCHAR(255),
processing_error_count INTEGER NOT NULL DEFAULT 0,
last_processing_error TIMESTAMP,
reset_after TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_error_tracker_user_id ON user_error_tracker (user_id);
CREATE INDEX IF NOT EXISTS idx_user_error_tracker_endpoint ON user_error_tracker (endpoint);
@@ -0,0 +1,28 @@
-- AI document-creation sessions for chat / outline-to-PDF flows.
CREATE TABLE IF NOT EXISTS ai_create_sessions (
session_id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
doc_type VARCHAR(255),
template_id VARCHAR(255),
template_tex VARCHAR(255),
preview_tex VARCHAR(255),
prompt_initial TEXT,
prompt_latest TEXT,
outline_text TEXT,
outline_filename VARCHAR(255),
outline_approved BOOLEAN NOT NULL DEFAULT FALSE,
outline_constraints TEXT,
draft_sections TEXT,
polished_latex TEXT,
pdf_url VARCHAR(2048),
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ai_create_sessions_user_id ON ai_create_sessions (user_id);
CREATE INDEX IF NOT EXISTS idx_ai_create_sessions_updated_at ON ai_create_sessions (updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_ai_create_sessions_user_pdf
ON ai_create_sessions (user_id, updated_at DESC)
WHERE pdf_url IS NOT NULL;
@@ -0,0 +1,77 @@
-- Saas-only sidecar tables for user and team metadata.
CREATE TABLE IF NOT EXISTS saas_user_extensions (
user_id BIGINT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
has_metered_billing_enabled BOOLEAN NOT NULL DEFAULT FALSE,
api_key_first_used_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_saas_user_extensions_metered_billing
ON saas_user_extensions (has_metered_billing_enabled);
CREATE TABLE IF NOT EXISTS saas_team_extensions (
team_id BIGINT PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE,
team_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD',
is_personal BOOLEAN NOT NULL DEFAULT FALSE,
seat_count INTEGER NOT NULL DEFAULT 1,
seats_used INTEGER NOT NULL DEFAULT 0,
max_seats INTEGER NOT NULL DEFAULT 1,
created_by_user_id BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_saas_team_extensions_is_personal
ON saas_team_extensions (is_personal);
CREATE INDEX IF NOT EXISTS idx_saas_team_extensions_created_by_user_id
ON saas_team_extensions (created_by_user_id);
-- Backfill from any pre-existing columns on users / teams, then drop them.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = 'has_metered_billing_enabled'
) THEN
INSERT INTO saas_user_extensions (user_id, has_metered_billing_enabled, api_key_first_used_at)
SELECT user_id, COALESCE(has_metered_billing_enabled, FALSE), api_key_first_used_at
FROM users
ON CONFLICT (user_id) DO NOTHING;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'teams'
AND column_name = 'team_type'
) THEN
INSERT INTO saas_team_extensions (
team_id, team_type, is_personal, seat_count, seats_used, max_seats, created_by_user_id
)
SELECT id,
COALESCE(team_type, 'STANDARD'),
COALESCE(is_personal, FALSE),
COALESCE(seat_count, 1),
COALESCE(seats_used, 0),
COALESCE(max_seats, 1),
created_by_user_id
FROM teams
ON CONFLICT (team_id) DO NOTHING;
ALTER TABLE teams DROP COLUMN IF EXISTS team_type;
ALTER TABLE teams DROP COLUMN IF EXISTS is_personal;
ALTER TABLE teams DROP COLUMN IF EXISTS seat_count;
ALTER TABLE teams DROP COLUMN IF EXISTS seats_used;
ALTER TABLE teams DROP COLUMN IF EXISTS max_seats;
ALTER TABLE teams DROP COLUMN IF EXISTS created_by_user_id;
END IF;
ALTER TABLE users DROP COLUMN IF EXISTS has_metered_billing_enabled;
ALTER TABLE users DROP COLUMN IF EXISTS api_key_first_used_at;
END
$$;