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:
ConnorYoh
2026-06-29 13:35:07 +00:00
committed by GitHub
co-authored by James Brunton
parent 84739e8b0e
commit 14245d33d1
183 changed files with 11473 additions and 2477 deletions
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
@@ -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));
}
}
@@ -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/**");
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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
}
@@ -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);
}
}
@@ -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) {}
@@ -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;
}
}
@@ -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;
}
}
@@ -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 {