-----BEGIN ENCRYPTED PRIVATE KEY-----
-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
+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
-----END ENCRYPTED PRIVATE KEY-----
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java
index bdd9df10a8..558341745d 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java
@@ -24,22 +24,6 @@ import tools.jackson.databind.node.ObjectNode;
/**
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
* A").
- *
- * Calls:
- *
- *
- * - {@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
- * /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
- *
- {@link #fetchEntitlement} — authenticates with the stored device credential against {@code
- * GET /api/v1/instance/entitlement}; what the local gate consults.
- *
- {@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
- * cumulative units and returns the refreshed entitlement.
- *
- {@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
- * /api/v1/instance/revoke-self}).
- *
- *
- * Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
- * {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
*/
@Slf4j
@Service
@@ -72,13 +56,7 @@ public class AccountLinkClient {
this.httpClient = httpClient;
}
- /** The device credential a successful {@link #register} returns. */
- public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {}
-
- /**
- * A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can
- * map auth failures (401/403) through rather than masking everything as a 502.
- */
+ /** A non-2xx reply from the SaaS account-link API. */
public static class UpstreamException extends IOException {
private final int status;
@@ -92,11 +70,7 @@ public class AccountLinkClient {
}
}
- /**
- * Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a
- * transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
- * this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
- */
+ /** Authoritative deny (401/403) — the device credential is revoked or invalid. */
public static final class RevokedException extends RuntimeException {
private final int status;
@@ -110,46 +84,142 @@ public class AccountLinkClient {
}
}
+ /** What the SaaS side hands back when it records a connect handshake. */
+ public record ConnectRequestResult(
+ String requestId, int expiresInSeconds, String authorizeUrl) {}
+
+ public enum ConnectClaimOutcome {
+ /** Approved and collected; the credential fields are populated. */
+ GRANTED,
+ /** A re-authentication was approved. */
+ CONFIRMED,
+ /** No human decision yet. */
+ PENDING,
+ /** Declined, expired or already used. */
+ REJECTED,
+ /** SaaS unreachable or erroring. */
+ UNAVAILABLE
+ }
+
+ public record ConnectClaimResult(
+ ConnectClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) {
+ static ConnectClaimResult of(ConnectClaimOutcome outcome) {
+ return new ConnectClaimResult(outcome, null, null, null);
+ }
+ }
+
+ /** Opens a connect handshake. */
+ public ConnectRequestResult connectRequest(
+ String name, String callbackUrl, String nonce, String claimSecret) throws IOException {
+ return connectRequest(name, callbackUrl, nonce, claimSecret, null);
+ }
+
/**
- * Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted
- * credential.
- *
- * @throws IOException on transport failure or a non-2xx response (caller surfaces to the
- * admin).
+ * As {@link #connectRequest}, but presenting an existing device credential so the SaaS side
+ * treats this as a re-authentication and pins the handshake to the team we already belong to.
*/
- public RegisterResult register(String supabaseJwt, String instanceName) throws IOException {
- String body =
- instanceName == null || instanceName.isBlank()
- ? "{}"
- : "{\"name\":" + mapper.writeValueAsString(instanceName) + "}";
- HttpRequest request =
+ public ConnectRequestResult connectRequest(
+ String name,
+ String callbackUrl,
+ String nonce,
+ String claimSecret,
+ DeviceCredential credential)
+ throws IOException {
+ ObjectNode root = mapper.createObjectNode();
+ if (name != null && !name.isBlank()) {
+ root.put("name", name);
+ }
+ root.put("callbackUrl", callbackUrl);
+ root.put("nonce", nonce);
+ root.put("claimSecret", claimSecret);
+
+ HttpRequest.Builder builder =
HttpRequest.newBuilder()
- .uri(uri("/api/v1/account-link/register"))
- .header("Authorization", "Bearer " + supabaseJwt)
+ .uri(uri("/api/v1/account-link/connect/request"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
- .POST(HttpRequest.BodyPublishers.ofString(body))
- .build();
+ .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(root)));
+ if (credential != null) {
+ builder.header(HEADER_DEVICE_ID, credential.getDeviceId())
+ .header(HEADER_DEVICE_SECRET, credential.getDeviceSecret());
+ }
- HttpResponse response = send(request);
+ HttpResponse response = send(builder.build());
if (response.statusCode() / 100 != 2) {
throw new UpstreamException(response.statusCode(), response.body());
}
- JsonNode root = mapper.readTree(response.body());
- String deviceId = text(root, "deviceId");
- String deviceSecret = text(root, "deviceSecret");
- if (deviceId == null || deviceSecret == null) {
- throw new IOException("SaaS register response missing deviceId/deviceSecret");
+ JsonNode body = mapper.readTree(response.body());
+ String requestId = text(body, "requestId");
+ if (requestId == null) {
+ throw new IOException("SaaS connect response missing requestId");
+ }
+ String authorizeUrl = text(body, "authorizeUrl");
+ if (authorizeUrl == null || !isAbsoluteHttpUrl(authorizeUrl)) {
+ throw new IOException("SaaS connect response carried no usable authorizeUrl");
+ }
+ return new ConnectRequestResult(requestId, body.path("expiresIn").asInt(0), authorizeUrl);
+ }
+
+ /**
+ * Collects the device credential for an approved handshake, proving possession of the claim
+ * secret.
+ */
+ public ConnectClaimResult connectClaim(String requestId, String claimSecret) {
+ HttpResponse response;
+ try {
+ ObjectNode root = mapper.createObjectNode();
+ root.put("requestId", requestId);
+ root.put("claimSecret", claimSecret);
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .uri(uri("/api/v1/account-link/connect/claim"))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .timeout(timeout())
+ .POST(
+ HttpRequest.BodyPublishers.ofString(
+ mapper.writeValueAsString(root)))
+ .build();
+ response = send(request);
+ } catch (Exception e) {
+ log.debug("Connect claim failed (transport): {}", e.getMessage());
+ return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
+ }
+ int status = response.statusCode();
+ if (status == 202) {
+ return ConnectClaimResult.of(ConnectClaimOutcome.PENDING);
+ }
+ if (status >= 500 && status <= 599) {
+ return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
+ }
+ if (status < 200 || status > 299) {
+ return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
+ }
+ try {
+ JsonNode body = mapper.readTree(response.body());
+ Long teamId = body.hasNonNull("teamId") ? body.get("teamId").asLong() : null;
+ // A re-authentication says so explicitly and carries no credential, so an absent
+ // credential is only an error when we were expecting one.
+ if ("confirmed".equals(text(body, "status"))) {
+ return new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, teamId);
+ }
+ String deviceId = text(body, "deviceId");
+ String deviceSecret = text(body, "deviceSecret");
+ if (deviceId == null || deviceSecret == null) {
+ log.warn("Connect claim succeeded but the reply carried no credential");
+ return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
+ }
+ return new ConnectClaimResult(
+ ConnectClaimOutcome.GRANTED, deviceId, deviceSecret, teamId);
+ } catch (RuntimeException e) {
+ log.debug("Connect claim parse failed: {}", e.getMessage());
+ return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
}
- Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null;
- return new RegisterResult(deviceId, deviceSecret, teamId);
}
/**
* Revokes this instance's own credential on the SaaS side, authenticated by that credential.
- * Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
- * unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
*/
public boolean revokeSelf(String deviceId, String deviceSecret) {
try {
@@ -174,17 +244,7 @@ public class AccountLinkClient {
}
}
- /**
- * Fetches the current entitlement using the stored device credential. Three outcomes:
- *
- *
- * - 2xx → the parsed snapshot.
- *
- 401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential);
- * the caller must BLOCK, not fail open.
- *
- transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null}
- * ("unknown" — the caller fails open).
- *
- */
+ /** Fetches the current entitlement using the stored device credential. */
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
HttpResponse response;
try {
@@ -224,9 +284,6 @@ public class AccountLinkClient {
/**
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
* returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
- * SaaS bills the delta against its last-seen cumulative, so resending the same totals is
- * idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
- * not advance its last-synced markers so the usage retries next sync.
*/
public InstanceEntitlement reportUsage(
String deviceId,
@@ -360,4 +417,19 @@ public class AccountLinkClient {
private static String text(JsonNode node, String field) {
return node.hasNonNull(field) ? node.get(field).asText() : null;
}
+
+ /** Absolute http(s) with a host. */
+ static boolean isAbsoluteHttpUrl(String candidate) {
+ try {
+ URI uri = URI.create(candidate.strip());
+ String scheme = uri.getScheme();
+ return uri.isAbsolute()
+ && scheme != null
+ && ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
+ && uri.getHost() != null
+ && !uri.getHost().isBlank();
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java
index 52af366df4..b1826b7cca 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java
@@ -16,21 +16,11 @@ import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
+import jakarta.servlet.http.HttpServletRequest;
+
import lombok.extern.slf4j.Slf4j;
-/**
- * Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A").
- *
- * The portal (served from this same origin, admin authenticated by the existing self-hosted
- * security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
- * backend, which mints + returns a device credential we store locally. {@code GET /status} backs
- * the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
- * to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
- * / test aid).
- *
- *
Admin-only, {@code @Profile("!saas")}, gated behind {@code
- * stirling.billing.account-link.enabled} — off → bean absent → 404.
- */
+/** Same-origin account-link surface on the self-hosted instance (combined billing). */
@Slf4j
@Hidden
@RestController
@@ -41,51 +31,110 @@ import lombok.extern.slf4j.Slf4j;
public class AccountLinkController {
private final AccountLinkService service;
+ private final ConnectService connectService;
private final LocalUsageService localUsageService;
// Present only when metering is on (its own flag); absent → /sync-now reports 409.
private final ObjectProvider syncServiceProvider;
public AccountLinkController(
AccountLinkService service,
+ ConnectService connectService,
LocalUsageService localUsageService,
ObjectProvider syncServiceProvider) {
this.service = service;
+ this.connectService = connectService;
this.localUsageService = localUsageService;
this.syncServiceProvider = syncServiceProvider;
}
- /** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
- public record LinkRequest(String supabaseJwt, String name) {}
+ /** {@code callbackUrl} is the portal telling us where its own callback route lives. */
+ public record ConnectStartRequest(String name, String callbackUrl) {}
- @PostMapping("/link")
- public ResponseEntity> link(@RequestBody LinkRequest req) {
- if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) {
- return ResponseEntity.badRequest()
- .body(java.util.Map.of("error", "supabaseJwt is required"));
- }
+ /** {@code nonce} comes from the callback fragment the approval page redirected to. */
+ public record ConnectCompleteRequest(String nonce) {}
+
+ /**
+ * Opens a browser-mediated link handshake and returns the approval URL to send the admin to.
+ */
+ @PostMapping("/connect/start")
+ public ResponseEntity> connectStart(
+ @RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
try {
- return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name()));
+ return ResponseEntity.ok(
+ connectService.start(req != null ? req.name() : null, callbackHint(req, http)));
} catch (AccountLinkClient.UpstreamException e) {
- // Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so
- // the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the
- // raw upstream body back to the browser.
- HttpStatus status =
- e.status() == HttpStatus.UNAUTHORIZED.value()
- || e.status() == HttpStatus.FORBIDDEN.value()
- ? HttpStatus.valueOf(e.status())
- : HttpStatus.BAD_GATEWAY;
- log.warn("Account-link register rejected upstream: HTTP {}", e.status());
- return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED"));
- } catch (IOException e) {
- // Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the
- // configured SaaS host/IP. Log it server-side; return the same opaque body the
- // UpstreamException branch does.
- log.warn("Account-link failed (transport): {}", e.getMessage());
+ log.warn("Account-link connect rejected upstream: HTTP {}", e.status());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
- .body(java.util.Map.of("error", "LINK_FAILED"));
+ .body(java.util.Map.of("error", "CONNECT_FAILED"));
+ } catch (IOException e) {
+ // Same reasoning as /link: a transport message can carry the configured SaaS host.
+ log.warn("Account-link connect failed (transport): {}", e.getMessage());
+ return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
+ .body(java.util.Map.of("error", "CONNECT_FAILED"));
}
}
+ /** Re-establishes the admin's SaaS session for a server that is already linked. */
+ @PostMapping("/connect/reauth")
+ public ResponseEntity> connectReauth(
+ @RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
+ try {
+ return ResponseEntity.ok(connectService.startReauth(callbackHint(req, http)));
+ } catch (AccountLinkClient.UpstreamException e) {
+ log.warn("Account-link reauth rejected upstream: HTTP {}", e.status());
+ return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
+ .body(java.util.Map.of("error", "CONNECT_FAILED"));
+ } catch (IOException e) {
+ log.warn("Account-link reauth failed: {}", e.getMessage());
+ return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
+ .body(java.util.Map.of("error", "CONNECT_FAILED"));
+ }
+ }
+
+ /** Called by the callback page with the nonce it found in the fragment. */
+ @PostMapping("/connect/complete")
+ public ResponseEntity connectComplete(
+ @RequestBody(required = false) ConnectCompleteRequest req) {
+ return ResponseEntity.ok(connectService.complete(req != null ? req.nonce() : null));
+ }
+
+ /** Everything we know about where the admin's browser is, for the callback. */
+ private static ConnectService.CallbackHint callbackHint(
+ ConnectStartRequest req, HttpServletRequest http) {
+ return new ConnectService.CallbackHint(
+ req != null ? req.callbackUrl() : null, http.getHeader("Origin"), baseUrlOf(http));
+ }
+
+ /**
+ * This instance's base URL as the browser reached it, including any context path so a subpath
+ * deployment builds a callback that actually resolves.
+ */
+ private static String baseUrlOf(HttpServletRequest request) {
+ String forwardedProto = firstHop(request.getHeader("X-Forwarded-Proto"));
+ String forwardedHost = firstHop(request.getHeader("X-Forwarded-Host"));
+ String scheme = forwardedProto != null ? forwardedProto : request.getScheme();
+ String hostPort;
+ if (forwardedHost != null) {
+ hostPort = forwardedHost;
+ } else {
+ int port = request.getServerPort();
+ boolean defaultPort =
+ ("http".equals(scheme) && port == 80)
+ || ("https".equals(scheme) && port == 443);
+ hostPort = defaultPort ? request.getServerName() : request.getServerName() + ":" + port;
+ }
+ String context = request.getContextPath() == null ? "" : request.getContextPath();
+ return scheme + "://" + hostPort + context;
+ }
+
+ private static String firstHop(String headerValue) {
+ if (headerValue == null || headerValue.isBlank()) {
+ return null;
+ }
+ String first = headerValue.split(",")[0].strip();
+ return first.isEmpty() ? null : first;
+ }
+
@GetMapping("/status")
public ResponseEntity status() {
return ResponseEntity.ok(service.status());
@@ -106,12 +155,7 @@ public class AccountLinkController {
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
}
- /**
- * Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
- * "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
- * re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
- * {@code 409} when metering is off (the sync bean is absent).
- */
+ /** Forces an immediate usage sync to SaaS — the same work the daily scheduler does. */
@PostMapping("/sync-now")
public ResponseEntity syncNow() {
UsageSyncService sync = syncServiceProvider.getIfAvailable();
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java
index 6d1f1fb151..619aa10e86 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java
@@ -8,29 +8,17 @@ import org.springframework.stereotype.Component;
import lombok.Getter;
import lombok.Setter;
-/**
- * Self-hosted side of combined-billing "Mode A" (connected self-hosted).
- *
- * Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag
- * the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional
- * code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is
- * off by default and dark — when off nothing gates and the link endpoints 404.
- */
+/** Self-hosted side of combined billing: this instance bills through a linked SaaS team. */
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "stirling.billing.account-link")
public class AccountLinkProperties {
- /** Master switch. When {@code false} (default) the feature is fully inert. */
+ /** Master switch. */
private boolean enabled = false;
- /**
- * Base URL of the SaaS backend this instance links to (register + entitlement live there).
- *
- *
STUB: defaults to the public cloud host; an operator overrides it for staging. There is no
- * existing SaaS-base-url property in the self-hosted profile, so this is introduced here.
- */
+ /** Base URL of the SaaS backend this instance links to (register + entitlement live there). */
private String saasBaseUrl = "https://stirling.com/app";
/** Cached entitlement is reused for this long before a refresh is attempted. */
@@ -39,20 +27,18 @@ public class AccountLinkProperties {
/** Connect/read timeout for the outbound SaaS calls. */
private int requestTimeoutSeconds = 10;
- /** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
+ /** Phase 2 usage metering + daily sync. */
private final Metering metering = new Metering();
/**
- * Dedicated billing switch, separate from {@link #enabled} so the link plumbing can be
- * enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
- * enforcement. Both default off; metering requires the master flag too. This is the production
- * safety key — flipping it on is what actually bills linked instances.
+ * Separate from {@link #enabled} so linking can be exercised without billing anything. Both
+ * default off, and metering needs the master flag as well.
*/
@Getter
@Setter
public static class Metering {
- /** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
+ /** Turns on usage metering, the daily sync, and cap enforcement. */
private boolean enabled = false;
/**
@@ -65,12 +51,7 @@ public class AccountLinkProperties {
*/
private int graceDays = 3;
- /**
- * Dedup window for identical input sets. A re-run of the same inputs within this window is
- * treated as workflow chaining and not re-charged; the same inputs run again after it are
- * billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
- * costs the same on the instance and in the cloud.
- */
+ /** Dedup window for identical input sets. */
private Duration workflowWindow = Duration.ofMinutes(5);
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java
index 1bb27d9cd6..e1283d83ba 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java
@@ -1,6 +1,5 @@
package stirling.software.proprietary.accountlink;
-import java.io.IOException;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -9,13 +8,7 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
-/**
- * Linking orchestrator (self-hosted side of combined-billing "Mode A").
- *
- *
{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase
- * JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest.
- * The credential — not the JWT — authenticates all later unattended entitlement calls.
- */
+/** Linking orchestrator (self-hosted side of combined billing). */
@Slf4j
@Service
@Profile("!saas")
@@ -38,24 +31,9 @@ public class AccountLinkService {
/** Status of this instance's link, for the portal's "Account link" card. */
public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {}
- /**
- * Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the
- * credential.
- *
- * @throws IOException if the SaaS register call fails (surfaced to the admin as a link error).
- */
- public LinkStatus link(String supabaseJwt, String instanceName) throws IOException {
- AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName);
- credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId());
- entitlementCache.invalidate();
- log.info("Account-link: instance linked to team {}", result.teamId());
- return status();
- }
-
/**
* Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code
- * revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear
- * still proceeds (admin's intent must win); the orphan row can be revoked from the portal.
+ * revoked_at} set), then clears locally regardless.
*/
public void unlink() {
credentialStore
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java
index fbac6a8603..2715b4743b 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java
@@ -12,7 +12,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
- * Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
+ * Singleton row holding this instance's daily-sync bookkeeping (combined billing).
*
*
{@link #lastSyncSeq} is reserved (incremented + persisted) before each report so it
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java
index 15b5e3842d..d0cdf36f90 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java
@@ -2,5 +2,5 @@ package stirling.software.proprietary.accountlink;
import org.springframework.data.jpa.repository.JpaRepository;
-/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
+/** Persistence for the singleton {@link AccountLinkSyncState} (combined billing). */
public interface AccountLinkSyncStateRepository extends JpaRepository {}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java
new file mode 100644
index 0000000000..978d6d494e
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java
@@ -0,0 +1,276 @@
+package stirling.software.proprietary.accountlink;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.Base64;
+import java.util.Locale;
+import java.util.Optional;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.model.ApplicationProperties;
+
+/** Browser-mediated account linking, instance side. */
+@Slf4j
+@Service
+@Profile("!saas")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class ConnectService {
+
+ /** Frontend route that consumes the callback fragment. */
+ static final String CALLBACK_PATH = "/account-link/callback";
+
+ private static final int SECRET_BYTES = 32;
+
+ private final AccountLinkClient client;
+ private final ConnectStateRepository stateRepo;
+ private final DeviceCredentialStore credentialStore;
+ private final EntitlementCache entitlementCache;
+ private final ApplicationProperties applicationProperties;
+ private final SecureRandom random = new SecureRandom();
+
+ public ConnectService(
+ AccountLinkClient client,
+ ConnectStateRepository stateRepo,
+ DeviceCredentialStore credentialStore,
+ EntitlementCache entitlementCache,
+ ApplicationProperties applicationProperties) {
+ this.client = client;
+ this.stateRepo = stateRepo;
+ this.credentialStore = credentialStore;
+ this.entitlementCache = entitlementCache;
+ this.applicationProperties = applicationProperties;
+ }
+
+ public enum Phase {
+ /** Nothing in flight and not linked. */
+ NONE,
+ /** A handshake is open, waiting for a leader to approve it on the SaaS site. */
+ PENDING,
+ /** Linked. */
+ LINKED,
+ /** The handshake outlived its window; start a new one. */
+ EXPIRED,
+ /** Declined or already used; start a new one. */
+ REJECTED,
+ /** SaaS could not be reached; the handshake is still valid and can be retried. */
+ UNAVAILABLE
+ }
+
+ /** What the portal renders. */
+ public record ConnectStatus(
+ Phase phase, String authorizeUrl, Long secondsRemaining, Long teamId) {
+ static ConnectStatus of(Phase phase) {
+ return new ConnectStatus(phase, null, null, null);
+ }
+ }
+
+ /** Everything we know about where the admin's browser actually is, in decreasing authority. */
+ public record CallbackHint(
+ String requestedCallbackUrl, String browserOrigin, String derivedBaseUrl) {}
+
+ /** Opens a handshake and returns where to send the admin. */
+ @Transactional
+ public ConnectStatus start(String name, CallbackHint hint) throws IOException {
+ if (credentialStore.isLinked()) {
+ return status();
+ }
+ return open(name, hint, null);
+ }
+
+ /**
+ * Opens a handshake that only re-establishes the admin's browser session, for an instance that
+ * is already linked.
+ */
+ @Transactional
+ public ConnectStatus startReauth(CallbackHint hint) throws IOException {
+ DeviceCredential credential =
+ credentialStore
+ .get()
+ .orElseThrow(
+ () ->
+ new IOException(
+ "This server is not linked, so there is no session"
+ + " to re-establish"));
+ return open(credential.getDeviceId(), hint, credential);
+ }
+
+ private ConnectStatus open(String name, CallbackHint hint, DeviceCredential credential)
+ throws IOException {
+ String callbackUrl = resolveCallbackUrl(hint);
+ if (callbackUrl == null) {
+ throw new IOException(
+ "Cannot determine where to send the admin back to; set system.frontendUrl");
+ }
+ String nonce = randomSecret();
+ String claimSecret = randomSecret();
+
+ AccountLinkClient.ConnectRequestResult created =
+ client.connectRequest(name, callbackUrl, nonce, claimSecret, credential);
+
+ LocalDateTime now = LocalDateTime.now();
+ ConnectState state = new ConnectState();
+ state.setId(ConnectState.SINGLETON_ID);
+ state.setRequestId(created.requestId());
+ state.setNonce(nonce);
+ state.setClaimSecret(claimSecret);
+ state.setCallbackUrl(callbackUrl);
+ state.setAuthorizeUrl(created.authorizeUrl());
+ state.setCreatedAt(now);
+ state.setExpiresAt(
+ now.plusSeconds(created.expiresInSeconds() > 0 ? created.expiresInSeconds() : 900));
+ stateRepo.save(state);
+
+ log.info("Account-link connect: handshake {} opened", created.requestId());
+ return pendingStatus(state, now);
+ }
+
+ /** Finishes a handshake from the callback the approval page redirected to. */
+ @Transactional
+ public ConnectStatus complete(String nonce) {
+ Optional found = stateRepo.findById(ConnectState.SINGLETON_ID);
+ if (found.isEmpty()) {
+ // Already finished (a double-submitted callback) or never started.
+ return status();
+ }
+ ConnectState state = found.get();
+ if (state.isExpired(LocalDateTime.now())) {
+ stateRepo.delete(state);
+ return ConnectStatus.of(Phase.EXPIRED);
+ }
+ if (nonce == null || !nonceMatches(nonce, state.getNonce())) {
+ log.warn(
+ "Account-link connect: callback for handshake {} had a bad nonce",
+ state.getRequestId());
+ return ConnectStatus.of(Phase.REJECTED);
+ }
+
+ AccountLinkClient.ConnectClaimResult claim =
+ client.connectClaim(state.getRequestId(), state.getClaimSecret());
+ return switch (claim.outcome()) {
+ case GRANTED -> {
+ credentialStore.save(claim.deviceId(), claim.deviceSecret(), claim.teamId());
+ entitlementCache.invalidate();
+ stateRepo.delete(state);
+ log.info("Account-link connect: linked to team {}", claim.teamId());
+ yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
+ }
+ case CONFIRMED -> {
+ stateRepo.delete(state);
+ log.info(
+ "Account-link connect: session re-established for team {}", claim.teamId());
+ yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
+ }
+ case PENDING ->
+ // The admin reached the callback before the approval committed. The row stays,
+ // so a retry finishes it.
+ ConnectStatus.of(Phase.PENDING);
+ case REJECTED -> {
+ stateRepo.delete(state);
+ yield ConnectStatus.of(Phase.REJECTED);
+ }
+ case UNAVAILABLE -> ConnectStatus.of(Phase.UNAVAILABLE);
+ };
+ }
+
+ @Transactional(readOnly = true)
+ public ConnectStatus status() {
+ Optional credential = credentialStore.get();
+ if (credential.isPresent()) {
+ return new ConnectStatus(Phase.LINKED, null, null, credential.get().getTeamId());
+ }
+ Optional state = stateRepo.findById(ConnectState.SINGLETON_ID);
+ if (state.isEmpty()) {
+ return ConnectStatus.of(Phase.NONE);
+ }
+ LocalDateTime now = LocalDateTime.now();
+ if (state.get().isExpired(now)) {
+ return ConnectStatus.of(Phase.EXPIRED);
+ }
+ return pendingStatus(state.get(), now);
+ }
+
+ private static ConnectStatus pendingStatus(ConnectState state, LocalDateTime now) {
+ long remaining = Duration.between(now, state.getExpiresAt()).toSeconds();
+ return new ConnectStatus(
+ Phase.PENDING, state.getAuthorizeUrl(), Math.max(remaining, 0), null);
+ }
+
+ /** Decides the callback, preferring knowledge over inference. */
+ String resolveCallbackUrl(CallbackHint hint) {
+ String configured = applicationProperties.getSystem().getFrontendUrl();
+ if (configured != null && !configured.isBlank()) {
+ return trimTrailingSlash(configured.strip()) + CALLBACK_PATH;
+ }
+ String browserOrigin = originOf(hint.browserOrigin());
+ if (browserOrigin != null) {
+ String requested = hint.requestedCallbackUrl();
+ if (requested != null && browserOrigin.equals(originOf(requested))) {
+ return requested.strip();
+ }
+ return browserOrigin + CALLBACK_PATH;
+ }
+ return hint.derivedBaseUrl() == null || hint.derivedBaseUrl().isBlank()
+ ? null
+ : trimTrailingSlash(hint.derivedBaseUrl().strip()) + CALLBACK_PATH;
+ }
+
+ /** Scheme, host and port of an absolute http(s) URL; null if it is not one. */
+ private static String originOf(String candidate) {
+ if (candidate == null || candidate.isBlank()) {
+ return null;
+ }
+ URI uri;
+ try {
+ uri = new URI(candidate.strip());
+ } catch (URISyntaxException e) {
+ return null;
+ }
+ if (uri.getScheme() == null || uri.getHost() == null) {
+ return null;
+ }
+ String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
+ if (!"http".equals(scheme) && !"https".equals(scheme)) {
+ return null;
+ }
+ int port = uri.getPort();
+ boolean defaultPort =
+ port == -1
+ || ("http".equals(scheme) && port == 80)
+ || ("https".equals(scheme) && port == 443);
+ return defaultPort
+ ? scheme + "://" + uri.getHost()
+ : scheme + "://" + uri.getHost() + ":" + port;
+ }
+
+ private static String trimTrailingSlash(String value) {
+ return value.replaceAll("/+$", "");
+ }
+
+ private String randomSecret() {
+ byte[] buf = new byte[SECRET_BYTES];
+ random.nextBytes(buf);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
+ }
+
+ /** Constant-time so a caller cannot probe the nonce a character at a time. */
+ private static boolean nonceMatches(String candidate, String expected) {
+ if (expected == null) {
+ return false;
+ }
+ return MessageDigest.isEqual(
+ candidate.getBytes(StandardCharsets.UTF_8),
+ expected.getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java
new file mode 100644
index 0000000000..c0dcea032e
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java
@@ -0,0 +1,60 @@
+package stirling.software.proprietary.accountlink;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/** The one in-flight "connect this server" handshake, instance side. */
+@Entity
+@Table(name = "account_link_connect_state")
+@NoArgsConstructor
+@Getter
+@Setter
+public class ConnectState implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ public static final Long SINGLETON_ID = 1L;
+
+ @Id
+ @Column(name = "id")
+ private Long id = SINGLETON_ID;
+
+ /** Opaque handle the SaaS side gave us; identifies the handshake on both sides. */
+ @Column(name = "request_id", nullable = false, length = 64)
+ private String requestId;
+
+ /** Correlator we minted. */
+ @Column(name = "nonce", nullable = false, length = 128)
+ private String nonce;
+
+ /** Secret we minted and sent to SaaS server to server. */
+ @Column(name = "claim_secret", nullable = false, length = 128)
+ private String claimSecret;
+
+ /** Where we asked the approval page to send the admin back to. */
+ @Column(name = "callback_url", nullable = false, length = 2048)
+ private String callbackUrl;
+
+ /** The approval URL handed to the browser, so a reload can offer it again. */
+ @Column(name = "authorize_url", nullable = false, length = 2048)
+ private String authorizeUrl;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+
+ @Column(name = "expires_at", nullable = false)
+ private LocalDateTime expiresAt;
+
+ public boolean isExpired(LocalDateTime now) {
+ return expiresAt != null && expiresAt.isBefore(now);
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java
new file mode 100644
index 0000000000..995dfccde1
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java
@@ -0,0 +1,6 @@
+package stirling.software.proprietary.accountlink;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+/** Data access for the singleton {@link ConnectState} row. */
+public interface ConnectStateRepository extends JpaRepository {}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java
index 4625572310..7da486b741 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java
@@ -13,8 +13,8 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
- * The device credential this self-hosted instance received when it linked a SaaS account
- * (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team.
+ * The device credential this self-hosted instance received when it linked a SaaS account (combined
+ * billing). Singleton — one instance links to exactly one SaaS team.
*
* Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code
* deviceSecret} so it can present it on every unattended entitlement call. It lives in the local
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java
index 018684b73f..c0cc901e1f 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java
@@ -8,7 +8,7 @@ import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
/**
- * Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance.
+ * Decides whether a request may proceed under combined billing on a self-hosted instance.
*
*
Rules (in order):
*
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java
index 0cd71c9bda..9e73267561 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java
@@ -39,8 +39,8 @@ import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
- * Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
- * AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
+ * Request-time gate + meter for combined billing. {@code preHandle} blocks billable (API / AI /
+ * automation) work when the instance is unlinked or over its limit; manual tools pass through.
* {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
*
*
Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java
index 1ed49d6a8b..278380a5da 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java
@@ -16,11 +16,11 @@ import lombok.NoArgsConstructor;
/**
* The last time the instance metered a given input set this period — the local equivalent of the
- * cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling workflow
- * window: an identical input set re-submitted within the window (see {@link
- * AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
- * same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job
- * window so the same operation costs the same on the instance and in the cloud.
+ * cloud's lineage join (combined billing). The meter dedups on a rolling workflow window: an
+ * identical input set re-submitted within the window (see {@link AccountLinkProperties.Metering})
+ * is treated as workflow chaining and not re-charged, while the same inputs run again after the
+ * window are billed afresh — matching the cloud's 5-minute open-job window so the same operation
+ * costs the same on the instance and in the cloud.
*
*
{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
* artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java
index 863f503f61..a31310a7b3 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java
@@ -5,7 +5,7 @@ import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
-/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
+/** Persistence for the per-period metered input-set signatures (combined billing). */
public interface MeteredInputSignatureRepository
extends JpaRepository {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java
index b90af07958..4c06089040 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java
@@ -17,10 +17,10 @@ import lombok.NoArgsConstructor;
import stirling.software.proprietary.billing.BillingCategory;
/**
- * Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
- * Each successful billable op increments its row; the daily sync reports the cumulative totals and
- * SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
- * nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
+ * Durable per-(billing period, category) cumulative usage counter for combined billing. Each
+ * successful billable op increments its row; the daily sync reports the cumulative totals and SaaS
+ * bills the delta since the last sync. The cumulative model is idempotent (a resend bills nothing)
+ * and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
* category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
*/
@Entity
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java
index 2140775abc..3013d00a52 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java
@@ -9,7 +9,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
-/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
+/** Persistence for the per-period/per-category usage counters (combined billing). */
public interface UsageCounterRepository extends JpaRepository {
/**
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java
index 4c4ce2377c..a12a26eb8a 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java
@@ -18,8 +18,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.BillingCategory;
/**
- * Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
- * usage to SaaS, which bills the delta against its own last-seen totals.
+ * Daily usage sender for combined billing. Reports each period's cumulative per-category usage to
+ * SaaS, which bills the delta against its own last-seen totals.
*
* Resilience: the sync seq is persisted before the report so it never regresses across
* restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java
index 59adc2af80..c2b0e53eb7 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java
@@ -59,7 +59,7 @@ public enum AuditLevel {
*/
public static AuditLevel fromInt(int level) {
// Ensure level is within valid bounds
- int boundedLevel = Math.min(Math.max(level, 0), 3);
+ int boundedLevel = Math.clamp(level, 0, 3);
for (AuditLevel auditLevel : values()) {
if (auditLevel.level == boundedLevel) {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java
index 232dd499dc..182c162f6f 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java
@@ -11,9 +11,9 @@ import java.util.HexFormat;
/**
* SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's
- * meter (combined-billing "Mode A"), so both derive an identical signature for the same
- * bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent
- * of file size), hardware-accelerated by the JVM where available.
+ * meter (combined billing), so both derive an identical signature for the same bytes — the
+ * basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent of file
+ * size), hardware-accelerated by the JVM where available.
*
*
Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core
* build yet is reachable from {@code :saas} (which depends on {@code :proprietary}).
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java
index 8e93871859..f03992ed4d 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java
@@ -17,16 +17,16 @@ import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
-
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.JobStoreEntry;
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.ObjectMapper;
+
/**
* Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId.
*
@@ -44,8 +44,10 @@ public class ValkeyJobStore implements JobStore {
private static final String FILE_INDEX_PREFIX = "stirling:file2job:";
private static final ObjectMapper MAPPER = new ObjectMapper();
- private static final TypeReference> LIST_STRING = new TypeReference<>() {};
- private static final TypeReference