feat(saas): account-link — connected self-hosted billing (Mode A) [WIP, flag-gated] (#6738)
> **Draft / WIP.** Combined-billing **Mode A** (connected self-hosted). Entirely behind `stirling.billing.account-link.enabled` (default **off** → beans absent → 404). Pairs with Stirling-PDF-SaaS PR #313 (twin migration → `v3`). ## What this does A self-hosted instance links a SaaS account in the **Portal**, gets a **device credential**, and authenticates unattended metering/entitlement with it — no long-lived user JWT on the server. The Portal then surfaces the team's **billing** (free trial → metered Processor plan) driven by the live wallet. ```mermaid sequenceDiagram participant Portal as Portal (browser) participant Supa as SaaS Supabase Auth participant Local as Self-hosted backend participant SaaS as SaaS Java (app/saas) Portal->>Supa: signIn / signUp (Supabase JS, short-lived JWT) Supa-->>Portal: JWT (SDK-refreshed, stays in browser) Portal->>Local: hand JWT (same-origin) Local->>SaaS: POST /account-link/register (Bearer JWT, leader) SaaS-->>Local: { device_id, device_secret } (secret once) Note over Local: store device_secret server-side loop unattended Local->>SaaS: /api/v1/instance/** (X-Device-Id + X-Device-Secret) SaaS-->>Local: entitlement / gate decision end ``` **Auth model:** human auth = Supabase JS (ephemeral JWT, kept for attended portal features). Durable instance auth = a team-bound **device_id + secret** (SHA-256 stored, shown once), non-user `ROLE_LINKED_INSTANCE`, path-scoped to `/api/v1/instance/**`. Instance binds to a **team**, never a user. ## Billing surface (Portal · Mode A states) `Usage & billing` is state-driven by the link/subscription dimension and built to the marketing designs, sharing one component layer across states: - **Unlinked** → link-account prompt. - **Linked · Free** — the *Processor trial*: a one-time 500-PDF free grant ("Process 500 PDFs free, then $X/PDF"), the team's free-editor fleet, and a leader-only **Switch on the Processor →** (embedded Stripe Checkout). - **Linked · Subscribed** — the *Processor plan* dashboard: PDFs-processed split (API / Agents / Automation), **spend this month** vs. a **spend limit** meter with a run-rate projection and an **in-place cap editor** (preset buckets + suggested value + guardrail), Stripe **invoices** (with billed PDFs per invoice), and the default **payment method**. Card / subscription changes deep-link to Stripe's hosted portal. Manual PDF editing is always free — only Automation / AI / API is metered; a `$0` cap blocks all metered work (≠ "no cap"). **Shared, not duplicated:** the editor-fleet card, the Enterprise upsell, and the meter (`@shared/billing` `MeterBar`) render in both the free and subscribed views; money/cap math lives once in `@shared/billing`. The page header is a sticky, full-bleed bar. **New SaaS reads** (defensive — degrade to empty/"—" when the Stripe mirror lacks a table, never 500): - `GET /api/v1/payg/payment-method` — default card (brand / last4 / expiry) from `stripe.payment_methods`. - Invoice **PDFs processed** — billed line-item quantity from `stripe.invoice_line_items`. ## Progress - [x] Schema: `V22 linked_instance` (+ Supabase twin in #313) - [x] `AccountLinkController` register / list / revoke (leader-only, team from caller) - [x] Device-credential filter (path-scoped, constant-time, revocation-aware) + `SupabaseSecurityConfig` wiring (conditional) - [x] `GET /api/v1/instance/whoami` + **`/entitlement`** (reuses `EntitlementService`/`TeamBillingService`) + tests - [x] Self-hosted backend (`app/proprietary`): orchestrator + instance gate (dark + **fail-open**) + tests - [x] Portal: in-app Supabase login modal + register hand-off + `LinkContext` (unlinked default) + "Linked instances" view — all `@shared` Storybook components - [x] **Portal billing surface** — free (Processor trial) + subscribed (Processor plan) Usage views to marketing spec; link-state derived from the **live wallet**; in-place cap editor; over-cap banner - [x] **SaaS reads** — payment-method endpoint + invoice billed-units (defensive `stripe.*` mirror DAOs) + tests - [x] Orphan guard: block leaving/accepting away from a team whose departure orphans its linked instances - [ ] Metering Step 2 (lease + reconcile loop) + bounded fail-open cutoff - [ ] Proprietary hardening (SaaS base-url config, secret-at-rest, finer billable classification) + HTTP integration test - [ ] Cross-repo Stripe lifecycle certified end-to-end (subscribe → meter → cancel → 402) - [ ] Admin ⟺ SaaS-leader enforcement (separate portal-team-mgmt workstream) ## Verification — all green | Gate | Result | |---|---| | `STIRLING_FLAVOR=saas :saas:test` | BUILD SUCCESSFUL (account-link + payg, incl. `PaygPaymentMethodControllerTest`, `PaygInvoicesControllerTest`) | | `:proprietary:test` | BUILD SUCCESSFUL (account-link + entitlement cache/interceptor) | | portal | tsc 0 · eslint 0 · **vitest 55** · storybook build (all billing stories) | | frontend post-sync | typecheck shared + portal + editor (saas + desktop): 0 | ## Screenshots — billing UI _Latest Storybook renders (Portal/Billing). Drag each capture below its caption — kept out of the repo._ **Linked · Free — Processor trial** <img width="1648" height="503" alt="01-free-processor-trial" src="https://github.com/user-attachments/assets/afe6238a-d3b4-47fd-8ea2-cbaed8b0a653" /> **Linked · Subscribed — Processor plan dashboard** <img width="1648" height="930" alt="02-subscribed-processor-plan" src="https://github.com/user-attachments/assets/329e6808-a9a9-4e65-99af-5a8a5e6bf4ab" /> **Spend limit — in-place cap editor** <img width="1648" height="411" alt="03-spend-limit-editor" src="https://github.com/user-attachments/assets/acc95096-bf8e-4ab0-a32c-3c20dc94f816" /> ## Review feedback applied Reworked the portal after first-pass feedback: linking signs in via the **shared Supabase login** (SSO + email/password) — no bespoke form; the **device secret is never shown in or sent to the FE** (the local backend registers + stores it server-side); billing copy reads **PDFs**, not "units"; the wallet surface uses **`@shared` components** matching the SaaS Plan page. Re-verified including an assertion the link response carries no `deviceSecret`/`deviceId`. **Synced onto unified auth + in-app login (2026-06-23).** Merged `main` incl. **#6725 unified auth** (`frontend/shared/auth`); the link flow uses a shared `useSupabaseLogin` hook + `SupabaseLoginForm`, a portal `LinkAccountModal`, and `useAccountLink.completeLink(session)` (+ on-mount SSO redirect-return). Config: `VITE_SAAS_SUPABASE_URL` + `VITE_SAAS_SUPABASE_ANON_KEY`. The local `/account-link/link` call carries the Spring admin bearer with the SaaS JWT in the body. **SSO** needs the SaaS Supabase project to allow-list the portal redirect URL (email/password works without it). ## Assumptions / open - **Proprietary remains a scaffold** (placeholder SaaS base-url, plaintext device secret at rest, coarse billable classification). - Payment-method + invoice-quantity render only when `stripe.payment_methods` / `stripe.invoice_line_items` are in the Sync-Engine target (confirm in the Supabase/Sync-Engine config); otherwise they degrade gracefully. - A self-contained local HTML report + manual E2E runbook live in `notes/account-link-report/` (dev artifacts, outside the repo). --------- Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
co-authored by
James Brunton
parent
84739e8b0e
commit
14245d33d1
+265
@@ -0,0 +1,265 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
|
||||
* A").
|
||||
*
|
||||
* <p>Two calls:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@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.
|
||||
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
|
||||
* GET /api/v1/instance/entitlement}; what the local gate consults.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see
|
||||
* {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS
|
||||
* endpoint.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class AccountLinkClient {
|
||||
|
||||
static final String HEADER_DEVICE_ID = "X-Device-Id";
|
||||
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
|
||||
|
||||
private final AccountLinkProperties properties;
|
||||
private final ObjectMapper mapper;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
@Autowired
|
||||
public AccountLinkClient(AccountLinkProperties properties, ObjectMapper mapper) {
|
||||
this(
|
||||
properties,
|
||||
mapper,
|
||||
HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(properties.getRequestTimeoutSeconds()))
|
||||
.build());
|
||||
}
|
||||
|
||||
/** Package-private: lets tests inject a stub {@link HttpClient}. */
|
||||
AccountLinkClient(
|
||||
AccountLinkProperties properties, ObjectMapper mapper, HttpClient httpClient) {
|
||||
this.properties = properties;
|
||||
this.mapper = mapper;
|
||||
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.
|
||||
*/
|
||||
public static class UpstreamException extends IOException {
|
||||
private final int status;
|
||||
|
||||
public UpstreamException(int status, String body) {
|
||||
super("SaaS account-link returned HTTP " + status + ": " + body);
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public int status() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative deny (401/403) from the entitlement endpoint — the device credential is revoked
|
||||
* or invalid. Distinct from a transport/server failure (which returns {@code null} and fails
|
||||
* open): the cache must BLOCK billable work on this rather than serve a stale entitled
|
||||
* snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport
|
||||
* try/catch.
|
||||
*/
|
||||
public static final class RevokedException extends RuntimeException {
|
||||
private final int status;
|
||||
|
||||
public RevokedException(int status) {
|
||||
super("SaaS entitlement denied (credential revoked/invalid): HTTP " + status);
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public int status() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
public RegisterResult register(String supabaseJwt, String instanceName) throws IOException {
|
||||
String body =
|
||||
instanceName == null || instanceName.isBlank()
|
||||
? "{}"
|
||||
: "{\"name\":" + mapper.writeValueAsString(instanceName) + "}";
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri("/api/v1/account-link/register"))
|
||||
.header("Authorization", "Bearer " + supabaseJwt)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout())
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = send(request);
|
||||
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");
|
||||
}
|
||||
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 ({@code POST
|
||||
* /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is
|
||||
* allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable
|
||||
* or rejects the call, so the caller (local unlink) can still clear locally and log the orphan
|
||||
* row for follow-up. Idempotent on SaaS (already-revoked → still 204).
|
||||
*/
|
||||
public boolean revokeSelf(String deviceId, String deviceSecret) {
|
||||
try {
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri("/api/v1/instance/revoke-self"))
|
||||
.header(HEADER_DEVICE_ID, deviceId)
|
||||
.header(HEADER_DEVICE_SECRET, deviceSecret)
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout())
|
||||
.POST(HttpRequest.BodyPublishers.noBody())
|
||||
.build();
|
||||
HttpResponse<String> response = send(request);
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
log.debug("Self-revoke returned HTTP {}", response.statusCode());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.debug("Self-revoke failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current entitlement using the stored device credential. Three outcomes:
|
||||
*
|
||||
* <ul>
|
||||
* <li>2xx → the parsed snapshot.
|
||||
* <li>401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential);
|
||||
* the caller must BLOCK, not fail open.
|
||||
* <li>transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null}
|
||||
* ("unknown" — the caller fails open).
|
||||
* </ul>
|
||||
*/
|
||||
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri("/api/v1/instance/entitlement"))
|
||||
.header(HEADER_DEVICE_ID, deviceId)
|
||||
.header(HEADER_DEVICE_SECRET, deviceSecret)
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout())
|
||||
.GET()
|
||||
.build();
|
||||
response = send(request);
|
||||
} catch (Exception e) {
|
||||
// Transport failure (timeout / connection refused / interrupted) → unknown, fail open.
|
||||
log.debug("Entitlement fetch failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
int status = response.statusCode();
|
||||
if (status == 401 || status == 403) {
|
||||
// Authoritative deny — the SaaS side rejected the credential (revoked/invalid).
|
||||
throw new RevokedException(status);
|
||||
}
|
||||
if (status / 100 != 2) {
|
||||
// Server / transient error → unknown, fail open (do NOT treat as a deny).
|
||||
log.debug("Entitlement fetch returned HTTP {}", status);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseEntitlement(response.body());
|
||||
} catch (IOException e) {
|
||||
log.debug("Entitlement parse failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private InstanceEntitlement parseEntitlement(String body) throws IOException {
|
||||
JsonNode root = mapper.readTree(body);
|
||||
boolean subscribed = root.path("subscribed").asBoolean(false);
|
||||
long freeRemaining = root.path("freeRemainingUnits").asLong(0);
|
||||
long periodSpend = root.path("periodSpendUnits").asLong(0);
|
||||
Long periodCap =
|
||||
root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null;
|
||||
EntitlementState state = mapState(root.path("state").asText(null));
|
||||
return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state);
|
||||
}
|
||||
|
||||
/** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */
|
||||
private static EntitlementState mapState(String raw) {
|
||||
if (raw == null) {
|
||||
return EntitlementState.UNKNOWN;
|
||||
}
|
||||
return switch (raw) {
|
||||
case "OK", "ACTIVE", "SUBSCRIBED", "FREE" -> EntitlementState.OK;
|
||||
case "OVER_LIMIT", "PAYG_LIMIT_REACHED", "BLOCKED" -> EntitlementState.OVER_LIMIT;
|
||||
default -> EntitlementState.UNKNOWN;
|
||||
};
|
||||
}
|
||||
|
||||
private HttpResponse<String> send(HttpRequest request) throws IOException {
|
||||
try {
|
||||
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted calling SaaS account-link", e);
|
||||
}
|
||||
}
|
||||
|
||||
private URI uri(String path) {
|
||||
String base = properties.getSaasBaseUrl().strip().replaceAll("/+$", "");
|
||||
return URI.create(base + path);
|
||||
}
|
||||
|
||||
private Duration timeout() {
|
||||
return Duration.ofSeconds(properties.getRequestTimeoutSeconds());
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
return node.hasNonNull(field) ? node.get(field).asText() : null;
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
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.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A").
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
|
||||
* stirling.billing.account-link.enabled} — off → bean absent → 404.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/account-link")
|
||||
@Profile("!saas")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class AccountLinkController {
|
||||
|
||||
private final AccountLinkService service;
|
||||
|
||||
public AccountLinkController(AccountLinkService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
|
||||
public record LinkRequest(String supabaseJwt, String name) {}
|
||||
|
||||
@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"));
|
||||
}
|
||||
try {
|
||||
return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name()));
|
||||
} 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());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(java.util.Map.of("error", "LINK_FAILED"));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
public ResponseEntity<AccountLinkService.LinkStatus> status() {
|
||||
return ResponseEntity.ok(service.status());
|
||||
}
|
||||
|
||||
@PostMapping("/unlink")
|
||||
public ResponseEntity<Void> unlink() {
|
||||
service.unlink();
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Self-hosted side of combined-billing "Mode A" (connected self-hosted).
|
||||
*
|
||||
* <p>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
|
||||
* <b>off by default</b> and <b>dark</b> — when off nothing gates and the link endpoints 404.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "stirling.billing.account-link")
|
||||
public class AccountLinkProperties {
|
||||
|
||||
/** Master switch. When {@code false} (default) the feature is fully inert. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* Base URL of the SaaS backend this instance links to (register + entitlement live there).
|
||||
*
|
||||
* <p>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.
|
||||
*/
|
||||
private String saasBaseUrl = "https://stirling.com/app";
|
||||
|
||||
/** Cached entitlement is reused for this long before a refresh is attempted. */
|
||||
private long entitlementCacheSeconds = 300;
|
||||
|
||||
/** Connect/read timeout for the outbound SaaS calls. */
|
||||
private int requestTimeoutSeconds = 10;
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Linking orchestrator (self-hosted side of combined-billing "Mode A").
|
||||
*
|
||||
* <p>{@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.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class AccountLinkService {
|
||||
|
||||
private final AccountLinkClient client;
|
||||
private final DeviceCredentialStore credentialStore;
|
||||
private final EntitlementCache entitlementCache;
|
||||
|
||||
public AccountLinkService(
|
||||
AccountLinkClient client,
|
||||
DeviceCredentialStore credentialStore,
|
||||
EntitlementCache entitlementCache) {
|
||||
this.client = client;
|
||||
this.credentialStore = credentialStore;
|
||||
this.entitlementCache = entitlementCache;
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
public void unlink() {
|
||||
credentialStore
|
||||
.get()
|
||||
.ifPresent(
|
||||
c -> {
|
||||
boolean ok = client.revokeSelf(c.getDeviceId(), c.getDeviceSecret());
|
||||
if (!ok) {
|
||||
log.warn(
|
||||
"Account-link: SaaS self-revoke failed for device {};"
|
||||
+ " clearing locally anyway (admin can revoke"
|
||||
+ " from the portal).",
|
||||
c.getDeviceId());
|
||||
}
|
||||
});
|
||||
credentialStore.clear();
|
||||
entitlementCache.invalidate();
|
||||
log.info("Account-link: instance unlinked");
|
||||
}
|
||||
|
||||
public LinkStatus status() {
|
||||
Optional<DeviceCredential> cred = credentialStore.get();
|
||||
return cred.map(
|
||||
c ->
|
||||
new LinkStatus(
|
||||
true,
|
||||
c.getDeviceId(),
|
||||
c.getTeamId(),
|
||||
c.getLinkedAt() != null
|
||||
? c.getLinkedAt().toString()
|
||||
: null))
|
||||
.orElseGet(() -> new LinkStatus(false, null, null, null));
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Registers the account-link entitlement gate. Path patterns cover the billable API surface; the
|
||||
* interceptor itself re-checks billability (and short-circuits manual tools), but scoping here
|
||||
* keeps the gate off the bulk of interactive endpoints entirely.
|
||||
*
|
||||
* <p>Whole config is gated behind {@code stirling.billing.account-link.enabled} +
|
||||
* {@code @Profile("!saas")}; absent when off, so no interceptor is registered.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class AccountLinkWebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final InstanceEntitlementInterceptor gateInterceptor;
|
||||
|
||||
public AccountLinkWebMvcConfig(InstanceEntitlementInterceptor gateInterceptor) {
|
||||
this.gateInterceptor = gateInterceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// AI surface is always billable; the broad /api/v1/** catch lets automation-marked manual
|
||||
// calls be gated too, while the interceptor lets genuine manual tools through.
|
||||
registry.addInterceptor(gateInterceptor)
|
||||
.addPathPatterns("/api/v1/**")
|
||||
.excludePathPatterns("/api/v1/account-link/**");
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
|
||||
/**
|
||||
* Classifies a request as <b>billable</b> (AI / automation) or free (a manual tool).
|
||||
*
|
||||
* <p>Mirrors the saas billing categorisation at a coarse level, without depending on the saas
|
||||
* module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation
|
||||
* marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy
|
||||
* sub-steps). Everything else — interactive manual PDF tools — is always free.
|
||||
*/
|
||||
public final class BillableOperationClassifier {
|
||||
|
||||
private static final String AI_PATH_PREFIX = "/api/v1/ai/";
|
||||
|
||||
private BillableOperationClassifier() {}
|
||||
|
||||
public static boolean isBillable(HttpServletRequest request) {
|
||||
if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) {
|
||||
return true;
|
||||
}
|
||||
String uri = request.getRequestURI();
|
||||
if (uri == null) {
|
||||
return false;
|
||||
}
|
||||
// Prefix-match the AI surface (not a loose substring contains), stripping a deployment
|
||||
// context path so /<ctx>/api/v1/ai/** still classifies as billable.
|
||||
String ctx = request.getContextPath();
|
||||
String path =
|
||||
ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
|
||||
? uri.substring(ctx.length())
|
||||
: uri;
|
||||
return path.startsWith(AI_PATH_PREFIX);
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
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 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.
|
||||
*
|
||||
* <p>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
|
||||
* database (the same store that already holds API-key material and the license signature), so it is
|
||||
* as secure-at-rest as the rest of the instance's secrets.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "account_link_device_credential")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class DeviceCredential implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final Long SINGLETON_ID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private Long id = SINGLETON_ID;
|
||||
|
||||
/** Public identifier minted by the SaaS register call; sent as {@code X-Device-Id}. */
|
||||
@Column(name = "device_id", nullable = false, length = 64)
|
||||
private String deviceId;
|
||||
|
||||
/** High-entropy secret returned once by register; sent as {@code X-Device-Secret}. */
|
||||
@Column(name = "device_secret", nullable = false, length = 128)
|
||||
private String deviceSecret;
|
||||
|
||||
/** SaaS team this instance is linked to; informational on the instance side. */
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
@Column(name = "linked_at", nullable = false)
|
||||
private LocalDateTime linkedAt;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface DeviceCredentialRepository extends JpaRepository<DeviceCredential, Long> {
|
||||
|
||||
/** The singleton credential, if this instance has linked. */
|
||||
default Optional<DeviceCredential> findCredential() {
|
||||
return findById(DeviceCredential.SINGLETON_ID);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Secure-at-rest persistence for this instance's device credential. Thin wrapper over the
|
||||
* singleton-row repository so the rest of the feature never touches JPA directly.
|
||||
*
|
||||
* <p>Gated + {@code @Profile("!saas")}: only the self-hosted profile links outward to a SaaS team.
|
||||
*/
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class DeviceCredentialStore {
|
||||
|
||||
private final DeviceCredentialRepository repo;
|
||||
|
||||
public DeviceCredentialStore(DeviceCredentialRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<DeviceCredential> get() {
|
||||
return repo.findCredential();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isLinked() {
|
||||
return repo.findCredential().isPresent();
|
||||
}
|
||||
|
||||
/** Persists (or replaces) the credential returned by a SaaS register call. */
|
||||
@Transactional
|
||||
public void save(String deviceId, String deviceSecret, Long teamId) {
|
||||
DeviceCredential cred = repo.findCredential().orElseGet(DeviceCredential::new);
|
||||
cred.setId(DeviceCredential.SINGLETON_ID);
|
||||
cred.setDeviceId(deviceId);
|
||||
cred.setDeviceSecret(deviceSecret);
|
||||
cred.setTeamId(teamId);
|
||||
cred.setLinkedAt(LocalDateTime.now());
|
||||
repo.save(cred);
|
||||
}
|
||||
|
||||
/** Unlinks this instance locally (idempotent). */
|
||||
@Transactional
|
||||
public void clear() {
|
||||
repo.findCredential().ifPresent(repo::delete);
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on
|
||||
* every billable request. Single-slot (one instance = one linked team), TTL-based.
|
||||
*
|
||||
* <p>Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it
|
||||
* has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has
|
||||
* ever been fetched <i>and</i> the latest refresh failed (the gate treats empty as "unknown →
|
||||
* allow").
|
||||
*
|
||||
* <p>But an AUTHORITATIVE deny (revoked/invalid credential → {@link
|
||||
* AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a
|
||||
* {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately
|
||||
* rather than serving a stale entitled snapshot.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class EntitlementCache {
|
||||
|
||||
private final DeviceCredentialStore credentialStore;
|
||||
private final AccountLinkClient client;
|
||||
private final Duration ttl;
|
||||
|
||||
/** Entitlement + fetch time, swapped atomically as one value so readers never tear. */
|
||||
private record Snapshot(InstanceEntitlement entitlement, Instant fetchedAt) {}
|
||||
|
||||
private static final Snapshot EMPTY = new Snapshot(null, Instant.EPOCH);
|
||||
|
||||
/** Blocked entitlement synthesised on an authoritative deny (revoked/invalid credential). */
|
||||
private static final InstanceEntitlement REVOKED =
|
||||
new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
|
||||
|
||||
private volatile Snapshot snapshot = EMPTY;
|
||||
|
||||
/** Single-flight guard: one thread refreshes while others serve the current snapshot. */
|
||||
private final AtomicBoolean refreshing = new AtomicBoolean(false);
|
||||
|
||||
public EntitlementCache(
|
||||
DeviceCredentialStore credentialStore,
|
||||
AccountLinkClient client,
|
||||
AccountLinkProperties properties) {
|
||||
this.credentialStore = credentialStore;
|
||||
this.client = client;
|
||||
this.ttl = Duration.ofSeconds(properties.getEntitlementCacheSeconds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Current entitlement, refreshing if stale. {@link Optional#empty()} means "unknown" — either
|
||||
* not linked or the SaaS side is unreachable and we have no prior snapshot.
|
||||
*/
|
||||
public Optional<InstanceEntitlement> current() {
|
||||
// Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS
|
||||
// call) while concurrent callers serve the last snapshot — no thundering herd of
|
||||
// synchronous round-trips on the billable hot path. Safe because the gate fails open.
|
||||
if (isStale(snapshot) && refreshing.compareAndSet(false, true)) {
|
||||
try {
|
||||
refresh();
|
||||
} finally {
|
||||
refreshing.set(false);
|
||||
}
|
||||
}
|
||||
return Optional.ofNullable(snapshot.entitlement());
|
||||
}
|
||||
|
||||
private boolean isStale(Snapshot snap) {
|
||||
// fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed
|
||||
// fetch backs off for a full TTL instead of every billable request re-triggering a
|
||||
// blocking round-trip against a dead/slow SaaS endpoint.
|
||||
return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but
|
||||
* still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny
|
||||
* (revoked credential) replaces it with a blocked snapshot so the gate stops billable work.
|
||||
*/
|
||||
void refresh() {
|
||||
Optional<DeviceCredential> cred = credentialStore.get();
|
||||
if (cred.isEmpty()) {
|
||||
// Unlinked: clear any stale snapshot so the gate sees "not linked".
|
||||
snapshot = new Snapshot(null, Instant.now());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
InstanceEntitlement fresh =
|
||||
client.fetchEntitlement(cred.get().getDeviceId(), cred.get().getDeviceSecret());
|
||||
if (fresh != null) {
|
||||
snapshot = new Snapshot(fresh, Instant.now());
|
||||
} else {
|
||||
// Unreachable / server error: keep the last known entitlement (may be null) but
|
||||
// stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime.
|
||||
log.debug(
|
||||
"Entitlement refresh failed; reusing last known snapshot, backing off a TTL");
|
||||
snapshot = new Snapshot(snapshot.entitlement(), Instant.now());
|
||||
}
|
||||
} catch (AccountLinkClient.RevokedException e) {
|
||||
// Authoritative deny — credential revoked/invalid. Do NOT fail open: block immediately
|
||||
// rather than serving the stale entitled snapshot until the next unlink.
|
||||
log.info(
|
||||
"Entitlement denied (HTTP {}); blocking billable work for the revoked credential",
|
||||
e.status());
|
||||
snapshot = new Snapshot(REVOKED, Instant.now());
|
||||
}
|
||||
}
|
||||
|
||||
/** Forces a refresh on the next {@link #current()} (e.g. right after linking). */
|
||||
public void invalidate() {
|
||||
snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
/**
|
||||
* Coarse entitlement state the local gate enforces against. Proprietary-local (no coupling to the
|
||||
* saas billing module): the SaaS entitlement response is parsed into this minimal shape.
|
||||
*/
|
||||
public enum EntitlementState {
|
||||
/** Within free pool or covered by an active subscription — billable work allowed. */
|
||||
OK,
|
||||
/** Free pool exhausted and no subscription / over the period cap — billable work blocked. */
|
||||
OVER_LIMIT,
|
||||
/**
|
||||
* Device credential revoked/invalid on the SaaS side (authoritative 401/403 deny) — billable
|
||||
* work blocked. Synthesised locally by {@code EntitlementCache}, never sent by SaaS.
|
||||
*/
|
||||
REVOKED,
|
||||
/** Unrecognised/malformed reply — the gate falls back to its numeric checks, not this flag. */
|
||||
UNKNOWN
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
/**
|
||||
* Outcome of {@link InstanceEntitlementGate}. {@link #allowed} is what the interceptor enforces;
|
||||
* {@link #reason} carries the machine-readable signal the FE maps to a prompt (e.g. "link to
|
||||
* activate"). Manual-tool and fail-open allows carry an informational reason but never block.
|
||||
*/
|
||||
public record GateDecision(boolean allowed, Reason reason) {
|
||||
|
||||
public enum Reason {
|
||||
/** Feature flag is off — gate is fully inert. */
|
||||
FLAG_OFF,
|
||||
/** Operation is a manual tool — always free, never gated. */
|
||||
MANUAL_FREE,
|
||||
/** Linked + within entitlement — billable work allowed. */
|
||||
ENTITLED,
|
||||
/** Entitlement source unreachable — fail open, allow. */
|
||||
FAIL_OPEN,
|
||||
/** Not linked — block billable work; FE should prompt to link. */
|
||||
NOT_LINKED,
|
||||
/** Linked but over the limit / no subscription — block billable work. */
|
||||
OVER_LIMIT,
|
||||
/** Credential revoked/invalid on the SaaS side — block billable work. */
|
||||
REVOKED
|
||||
}
|
||||
|
||||
public static GateDecision allow(Reason reason) {
|
||||
return new GateDecision(true, reason);
|
||||
}
|
||||
|
||||
public static GateDecision block(Reason reason) {
|
||||
return new GateDecision(false, reason);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
/**
|
||||
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response —
|
||||
* just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no
|
||||
* saas types.
|
||||
*
|
||||
* @param subscribed team has an active subscription
|
||||
* @param freeRemainingUnits remaining free-pool units (>0 means free work is available)
|
||||
* @param periodSpendUnits paid units spent this period
|
||||
* @param periodCapUnits paid cap for the period; {@code null} = uncapped
|
||||
* @param state coarse state classification (see {@link EntitlementState})
|
||||
*/
|
||||
public record InstanceEntitlement(
|
||||
boolean subscribed,
|
||||
long freeRemainingUnits,
|
||||
long periodSpendUnits,
|
||||
Long periodCapUnits,
|
||||
EntitlementState state) {}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
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.
|
||||
*
|
||||
* <p>Rules (in order):
|
||||
*
|
||||
* <ol>
|
||||
* <li>Flag off → always allow (feature inert).
|
||||
* <li>Manual tool → always allow (manual tools are free, never metered).
|
||||
* <li>Billable + not linked → block with {@code NOT_LINKED} ("link to activate").
|
||||
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow.
|
||||
* <li>Billable + linked + entitled → allow.
|
||||
* <li>Billable + linked + credential revoked → block with {@code REVOKED}.
|
||||
* <li>Billable + linked + over limit → block with {@code OVER_LIMIT}.
|
||||
* </ol>
|
||||
*
|
||||
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the
|
||||
* live flag / linked-state / entitlement. This is the unit-tested core.
|
||||
*/
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class InstanceEntitlementGate {
|
||||
|
||||
private final AccountLinkProperties properties;
|
||||
private final DeviceCredentialStore credentialStore;
|
||||
private final EntitlementCache entitlementCache;
|
||||
|
||||
public InstanceEntitlementGate(
|
||||
AccountLinkProperties properties,
|
||||
DeviceCredentialStore credentialStore,
|
||||
EntitlementCache entitlementCache) {
|
||||
this.properties = properties;
|
||||
this.credentialStore = credentialStore;
|
||||
this.entitlementCache = entitlementCache;
|
||||
}
|
||||
|
||||
/** Evaluates the gate for a request, resolving live state from the store + cache. */
|
||||
public GateDecision evaluate(boolean billable) {
|
||||
if (!properties.isEnabled()) {
|
||||
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
|
||||
}
|
||||
if (!billable) {
|
||||
return GateDecision.allow(GateDecision.Reason.MANUAL_FREE);
|
||||
}
|
||||
boolean linked = credentialStore.isLinked();
|
||||
Optional<InstanceEntitlement> entitlement =
|
||||
linked ? entitlementCache.current() : Optional.empty();
|
||||
return decide(true, true, linked, entitlement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown"
|
||||
* (unreachable): when linked, that fails open.
|
||||
*/
|
||||
public static GateDecision decide(
|
||||
boolean flagEnabled,
|
||||
boolean billable,
|
||||
boolean linked,
|
||||
Optional<InstanceEntitlement> entitlement) {
|
||||
if (!flagEnabled) {
|
||||
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
|
||||
}
|
||||
if (!billable) {
|
||||
return GateDecision.allow(GateDecision.Reason.MANUAL_FREE);
|
||||
}
|
||||
if (!linked) {
|
||||
return GateDecision.block(GateDecision.Reason.NOT_LINKED);
|
||||
}
|
||||
if (entitlement.isEmpty()) {
|
||||
// Linked but entitlement source unreachable — never hard-block billable work on our
|
||||
// inability to reach billing.
|
||||
return GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
|
||||
}
|
||||
InstanceEntitlement e = entitlement.get();
|
||||
if (e.state() == EntitlementState.REVOKED) {
|
||||
// Credential revoked/invalid (authoritative deny) — block, distinct from over-limit.
|
||||
return GateDecision.block(GateDecision.Reason.REVOKED);
|
||||
}
|
||||
return entitled(e)
|
||||
? GateDecision.allow(GateDecision.Reason.ENTITLED)
|
||||
: GateDecision.block(GateDecision.Reason.OVER_LIMIT);
|
||||
}
|
||||
|
||||
/** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */
|
||||
private static boolean entitled(InstanceEntitlement e) {
|
||||
if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) {
|
||||
return false;
|
||||
}
|
||||
if (e.subscribed()) {
|
||||
// Subscribed: allowed unless a period cap is set and exceeded.
|
||||
return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits();
|
||||
}
|
||||
// Unsubscribed: only the free pool covers billable work.
|
||||
return e.freeRemainingUnits() > 0;
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and
|
||||
* blocks it when the instance is unlinked or over its limit; manual tools pass straight through.
|
||||
*
|
||||
* <p>Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code
|
||||
* {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to
|
||||
* activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit
|
||||
* responses). Fail-open and flag-off both let the request continue.
|
||||
*
|
||||
* <p>Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link
|
||||
* AccountLinkWebMvcConfig} never registers it, so there is no per-request cost.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class InstanceEntitlementInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final InstanceEntitlementGate gate;
|
||||
|
||||
public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) {
|
||||
this.gate = gate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
GateDecision decision;
|
||||
try {
|
||||
decision = gate.evaluate(BillableOperationClassifier.isBillable(request));
|
||||
} catch (RuntimeException e) {
|
||||
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
|
||||
// turn into a hard block on billable work.
|
||||
log.debug("Account-link gate evaluation failed; allowing request", e);
|
||||
return true;
|
||||
}
|
||||
if (decision.allowed()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
log.debug("Account-link gate blocked {} ({})", request.getRequestURI(), decision.reason());
|
||||
response.setStatus(HttpStatus.PAYMENT_REQUIRED.value());
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"{\"error\":\"ACCOUNT_LINK_REQUIRED\",\"reason\":\""
|
||||
+ decision.reason().name()
|
||||
+ "\"}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+2
@@ -33,6 +33,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.storage.repository",
|
||||
"stirling.software.proprietary.workflow.repository",
|
||||
"stirling.software.proprietary.policy.store",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.policy.source"
|
||||
})
|
||||
@EntityScan({
|
||||
@@ -41,6 +42,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.storage.model",
|
||||
"stirling.software.proprietary.workflow.model",
|
||||
"stirling.software.proprietary.policy.store",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.policy.source"
|
||||
})
|
||||
public class DatabaseConfig {
|
||||
|
||||
Reference in New Issue
Block a user