diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 36c2c598db..0a017d6273 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -356,25 +356,24 @@ jobs: exit 1 fi - - name: Build Tauri app + - name: Build Tauri app (signed) + if: inputs.sign uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Apple signing/notarization env is blanked when sign is false so the - # cache warmer compiles an unsigned bundle and skips notarization. - APPLE_CERTIFICATE: ${{ inputs.sign && secrets.APPLE_CERTIFICATE || '' }} - APPLE_CERTIFICATE_PASSWORD: ${{ inputs.sign && secrets.APPLE_CERTIFICATE_PASSWORD || '' }} - APPLE_SIGNING_IDENTITY: ${{ inputs.sign && env.APPLE_SIGNING_IDENTITY || '' }} - APPLE_ID: ${{ inputs.sign && secrets.APPLE_ID || '' }} - APPLE_PASSWORD: ${{ inputs.sign && secrets.APPLE_ID_PASSWORD || '' }} - APPLE_TEAM_ID: ${{ inputs.sign && secrets.APPLE_TEAM_ID || '' }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} # AppImage signing — three env vars work together: # SIGN=1 tells linuxdeploy-plugin-appimage to forward --sign to appimagetool # APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively # SIGN_KEY appimagetool picks the key matching this fingerprint # Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present. - # Mirror the Windows/macOS gate: only sign when enabled AND secret is present AND ref is main (skips PRs from forks/Dependabot and the cache warmer). - SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }} + # Mirror the Windows/macOS gate: only sign when secret is present AND ref is main (skips PRs from forks/Dependabot). + SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -392,6 +391,26 @@ jobs: # failure (#6127 onwards) does not tank deb/rpm uploads. args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }} + - name: Build Tauri app (unsigned) + if: ${{ !inputs.sign }} + uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SIGN: "0" + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }} + VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }} + VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }} + CI: true + with: + projectPath: ./frontend/editor + tauriScript: npx tauri + # Linux: build deb+rpm only here. AppImage runs in its own + # continue-on-error step below so its persistent linuxdeploy + # failure (#6127 onwards) does not tank deb/rpm uploads. + args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }} + # AppImage is decoupled so its linuxdeploy run gets a fresh process # (rpm scratch state torn down) and its failure can't tank deb/rpm. - name: Build Tauri app (Linux AppImage) diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 2be287bd0f..77c9645476 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -30,6 +30,10 @@ tasks: dev:proprietary: desc: "Start backend dev server in proprietary mode" + # `dotenv:` reads from the root Taskfile's directory (".") because this + # subtaskfile is included with `dir: .`. Local overrides in + # .env.proprietary.local win over the committed .env.proprietary defaults. + dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary'] ignore_error: true vars: PORT: '{{.PORT | default "8080"}}' diff --git a/app/.env.proprietary b/app/.env.proprietary new file mode 100644 index 0000000000..4e30318e57 --- /dev/null +++ b/app/.env.proprietary @@ -0,0 +1,8 @@ +# Committed defaults for `task backend:dev:proprietary` (self-hosted / proprietary +# flavor). Local overrides + secrets live in app/.env.proprietary.local (ignored). + +# Combined-billing account link (Mode A). Feature-flagged: OFF until release. +# Flip to true in app/.env.proprietary.local to test linking locally. +STIRLING_BILLING_ACCOUNT_LINK_ENABLED=false +# SaaS base URL the linked instance calls (register + entitlement). +STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL=https://stirling.com/app diff --git a/app/.gitignore b/app/.gitignore index e2a86ce4cf..f7e1a1575a 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,3 +1,4 @@ # Whitelist committed env defaults. `.env.saas.local` (and any other .env*) # stays ignored via the root .gitignore. !.env.saas +!.env.proprietary diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 7da9d76c6c..9ed2610f51 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -595,7 +595,7 @@ public class ApplicationProperties { public static class SAML2 { private String provider; private Boolean enabled = false; - private Boolean autoCreateUser = false; + private Boolean autoCreateUser = true; private Boolean blockRegistration = false; private String registrationId = "stirling"; @@ -672,7 +672,7 @@ public class ApplicationProperties { private String issuer; private String clientId; @ToString.Exclude private String clientSecret; - private Boolean autoCreateUser = false; + private Boolean autoCreateUser = true; private Boolean blockRegistration = false; private String useAsUsername; private Collection scopes = new ArrayList<>(); @@ -743,7 +743,6 @@ public class ApplicationProperties { @Data public static class Jwt { private boolean enableKeystore = true; - private boolean enableKeyRotation = false; private boolean enableKeyCleanup = true; /** @@ -847,8 +846,8 @@ public class ApplicationProperties { @Data public static class Trust { private boolean serverAsAnchor = true; - private boolean useSystemTrust = false; - private boolean useMozillaBundle = false; + private boolean useSystemTrust = true; + private boolean useMozillaBundle = true; private boolean useAATL = false; private boolean useEUTL = false; } @@ -891,10 +890,10 @@ public class ApplicationProperties { private Boolean enableAnalytics; private Boolean enablePosthog; private Boolean enableScarf; - private Boolean enableDesktopInstallSlide; + private Boolean enableDesktopInstallSlide = true; private Datasource datasource; private boolean disableSanitize; - private int maxDPI; + private int maxDPI = 500; private boolean enableUrlToPDF; private Html html = new Html(); private CustomPaths customPaths = new CustomPaths(); @@ -908,8 +907,9 @@ public class ApplicationProperties { private String frontendUrl; // Frontend URL for invite email links (e.g. // 'https://app.example.com'). If not set, falls back to backendUrl. - private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature + private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings(); + private ServerCertificate serverCertificate = new ServerCertificate(); @Data public static class MobileScannerSettings { @@ -919,6 +919,16 @@ public class ApplicationProperties { private boolean stretchToFit = false; // Whether to stretch image to fill page } + @Data + public static class ServerCertificate { + private boolean enabled = + true; // Enable server-side "Sign with Stirling-PDF" certificate + private String organizationName = "Stirling PDF Inc"; + private int validity = 365; // Certificate validity in days + private boolean regenerateOnStartup = + false; // Generate a new certificate on each startup + } + public boolean isAnalyticsEnabled() { return this.enableAnalytics != null && this.enableAnalytics; } @@ -1003,7 +1013,7 @@ public class ApplicationProperties { @Data public static class Sharing { private boolean enabled = false; - private boolean linkEnabled = false; + private boolean linkEnabled = true; private boolean emailEnabled = false; private int linkExpirationDays = 3; } @@ -1177,7 +1187,7 @@ public class ApplicationProperties { @Data public static class Metrics { - private boolean enabled; + private boolean enabled = true; } @Data @@ -1229,7 +1239,7 @@ public class ApplicationProperties { private boolean enableInvites = false; private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days) private String host; - private int port; + private int port = 587; private String username; @ToString.Exclude private String password; private String from; @@ -1256,10 +1266,10 @@ public class ApplicationProperties { @ToString.Exclude private String botToken; private String botUsername; private String pipelineInboxFolder = "telegram"; - private Boolean customFolderSuffix = false; - private Boolean enableAllowUserIDs = false; + private Boolean customFolderSuffix = true; + private Boolean enableAllowUserIDs = true; private List allowUserIDs = new ArrayList<>(); - private Boolean enableAllowChannelIDs = false; + private Boolean enableAllowChannelIDs = true; private List allowChannelIDs = new ArrayList<>(); private long processingTimeoutSeconds = 180; private long pollingIntervalMillis = 2000; diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index dac9816018..2726f8d764 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -85,7 +85,6 @@ public class WebMvcConfig implements WebMvcConfigurer { "/icons/**", "/modern-logo/**", "/classic-logo/**", - "/robots.txt", "/3rdPartyLicenses.json", "/pdfjs/**", "/pdfjs-legacy/**", diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/RobotsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/RobotsController.java new file mode 100644 index 0000000000..cf4c9ba610 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/RobotsController.java @@ -0,0 +1,30 @@ +package stirling.software.SPDF.controller.web; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import stirling.software.common.model.ApplicationProperties; + +/** + * Serves /robots.txt dynamically so the system.googlevisibility flag actually controls + * search-engine indexing. 'true' returns an allow-all policy; 'false' returns a disallow-all policy + * to keep the instance out of search engines (useful for embedded/internal deployments). + */ +@RestController +public class RobotsController { + + private final ApplicationProperties applicationProperties; + + public RobotsController(ApplicationProperties applicationProperties) { + this.applicationProperties = applicationProperties; + } + + @GetMapping(value = "/robots.txt", produces = MediaType.TEXT_PLAIN_VALUE) + @ResponseBody + public String robotsTxt() { + boolean allowIndexing = applicationProperties.getSystem().isGooglevisibility(); + return "User-agent: *\n" + (allowIndexing ? "Allow: /\n" : "Disallow: /\n"); + } +} diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index fe2b8635aa..85d97158b3 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -62,8 +62,6 @@ security: # IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId} # Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration. jwt: # This feature is currently under development and not yet fully supported. Do not use in production. - persistence: true # Set to 'true' to enable JWT key store - enableKeyRotation: true # Set to 'true' to enable key pair rotation enableKeyCleanup: true # Set to 'true' to enable key pair cleanup tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day). desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days). @@ -141,10 +139,10 @@ telegram: botUsername: "" # Telegram bot username (without @) pipelineInboxFolder: telegram # Name of the pipeline inbox folder for Telegram uploads customFolderSuffix: true # set to 'true' to allow users to specify custom target folders via UserID - enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs - allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users. - enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs - allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels. + enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs. NOTE: only takes effect when allowUserIDs is non-empty; with an empty list every user is still allowed even when this is 'true' + allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users (the enableAllowUserIDs toggle has no effect until this list is populated). + enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs. NOTE: only takes effect when allowChannelIDs is non-empty; with an empty list every channel is still allowed even when this is 'true' + allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels (the enableAllowChannelIDs toggle has no effect until this list is populated). processingTimeoutSeconds: 180 # Maximum time in seconds to wait for processing a Telegram request pollingIntervalMillis: 2000 # Interval in milliseconds between polling for new messages feedback: @@ -172,7 +170,7 @@ legal: system: defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US - googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow + googlevisibility: false # 'true' serves an allow-all /robots.txt; 'false' serves a disallow-all /robots.txt to keep the instance out of search engines enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes) showUpdate: true # see when a new update is available showUpdateOnlyAdmin: true # only admins can see when a new update is available, depending on showUpdate it must be set to 'true' @@ -186,7 +184,7 @@ system: enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML) maxDPI: 500 # Maximum allowed DPI for PDF to image conversion - corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173' + corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down. backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development. frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails. enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured. @@ -197,7 +195,7 @@ system: stretchToFit: false # Whether to stretch images to fill the entire page (may distort aspect ratio). If false, images are centered with preserved aspect ratio. Only applies when convertToPdf is true. serverCertificate: enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option - organizationName: Stirling-PDF # Organization name for generated certificates + organizationName: Stirling PDF Inc # Organization name for generated certificates validity: 365 # Certificate validity in days regenerateOnStartup: false # Generate new certificate on each startup html: @@ -304,7 +302,7 @@ autoPipeline: allowedExtensions: [] # Optional extension allow-list (case-insensitive, without the leading dot). Empty list = accept all extensions. Example: ["pdf", "tiff"] ui: - appNameNavbar: "" # name displayed on the navigation bar + appNameNavbar: "" # custom app/brand name. NOTE: no longer shown in the navbar (the navbar renders the logo). It IS used as the browser tab title and as the TOTP/2FA issuer label in authenticator apps. Empty falls back to "Stirling PDF" logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo) languages: [] # If empty, all languages are enabled. To restrict to specific languages, use a whitelist like ["de_DE", "pl_PL", "sv_SE"]. Empty list or not restricting any languages will enable all available languages. defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out diff --git a/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceMoreTest.java index cbf0d30113..f133dafdcb 100644 --- a/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceMoreTest.java @@ -89,9 +89,13 @@ class CertificateValidationServiceMoreTest { } private static ApplicationProperties defaultProps() { - // Real POJO defaults: trust all off, revocation "none". + // Test baseline: every trust source explicitly off so each test enables only what it + // exercises (the shipped POJO defaults now enable system + Mozilla trust). ApplicationProperties props = new ApplicationProperties(); - props.getSecurity().getValidation().getTrust().setServerAsAnchor(false); + var trust = props.getSecurity().getValidation().getTrust(); + trust.setServerAsAnchor(false); + trust.setUseSystemTrust(false); + trust.setUseMozillaBundle(false); return props; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java new file mode 100644 index 0000000000..a77f67f8a8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java @@ -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"). + * + *

Two calls: + * + *

    + *
  • {@link #register} — relays the admin's short-lived Supabase JWT to {@code POST + * /api/v1/account-link/register}; the SaaS side mints + returns a device credential. + *
  • {@link #fetchEntitlement} — authenticates with the stored device credential against {@code + * GET /api/v1/instance/entitlement}; what the local gate consults. + *
+ * + *

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 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 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: + * + *

    + *
  • 2xx → the parsed snapshot. + *
  • 401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential); + * the caller must BLOCK, not fail open. + *
  • transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null} + * ("unknown" — the caller fails open). + *
+ */ + public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) { + HttpResponse 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 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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java new file mode 100644 index 0000000000..1d1a8aa77d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java @@ -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"). + * + *

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. + * + *

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 status() { + return ResponseEntity.ok(service.status()); + } + + @PostMapping("/unlink") + public ResponseEntity unlink() { + service.unlink(); + return ResponseEntity.noContent().build(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java new file mode 100644 index 0000000000..c12e56f06d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java @@ -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). + * + *

Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag + * the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional + * code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is + * off by default and dark — when off nothing gates and the link endpoints 404. + */ +@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). + * + *

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; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java new file mode 100644 index 0000000000..1bb27d9cd6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java @@ -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"). + * + *

{@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 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)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java new file mode 100644 index 0000000000..8c20abfa11 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java @@ -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. + * + *

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/**"); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java new file mode 100644 index 0000000000..136e4c3214 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java @@ -0,0 +1,38 @@ +package stirling.software.proprietary.accountlink; + +import jakarta.servlet.http.HttpServletRequest; + +import stirling.software.common.service.InternalApiClient; + +/** + * Classifies a request as billable (AI / automation) or free (a manual tool). + * + *

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 //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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java new file mode 100644 index 0000000000..4625572310 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java @@ -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. + * + *

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; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java new file mode 100644 index 0000000000..792731e589 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java @@ -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 { + + /** The singleton credential, if this instance has linked. */ + default Optional findCredential() { + return findById(DeviceCredential.SINGLETON_ID); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java new file mode 100644 index 0000000000..33658a48c6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java @@ -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. + * + *

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 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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java new file mode 100644 index 0000000000..63818c1f98 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java @@ -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. + * + *

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 and the latest refresh failed (the gate treats empty as "unknown → + * allow"). + * + *

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 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 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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementState.java new file mode 100644 index 0000000000..1cca34cb69 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementState.java @@ -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 +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java new file mode 100644 index 0000000000..677183278b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java @@ -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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java new file mode 100644 index 0000000000..6445d886e9 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java @@ -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) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java new file mode 100644 index 0000000000..c975bad055 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java @@ -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. + * + *

Rules (in order): + * + *

    + *
  1. Flag off → always allow (feature inert). + *
  2. Manual tool → always allow (manual tools are free, never metered). + *
  3. Billable + not linked → block with {@code NOT_LINKED} ("link to activate"). + *
  4. Billable + linked + entitlement unknown (unreachable) → fail open, allow. + *
  5. Billable + linked + entitled → allow. + *
  6. Billable + linked + credential revoked → block with {@code REVOKED}. + *
  7. Billable + linked + over limit → block with {@code OVER_LIMIT}. + *
+ * + *

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 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 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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java new file mode 100644 index 0000000000..8597813a85 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java @@ -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. + * + *

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. + * + *

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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index 3c713206de..ee449b31f2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -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 { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java index 44d750e8e4..09b767a6d1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java @@ -22,12 +22,12 @@ import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.ServerCertificateServiceInterface; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; @@ -40,22 +40,22 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa private static final String KEYSTORE_ALIAS = "stirling-pdf-server"; private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert"; - @Value("${system.serverCertificate.enabled:false}") private boolean enabled; - - @Value("${system.serverCertificate.organizationName:Stirling-PDF}") private String organizationName; - - @Value("${system.serverCertificate.validity:365}") private int validityDays; - - @Value("${system.serverCertificate.regenerateOnStartup:false}") private boolean regenerateOnStartup; private final LicenseKeyChecker licenseKeyChecker; - public ServerCertificateService(LicenseKeyChecker licenseKeyChecker) { + public ServerCertificateService( + LicenseKeyChecker licenseKeyChecker, ApplicationProperties applicationProperties) { this.licenseKeyChecker = licenseKeyChecker; + ApplicationProperties.System.ServerCertificate config = + applicationProperties.getSystem().getServerCertificate(); + this.enabled = config.isEnabled(); + this.organizationName = config.getOrganizationName(); + this.validityDays = config.getValidity(); + this.regenerateOnStartup = config.isRegenerateOnStartup(); } static { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UserServerCertificateService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UserServerCertificateService.java index 5f73deb322..a21b0a105c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UserServerCertificateService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UserServerCertificateService.java @@ -81,7 +81,7 @@ public class UserServerCertificateService { // Certificate details with username String username = user.getUsername(); - X500Name subject = new X500Name("CN=" + username + ", O=Stirling-PDF User, C=US"); + X500Name subject = new X500Name("CN=" + username + ", OU=User, O=Stirling PDF Inc, C=US"); BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis()); Date notBefore = new Date(); Date notAfter = diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java new file mode 100644 index 0000000000..7d954a4398 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java @@ -0,0 +1,192 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.ConnectException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import tools.jackson.databind.ObjectMapper; + +/** + * Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register + * relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on + * unreachable) behaviour hold. + */ +class AccountLinkClientTest { + + private AccountLinkProperties properties; + private HttpClient httpClient; + private AccountLinkClient client; + + @BeforeEach + void setUp() { + properties = new AccountLinkProperties(); + properties.setEnabled(true); + properties.setSaasBaseUrl("https://saas.example.com"); + httpClient = mock(HttpClient.class); + client = new AccountLinkClient(properties, new ObjectMapper(), httpClient); + } + + @SuppressWarnings("unchecked") + private HttpResponse response(int status, String body) { + HttpResponse resp = mock(HttpResponse.class); + when(resp.statusCode()).thenReturn(status); + when(resp.body()).thenReturn(body); + return resp; + } + + @Test + @SuppressWarnings("unchecked") + void registerRelaysJwtAndParsesCredential() throws Exception { + // Build the stub response first: nesting response() inside when() trips Mockito's + // unfinished-stubbing check (inner when() runs mid outer when()). + HttpResponse resp = + response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}"); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) + .thenReturn(resp); + + AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server"); + + assertEquals("dev-1", result.deviceId()); + assertEquals("sec-1", result.deviceSecret()); + assertEquals(42L, result.teamId()); + + HttpRequest sent = captor.getValue(); + assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null)); + assertEquals( + "https://saas.example.com/api/v1/account-link/register", sent.uri().toString()); + } + + @Test + @SuppressWarnings("unchecked") + void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception { + HttpResponse resp = response(401, "{\"error\":\"unauthorized\"}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + AccountLinkClient.UpstreamException ex = + assertThrows( + AccountLinkClient.UpstreamException.class, + () -> client.register("jwt", null)); + assertEquals(401, ex.status()); + } + + @Test + @SuppressWarnings("unchecked") + void fetchEntitlementParsesSnapshotAndSendsDeviceHeaders() throws Exception { + HttpResponse resp = + response( + 200, + "{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":10,\"periodCapUnits\":100,\"state\":\"OK\"}"); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) + .thenReturn(resp); + + InstanceEntitlement e = client.fetchEntitlement("dev-1", "sec-1"); + + assertNotNull(e); + assertEquals(true, e.subscribed()); + assertEquals(10, e.periodSpendUnits()); + assertEquals(100L, e.periodCapUnits()); + assertEquals(EntitlementState.OK, e.state()); + + HttpRequest sent = captor.getValue(); + assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null)); + assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null)); + } + + @Test + @SuppressWarnings("unchecked") + void fetchEntitlementMapsOverLimitState() throws Exception { + // Pins the consume side of the wire contract: InstanceController emits "OVER_LIMIT" (for a + // DEGRADED team) and the client must map it to the gate-blocking state. + HttpResponse resp = + response( + 200, + "{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":1300,\"periodCapUnits\":1250,\"state\":\"OVER_LIMIT\"}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + + InstanceEntitlement e = client.fetchEntitlement("dev-1", "sec-1"); + + assertNotNull(e); + assertEquals(EntitlementState.OVER_LIMIT, e.state()); + } + + @Test + @SuppressWarnings("unchecked") + void fetchEntitlementReturnsNullWhenUnreachable() throws Exception { + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))) + .thenThrow(new ConnectException("refused")); + // Null = unknown → the cache/gate fail open. + assertNull(client.fetchEntitlement("dev-1", "sec-1")); + } + + @Test + @SuppressWarnings("unchecked") + void fetchEntitlementReturnsNullOnServerError() throws Exception { + // 5xx is a transient/server failure, not a credential deny → null, the cache fails open. + HttpResponse resp = response(503, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + assertNull(client.fetchEntitlement("dev-1", "sec-1")); + } + + @Test + @SuppressWarnings("unchecked") + void fetchEntitlementThrowsRevokedOnDeny() throws Exception { + // 401/403 = authoritative deny (revoked/invalid credential) → RevokedException, NOT null: + // the cache must block billable work rather than fail open on a stale snapshot. + for (int status : new int[] {401, 403}) { + HttpResponse resp = response(status, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + AccountLinkClient.RevokedException ex = + assertThrows( + AccountLinkClient.RevokedException.class, + () -> client.fetchEntitlement("dev-1", "sec-1")); + assertEquals(status, ex.status()); + } + } + + @Test + @SuppressWarnings("unchecked") + void revokeSelfSendsDeviceHeadersAndReturnsTrueOn2xx() throws Exception { + HttpResponse resp = response(204, ""); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) + .thenReturn(resp); + + assertEquals(true, client.revokeSelf("dev-1", "sec-1")); + + HttpRequest sent = captor.getValue(); + assertEquals("https://saas.example.com/api/v1/instance/revoke-self", sent.uri().toString()); + assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null)); + assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null)); + assertEquals("POST", sent.method()); + } + + @Test + @SuppressWarnings("unchecked") + void revokeSelfReturnsFalseOnErrorStatus() throws Exception { + HttpResponse resp = response(403, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + assertEquals(false, client.revokeSelf("dev-1", "sec-1")); + } + + @Test + @SuppressWarnings("unchecked") + void revokeSelfReturnsFalseWhenUnreachable() throws Exception { + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))) + .thenThrow(new ConnectException("refused")); + assertEquals(false, client.revokeSelf("dev-1", "sec-1")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java new file mode 100644 index 0000000000..6e4a816bb0 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java @@ -0,0 +1,68 @@ +package stirling.software.proprietary.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest; + +/** + * The local (self-hosted) account-link controller's error mapping: an upstream auth rejection + * surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport + * faults are a 502. + */ +class AccountLinkControllerTest { + + private AccountLinkService service; + private AccountLinkController controller; + + @BeforeEach + void setUp() { + service = mock(AccountLinkService.class); + controller = new AccountLinkController(service); + } + + @Test + void link_missingJwt_returns400() { + ResponseEntity resp = controller.link(new LinkRequest(" ", null)); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void link_upstreamUnauthorized_maps401() throws Exception { + when(service.link("jwt", null)) + .thenThrow(new AccountLinkClient.UpstreamException(401, "bad token")); + ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void link_upstreamForbidden_maps403() throws Exception { + when(service.link("jwt", null)) + .thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden")); + ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void link_upstreamServerError_maps502() throws Exception { + when(service.link("jwt", null)) + .thenThrow(new AccountLinkClient.UpstreamException(500, "boom")); + ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + } + + @Test + void link_transportFailure_maps502() throws Exception { + when(service.link("jwt", null)).thenThrow(new IOException("connection refused")); + ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java new file mode 100644 index 0000000000..b909fb37a0 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java @@ -0,0 +1,111 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class AccountLinkServiceTest { + + private AccountLinkClient client; + private DeviceCredentialStore store; + private EntitlementCache cache; + private AccountLinkService service; + + @BeforeEach + void setUp() { + client = mock(AccountLinkClient.class); + store = mock(DeviceCredentialStore.class); + cache = mock(EntitlementCache.class); + service = new AccountLinkService(client, store, cache); + } + + @Test + void link_storesCredentialAndInvalidatesCache() throws IOException { + when(client.register("jwt", "name")) + .thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L)); + DeviceCredential stored = new DeviceCredential(); + stored.setDeviceId("dev-1"); + stored.setTeamId(7L); + stored.setLinkedAt(LocalDateTime.now()); + when(store.get()).thenReturn(Optional.of(stored)); + + AccountLinkService.LinkStatus status = service.link("jwt", "name"); + + verify(store).save("dev-1", "sec-1", 7L); + verify(cache).invalidate(); + assertTrue(status.linked()); + assertEquals("dev-1", status.deviceId()); + assertEquals(7L, status.teamId()); + } + + @Test + void link_propagatesRegisterFailure() throws IOException { + when(client.register(any(), any())).thenThrow(new IOException("boom")); + org.junit.jupiter.api.Assertions.assertThrows( + IOException.class, () -> service.link("jwt", null)); + verify(cache, org.mockito.Mockito.never()).invalidate(); + } + + @Test + void status_unlinkedWhenNoCredential() { + when(store.get()).thenReturn(Optional.empty()); + AccountLinkService.LinkStatus status = service.status(); + assertFalse(status.linked()); + } + + @Test + void unlink_callsSaasRevokeBeforeClearingLocally() { + DeviceCredential cred = new DeviceCredential(); + cred.setDeviceId("dev-1"); + cred.setDeviceSecret("sec-1"); + cred.setTeamId(7L); + cred.setLinkedAt(LocalDateTime.now()); + when(store.get()).thenReturn(Optional.of(cred)); + when(client.revokeSelf("dev-1", "sec-1")).thenReturn(true); + + service.unlink(); + + verify(client).revokeSelf("dev-1", "sec-1"); + verify(store).clear(); + verify(cache).invalidate(); + } + + @Test + void unlink_clearsLocallyEvenWhenSaasRevokeFails() { + DeviceCredential cred = new DeviceCredential(); + cred.setDeviceId("dev-1"); + cred.setDeviceSecret("sec-1"); + cred.setLinkedAt(LocalDateTime.now()); + when(store.get()).thenReturn(Optional.of(cred)); + // SaaS unreachable / returns non-2xx. + when(client.revokeSelf("dev-1", "sec-1")).thenReturn(false); + + service.unlink(); + + // Local clear MUST still happen — admin's intent wins; orphan row is a follow-up. + verify(store).clear(); + verify(cache).invalidate(); + } + + @Test + void unlink_whenAlreadyUnlinked_skipsSaasRevoke() { + when(store.get()).thenReturn(Optional.empty()); + + service.unlink(); + + org.mockito.Mockito.verifyNoInteractions(client); + verify(store).clear(); + verify(cache).invalidate(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java new file mode 100644 index 0000000000..275026794a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java @@ -0,0 +1,49 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import stirling.software.common.service.InternalApiClient; + +class BillableOperationClassifierTest { + + @Test + void aiPathIsBillable() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo"); + assertTrue(BillableOperationClassifier.isBillable(req)); + } + + @Test + void automationHeaderIsBillable() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); + req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1"); + assertTrue(BillableOperationClassifier.isBillable(req)); + } + + @Test + void plainManualToolIsFree() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); + assertFalse(BillableOperationClassifier.isBillable(req)); + } + + @Test + void aiSegmentNotAtPathStartIsFree() { + // Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a + // proxy prefix) must NOT classify a manual tool as billable. + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo"); + assertFalse(BillableOperationClassifier.isBillable(req)); + } + + @Test + void aiPathUnderContextPathIsBillable() { + // A real context-path deployment still classifies: //api/v1/ai/** is billable. + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo"); + req.setContextPath("/stirling"); + assertTrue(BillableOperationClassifier.isBillable(req)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/EntitlementCacheTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/EntitlementCacheTest.java new file mode 100644 index 0000000000..3dbeb1ef5c --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/EntitlementCacheTest.java @@ -0,0 +1,114 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class EntitlementCacheTest { + + private DeviceCredentialStore store; + private AccountLinkClient client; + private AccountLinkProperties properties; + private EntitlementCache cache; + + @BeforeEach + void setUp() { + store = mock(DeviceCredentialStore.class); + client = mock(AccountLinkClient.class); + properties = new AccountLinkProperties(); + properties.setEntitlementCacheSeconds(300); + cache = new EntitlementCache(store, client, properties); + } + + private DeviceCredential cred() { + DeviceCredential c = new DeviceCredential(); + c.setDeviceId("dev-1"); + c.setDeviceSecret("sec-1"); + c.setTeamId(1L); + c.setLinkedAt(LocalDateTime.now()); + return c; + } + + @Test + void unlinked_returnsEmpty() { + when(store.get()).thenReturn(Optional.empty()); + assertTrue(cache.current().isEmpty()); + } + + @Test + void linked_fetchesAndCachesWithinTtl() { + InstanceEntitlement snap = new InstanceEntitlement(false, 10, 0, null, EntitlementState.OK); + when(store.get()).thenReturn(Optional.of(cred())); + when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap); + + assertEquals(snap, cache.current().orElseThrow()); + // Second read within TTL must not re-fetch. + assertEquals(snap, cache.current().orElseThrow()); + verify(client, times(1)).fetchEntitlement(any(), any()); + } + + @Test + void linked_unreachable_keepsLastKnownSnapshot_failOpenFriendly() { + InstanceEntitlement snap = new InstanceEntitlement(true, 0, 1, 100L, EntitlementState.OK); + when(store.get()).thenReturn(Optional.of(cred())); + when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap); + assertEquals(snap, cache.current().orElseThrow()); + + // TTL elapsed → refresh attempted, but the SaaS side is now unreachable (null). + cache.invalidate(); + when(client.fetchEntitlement(anyString(), anyString())).thenReturn(null); + assertEquals(snap, cache.current().orElseThrow(), "stale snapshot retained on failure"); + } + + @Test + void linked_neverFetched_unreachable_backsOffWithinTtl() { + // No prior snapshot + SaaS unreachable: the gate fails open (empty), but a failed + // attempt stamps the TTL so a second read within the window does NOT re-fetch — + // no sustained hammer of blocking round-trips against a dead endpoint. + when(store.get()).thenReturn(Optional.of(cred())); + when(client.fetchEntitlement(anyString(), anyString())).thenReturn(null); + + assertTrue(cache.current().isEmpty()); + assertTrue(cache.current().isEmpty()); + verify(client, times(1)).fetchEntitlement(any(), any()); + } + + @Test + void linked_revoked_blocksAndDropsStaleEntitlement() { + InstanceEntitlement entitled = + new InstanceEntitlement(true, 0, 1, 100L, EntitlementState.OK); + when(store.get()).thenReturn(Optional.of(cred())); + when(client.fetchEntitlement(anyString(), anyString())).thenReturn(entitled); + assertEquals(entitled, cache.current().orElseThrow()); + + // Credential revoked: the next refresh is an authoritative deny. The cache must NOT keep + // serving the stale entitled snapshot — it replaces it with a blocked REVOKED one. + cache.invalidate(); + when(client.fetchEntitlement(anyString(), anyString())) + .thenThrow(new AccountLinkClient.RevokedException(401)); + assertEquals(EntitlementState.REVOKED, cache.current().orElseThrow().state()); + } + + @Test + void invalidate_forcesRefetch() { + InstanceEntitlement snap = new InstanceEntitlement(false, 10, 0, null, EntitlementState.OK); + when(store.get()).thenReturn(Optional.of(cred())); + when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap); + + cache.current(); + cache.invalidate(); + cache.current(); + verify(client, times(2)).fetchEntitlement(any(), any()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java new file mode 100644 index 0000000000..0c250fd3e9 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java @@ -0,0 +1,117 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.accountlink.GateDecision.Reason; + +/** + * Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and + * over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is + * needed. + */ +class InstanceEntitlementGateTest { + + private static InstanceEntitlement free() { + return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK); + } + + private static InstanceEntitlement exhaustedUnsubscribed() { + return new InstanceEntitlement(false, 0, 0, null, EntitlementState.OVER_LIMIT); + } + + private static InstanceEntitlement subscribedWithinCap() { + return new InstanceEntitlement(true, 0, 10, 100L, EntitlementState.OK); + } + + private static InstanceEntitlement subscribedOverCap() { + return new InstanceEntitlement(true, 0, 100, 100L, EntitlementState.OK); + } + + @Test + void flagOff_allowsEverything_evenBillableUnlinked() { + GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty()); + assertTrue(d.allowed()); + assertEquals(Reason.FLAG_OFF, d.reason()); + } + + @Test + void manualTool_alwaysFree_evenUnlinked() { + GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty()); + assertTrue(d.allowed()); + assertEquals(Reason.MANUAL_FREE, d.reason()); + } + + @Test + void billable_notLinked_blocksWithLinkSignal() { + GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty()); + assertFalse(d.allowed()); + assertEquals(Reason.NOT_LINKED, d.reason()); + } + + @Test + void billable_linked_entitlementUnreachable_failsOpen() { + GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty()); + assertTrue(d.allowed()); + assertEquals(Reason.FAIL_OPEN, d.reason()); + } + + @Test + void billable_linked_freePoolAvailable_allows() { + GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free())); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_unsubscribedAndExhausted_blocksOverLimit() { + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(exhaustedUnsubscribed())); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void billable_linked_subscribedWithinCap_allows() { + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithinCap())); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_subscribedOverCap_blocks() { + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap())); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void billable_linked_revoked_blocksWithRevokedSignal() { + // Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED — + // blocks distinctly from over-limit, even though the snapshot is "present". + InstanceEntitlement revoked = + new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED); + GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked)); + assertFalse(d.allowed()); + assertEquals(Reason.REVOKED, d.reason()); + } + + @Test + void billable_linked_unsubscribedWithFreePool_overLimitStateStillBlocks() { + // Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive. + InstanceEntitlement conflicting = + new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT); + GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting)); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java new file mode 100644 index 0000000000..f764f5e836 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java @@ -0,0 +1,71 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Verifies {@link InstanceEntitlementGate#evaluate} resolves live state from store + cache. */ +class InstanceEntitlementGateWiringTest { + + private AccountLinkProperties properties; + private DeviceCredentialStore store; + private EntitlementCache cache; + private InstanceEntitlementGate gate; + + @BeforeEach + void setUp() { + properties = new AccountLinkProperties(); + properties.setEnabled(true); + store = mock(DeviceCredentialStore.class); + cache = mock(EntitlementCache.class); + gate = new InstanceEntitlementGate(properties, store, cache); + } + + @Test + void manualNeverConsultsStoreOrCache() { + GateDecision d = gate.evaluate(false); + assertTrue(d.allowed()); + assertEquals(GateDecision.Reason.MANUAL_FREE, d.reason()); + verify(store, never()).isLinked(); + verify(cache, never()).current(); + } + + @Test + void billableUnlinkedDoesNotHitCache() { + when(store.isLinked()).thenReturn(false); + GateDecision d = gate.evaluate(true); + assertFalse(d.allowed()); + assertEquals(GateDecision.Reason.NOT_LINKED, d.reason()); + verify(cache, never()).current(); + } + + @Test + void billableLinkedConsultsCache() { + when(store.isLinked()).thenReturn(true); + when(cache.current()) + .thenReturn( + Optional.of( + new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK))); + GateDecision d = gate.evaluate(true); + assertTrue(d.allowed()); + assertEquals(GateDecision.Reason.ENTITLED, d.reason()); + } + + @Test + void flagOffShortCircuits() { + properties.setEnabled(false); + GateDecision d = gate.evaluate(true); + assertTrue(d.allowed()); + assertEquals(GateDecision.Reason.FLAG_OFF, d.reason()); + verify(store, never()).isLinked(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java new file mode 100644 index 0000000000..709e3c0866 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java @@ -0,0 +1,61 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@ExtendWith(MockitoExtension.class) +class InstanceEntitlementInterceptorTest { + + @Mock private InstanceEntitlementGate gate; + + private boolean preHandle(MockHttpServletResponse response) throws Exception { + return new InstanceEntitlementInterceptor(gate) + .preHandle( + new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object()); + } + + @Test + void allowsWhenGateAllows() throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertTrue(preHandle(response)); + assertEquals(200, response.getStatus()); + } + + @Test + void blocksWith402AndLinkSignalWhenGateBlocks() throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.block(GateDecision.Reason.NOT_LINKED)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertFalse(preHandle(response)); + assertEquals(HttpStatus.PAYMENT_REQUIRED.value(), response.getStatus()); + assertEquals("application/json", response.getContentType()); + assertTrue(response.getContentAsString().contains("ACCOUNT_LINK_REQUIRED")); + assertTrue(response.getContentAsString().contains("NOT_LINKED")); + } + + @Test + void failsOpenWhenGateThrows() throws Exception { + // A DB / SaaS blip while resolving entitlement must never hard-block billable work. + when(gate.evaluate(anyBoolean())) + .thenThrow(new RuntimeException("entitlement source down")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertTrue(preHandle(response)); + assertEquals(200, response.getStatus()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ServerCertificateServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ServerCertificateServiceTest.java index 4d78ca7471..e13bc7b0b4 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/ServerCertificateServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ServerCertificateServiceTest.java @@ -25,6 +25,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; @@ -48,7 +49,7 @@ class ServerCertificateServiceTest { @BeforeEach void setUp() { - service = new ServerCertificateService(licenseKeyChecker); + service = new ServerCertificateService(licenseKeyChecker, new ApplicationProperties()); // default: feature enabled, validity 365, org Stirling-PDF, no regenerate ReflectionTestUtils.setField(service, "enabled", true); ReflectionTestUtils.setField(service, "organizationName", "Stirling-PDF"); diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java new file mode 100644 index 0000000000..91974a6d04 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java @@ -0,0 +1,161 @@ +package stirling.software.saas.accountlink; + +import java.util.List; + +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.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Account-link registration surface (combined-billing "Mode A"). + * + *

A self-hosted instance's local backend calls {@code POST /register} with the admin's + * short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new + * auth here). We resolve the caller's team, mint a device credential bound to it, and return the + * secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this + * JWT. + * + *

Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent → + * 404. Leader-only, and the team is always derived from the caller (never the request body). + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/account-link") +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class AccountLinkController { + + private final AccountLinkService service; + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + + public AccountLinkController( + AccountLinkService service, + TeamMembershipRepository memberRepo, + UserRepository userRepository) { + this.service = service; + this.memberRepo = memberRepo; + this.userRepository = userRepository; + } + + /** Optional display name for the instance (hostname / label). */ + public record RegisterRequest(String name) {} + + /** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */ + public record RegisterResponse( + Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {} + + public record InstanceRow( + Long instanceId, + String deviceId, + String name, + String createdAt, + String lastSeenAt, + boolean revoked) {} + + @PostMapping("/register") + @PreAuthorize("isAuthenticated()") + public ResponseEntity register( + @RequestBody(required = false) RegisterRequest req, Authentication auth) { + LeaderTeam lt = resolveLeaderTeam(auth); + if (lt.error() != null) { + return ResponseEntity.status(lt.error()).build(); + } + String name = req != null ? req.name() : null; + AccountLinkService.RegisteredInstance reg = + service.register(lt.teamId(), lt.userId(), name); + return ResponseEntity.status(HttpStatus.CREATED) + .body( + new RegisterResponse( + reg.instanceId(), + lt.teamId(), + reg.deviceId(), + reg.deviceSecret(), + reg.name())); + } + + @GetMapping("/instances") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> list(Authentication auth) { + LeaderTeam lt = resolveLeaderTeam(auth); + if (lt.error() != null) { + return ResponseEntity.status(lt.error()).build(); + } + List rows = + service.list(lt.teamId()).stream() + .map( + i -> + new InstanceRow( + i.getInstanceId(), + i.getDeviceId(), + i.getName(), + i.getCreatedAt() != null + ? i.getCreatedAt().toString() + : null, + i.getLastSeenAt() != null + ? i.getLastSeenAt().toString() + : null, + i.getRevokedAt() != null)) + .toList(); + return ResponseEntity.ok(rows); + } + + @PostMapping("/instances/{instanceId}/revoke") + @PreAuthorize("isAuthenticated()") + public ResponseEntity revoke(@PathVariable Long instanceId, Authentication auth) { + LeaderTeam lt = resolveLeaderTeam(auth); + if (lt.error() != null) { + return ResponseEntity.status(lt.error()).build(); + } + boolean ok = service.revoke(lt.teamId(), instanceId); + return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); + } + + // --------------------------------------------------------------------------------------- + // Helpers — team always derived from the caller; instance linking is a leader (billing) action. + // --------------------------------------------------------------------------------------- + + /** + * Resolved caller team, or an {@code error} status to return (teamId/userId null when error). + */ + private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {} + + private LeaderTeam resolveLeaderTeam(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED); + } + List rows = memberRepo.findPrimaryMembership(user.getId()); + if (rows.isEmpty()) { + return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); + } + TeamMembership m = rows.get(0); + if (m.getRole() != TeamRole.LEADER) { + return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); + } + return new LeaderTeam(m.getTeam().getId(), user.getId(), null); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java new file mode 100644 index 0000000000..c31fc1b03e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java @@ -0,0 +1,117 @@ +package stirling.software.saas.accountlink; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +/** + * Account-link instance registration + lifecycle (combined-billing "Mode A"). + * + *

Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound + * to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its + * unattended entitlement reads with that credential. + * + *

Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so + * {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404. + */ +@Slf4j +@Service +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class AccountLinkService { + + /** 32 bytes of entropy → URL-safe secret; high enough that an unsalted SHA-256 hash is fine. */ + private static final int SECRET_BYTES = 32; + + private final LinkedInstanceRepository repo; + private final SecureRandom random = new SecureRandom(); + + public AccountLinkService(LinkedInstanceRepository repo) { + this.repo = repo; + } + + /** Result of {@link #register}; {@code deviceSecret} is plaintext and returned exactly once. */ + public record RegisteredInstance( + Long instanceId, String deviceId, String deviceSecret, String name) {} + + /** + * Creates a new linked instance for {@code teamId}, returning the one-time plaintext secret. + */ + @Transactional + public RegisteredInstance register(Long teamId, Long createdByUserId, String name) { + String deviceId = UUID.randomUUID().toString(); + String deviceSecret = randomSecret(); + + LinkedInstance instance = new LinkedInstance(); + instance.setTeamId(teamId); + instance.setCreatedByUserId(createdByUserId); + instance.setDeviceId(deviceId); + instance.setDeviceSecretHash(sha256Hex(deviceSecret)); + instance.setName(name); + repo.save(instance); + + log.info( + "Account-link: registered instance {} (device {}) for team {}", + instance.getInstanceId(), + deviceId, + teamId); + return new RegisteredInstance(instance.getInstanceId(), deviceId, deviceSecret, name); + } + + /** + * All instances for a team, newest first (includes revoked, for the "Linked instances" list). + */ + @Transactional(readOnly = true) + public List list(Long teamId) { + return repo.findByTeamIdOrderByCreatedAtDesc(teamId); + } + + /** + * Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by + * a different team (so a caller can never revoke another team's instance). Idempotent. + */ + @Transactional + public boolean revoke(Long teamId, Long instanceId) { + Optional found = repo.findById(instanceId); + if (found.isEmpty() || !found.get().getTeamId().equals(teamId)) { + return false; + } + LinkedInstance instance = found.get(); + if (instance.getRevokedAt() == null) { + instance.setRevokedAt(LocalDateTime.now()); + repo.save(instance); + log.info("Account-link: revoked instance {} for team {}", instanceId, teamId); + } + return true; + } + + private String randomSecret() { + byte[] buf = new byte[SECRET_BYTES]; + random.nextBytes(buf); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + } + + /** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */ + static String sha256Hex(String value) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(md.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java new file mode 100644 index 0000000000..a2fd13a095 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java @@ -0,0 +1,108 @@ +package stirling.software.saas.accountlink; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +/** + * Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A"). + * + *

Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link + * LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the + * stored hash. On a match it sets a {@link LinkedInstanceAuthenticationToken} (team-scoped, {@code + * ROLE_LINKED_INSTANCE}); otherwise it does nothing and lets the chain continue (→ 401 on a + * protected endpoint). + * + *

Read-only and path-scoped to {@code /api/v1/instance/**}: the device principal is never + * established for user-facing endpoints, so a leaked secret can only reach the instance surface. + * Gated behind {@code stirling.billing.account-link.enabled}; absent when the flag is off, so + * {@code SupabaseSecurityConfig} never wires it in. + */ +@Slf4j +@Component +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class DeviceCredentialAuthenticationFilter extends OncePerRequestFilter { + + static final String HEADER_DEVICE_ID = "X-Device-Id"; + static final String HEADER_DEVICE_SECRET = "X-Device-Secret"; + static final String INSTANCE_PATH_PREFIX = "/api/v1/instance/"; + + private final LinkedInstanceRepository repo; + + public DeviceCredentialAuthenticationFilter(LinkedInstanceRepository repo) { + this.repo = repo; + } + + /** Only the instance surface uses the device credential; everything else skips this filter. */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith(INSTANCE_PATH_PREFIX); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String deviceId = request.getHeader(HEADER_DEVICE_ID); + String secret = request.getHeader(HEADER_DEVICE_SECRET); + + if (deviceId != null + && secret != null + && SecurityContextHolder.getContext().getAuthentication() == null) { + repo.findByDeviceIdAndRevokedAtIsNull(deviceId) + .ifPresent( + instance -> { + if (constantTimeEquals( + AccountLinkService.sha256Hex(secret), + instance.getDeviceSecretHash())) { + SecurityContextHolder.getContext() + .setAuthentication( + new LinkedInstanceAuthenticationToken( + instance.getInstanceId(), + instance.getTeamId())); + // Stamp liveness, best-effort. Auth is already set above; a + // transient write failure must NOT 500 an otherwise-valid + // request, so swallow it. Targeted single-column UPDATE (not a + // full save) so a concurrent revoke between the read above and + // this write can't be clobbered back to active. + try { + repo.touchLastSeen( + instance.getInstanceId(), LocalDateTime.now()); + } catch (RuntimeException e) { + log.debug( + "last_seen_at update failed for device {}: {}", + deviceId, + e.getMessage()); + } + } else { + log.debug("Device credential mismatch for device {}", deviceId); + } + }); + } + + chain.doFilter(request, response); + } + + private static boolean constantTimeEquals(String a, String b) { + if (a == null || b == null) { + return false; + } + return MessageDigest.isEqual( + a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java new file mode 100644 index 0000000000..7392eb928a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java @@ -0,0 +1,131 @@ +package stirling.software.saas.accountlink; + +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.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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; + +import stirling.software.saas.payg.billing.TeamBillingContext; +import stirling.software.saas.payg.billing.TeamBillingService; +import stirling.software.saas.payg.entitlement.EntitlementService; +import stirling.software.saas.payg.entitlement.EntitlementSnapshot; +import stirling.software.saas.payg.model.EntitlementState; + +/** + * Instance-facing surface (combined-billing "Mode A"), authenticated by the device + * credential — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device + * credential is scoped here and nowhere else. + * + *

{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid + * device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET + * /entitlement} is the read the local gate consumes — the same team-scoped snapshot the FE wallet + * sees, trimmed to the fields the gate needs (subscription, free pool, period spend/cap, state), + * and built on the same device-credential auth. + * + *

Gated behind {@code stirling.billing.account-link.enabled}: off → beans absent → 404. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/instance") +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class InstanceController { + + private final EntitlementService entitlementService; + private final TeamBillingService billingService; + private final AccountLinkService accountLinkService; + + public InstanceController( + EntitlementService entitlementService, + TeamBillingService billingService, + AccountLinkService accountLinkService) { + this.entitlementService = entitlementService; + this.billingService = billingService; + this.accountLinkService = accountLinkService; + } + + public record WhoAmIResponse(Long instanceId, Long teamId) {} + + /** + * Minimal entitlement view the local gate enforces against. {@code periodCapUnits} null = + * uncapped. {@code state} is the coarse OK / OVER_LIMIT vocabulary the instance gate parses + * (see {@link #coarseState}), not the SaaS feature-state enum. + */ + public record EntitlementResponse( + boolean subscribed, + long freeRemainingUnits, + long periodSpendUnits, + Long periodCapUnits, + String state) {} + + @GetMapping("/whoami") + @PreAuthorize("hasRole('LINKED_INSTANCE')") + public ResponseEntity whoami(Authentication auth) { + if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { + // Belt-and-braces: hasRole already guarantees this, but never leak a non-instance + // principal. + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + return ResponseEntity.ok(new WhoAmIResponse(token.getInstanceId(), token.getTeamId())); + } + + /** + * Revokes this instance's own credential — a credential can mark itself revoked the same way a + * session logs itself out. Called by the proprietary backend on local unlink so the SaaS row + * gets {@code revoked_at} set; idempotent (already-revoked → still 204). + */ + @PostMapping("/revoke-self") + @PreAuthorize("hasRole('LINKED_INSTANCE')") + public ResponseEntity revokeSelf(Authentication auth) { + if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + accountLinkService.revoke(token.getTeamId(), token.getInstanceId()); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/entitlement") + @PreAuthorize("hasRole('LINKED_INSTANCE')") + @Transactional(readOnly = true) + public ResponseEntity entitlement(Authentication auth) { + if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + Long teamId = token.getTeamId(); + + // Same composition the FE wallet uses: billing facts (subscription, free pool) from + // TeamBillingService, period spend/cap + state from the entitlement snapshot. + TeamBillingContext billing = billingService.forTeam(teamId); + EntitlementSnapshot snap = entitlementService.getSnapshot(teamId); + + return ResponseEntity.ok( + new EntitlementResponse( + billing.subscribed(), + billing.freeRemainingUnits(), + snap.periodSpendUnits(), + snap.periodCapUnits(), + coarseState(snap.state()))); + } + + /** + * Collapses the SaaS feature-state machine into the OK / OVER_LIMIT vocabulary the instance + * gate parses. DEGRADED means automation + AI are gated off — which, for a gate that governs + * only billable work (manual tools are free-pathed before it), is exactly OVER_LIMIT; FULL and + * WARNED are OK. + */ + private static String coarseState(EntitlementState state) { + return state == EntitlementState.DEGRADED ? "OVER_LIMIT" : "OK"; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java new file mode 100644 index 0000000000..ec92c97758 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java @@ -0,0 +1,77 @@ +package stirling.software.saas.accountlink; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code + * linked_instance}, V22). + * + *

Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's + * short-lived Supabase JWT. Registration mints a {@code device_id} (public) plus a {@code + * device_secret} (high-entropy, returned once and stored only on the instance — we keep an unsalted + * SHA-256 hash, the same posture as API keys). The instance authenticates its unattended + * entitlement reads with that device credential, so no long-lived user JWT lives on the server + * side. + * + *

{@code revoked_at IS NULL} means active; revoking sets it and the credential stops + * authenticating. The whole surface is gated behind {@code stirling.billing.account-link.enabled}. + */ +@Entity +@Table(name = "linked_instance") +@Getter +@Setter +@NoArgsConstructor +public class LinkedInstance { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "instance_id") + private Long instanceId; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + /** + * Admin who registered the instance; informational (no FK, so a user delete never offlines it). + */ + @Column(name = "created_by_user_id") + private Long createdByUserId; + + /** Public, non-secret identifier the instance presents on every request. */ + @Column(name = "device_id", nullable = false, unique = true, length = 64) + private String deviceId; + + /** SHA-256 hex of the device secret; the secret itself is never stored. */ + @Column(name = "device_secret_hash", nullable = false, length = 64) + private String deviceSecretHash; + + /** Operator-set display label (hostname etc.) for the "Linked instances" list. */ + @Column(name = "name", length = 255) + private String name; + + /** Insert time; Hibernate populates this on persist (DB DEFAULT is belt-and-braces). */ + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** Stamped when the device credential last authenticated; powers staleness display. */ + @Column(name = "last_seen_at") + private LocalDateTime lastSeenAt; + + /** NULL = active. Set on unlink/revoke; a revoked credential fails authentication. */ + @Column(name = "revoked_at") + private LocalDateTime revokedAt; +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java new file mode 100644 index 0000000000..af883bd66a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java @@ -0,0 +1,45 @@ +package stirling.software.saas.accountlink; + +import java.util.List; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +/** + * Authentication for a linked self-hosted instance (combined-billing "Mode A"). + * + *

Deliberately not a user: the principal is the instance ({@code instanceId}) bound to + * a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code + * User} and creates no user row — a device credential can never act as a person, only as its team's + * instance, and only on the instance-facing endpoints. + */ +public class LinkedInstanceAuthenticationToken extends AbstractAuthenticationToken { + + private final Long instanceId; + private final Long teamId; + + public LinkedInstanceAuthenticationToken(Long instanceId, Long teamId) { + super(List.of(new SimpleGrantedAuthority("ROLE_LINKED_INSTANCE"))); + this.instanceId = instanceId; + this.teamId = teamId; + setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return null; // the secret is never retained on the authentication + } + + @Override + public Object getPrincipal() { + return instanceId; + } + + public Long getInstanceId() { + return instanceId; + } + + public Long getTeamId() { + return teamId; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceRepository.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceRepository.java new file mode 100644 index 0000000000..dae6a2a69e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceRepository.java @@ -0,0 +1,43 @@ +package stirling.software.saas.accountlink; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +/** + * Data access for {@link LinkedInstance}. Plain Spring Data JPA against {@code stirling_pdf} — + * native schema access, no RPC, consistent with the rest of the SaaS backend. + */ +public interface LinkedInstanceRepository extends JpaRepository { + + /** + * Active-credential lookup for the device-credential auth filter (revoked rows never match). + */ + Optional findByDeviceIdAndRevokedAtIsNull(String deviceId); + + /** Backs the portal "Linked instances" list (includes revoked, newest first). */ + List findByTeamIdOrderByCreatedAtDesc(Long teamId); + + /** Active (non-revoked) linked instances on a team — the orphan guard's count. */ + long countByTeamIdAndRevokedAtIsNull(Long teamId); + + /** + * Stamps liveness on a single instance. A targeted single-column UPDATE rather than a + * full-entity {@code save}: the auth filter loads the instance outside a transaction, so a full + * save would write back the stale (in-memory {@code null}) {@code revoked_at} and could + * silently un-revoke a credential that was revoked between the read and the write. The {@code + * revoked_at IS NULL} guard makes this a no-op once revoked. + */ + @Modifying + @Transactional + @Query( + "UPDATE LinkedInstance li SET li.lastSeenAt = :now " + + "WHERE li.instanceId = :instanceId AND li.revokedAt IS NULL") + int touchLastSeen(@Param("instanceId") Long instanceId, @Param("now") LocalDateTime now); +} diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java index 3c6b0d14df..5e90df6bf5 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java @@ -14,12 +14,14 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @Profile("saas") @EnableJpaRepositories( basePackages = { + "stirling.software.saas.accountlink", "stirling.software.saas.repository", "stirling.software.saas.billing.repository", "stirling.software.saas.ai.repository", "stirling.software.saas.payg.repository" }) @EntityScan({ + "stirling.software.saas.accountlink", "stirling.software.saas.model", "stirling.software.saas.billing.model", "stirling.software.saas.ai.model", diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java new file mode 100644 index 0000000000..6b8bb2c883 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java @@ -0,0 +1,150 @@ +package stirling.software.saas.payg.api; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.stripe.StripeInvoiceDao; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Read-only Stripe-invoices surface for the linked org's billing page. + * + *

{@code GET /api/v1/payg/invoices?limit=N} returns the team's most recent Stripe invoices, + * sourced from the {@code stripe.invoices} table the Sync Engine maintains. The caller's team is + * resolved from the authenticated principal (same pattern as {@link PaygWalletController}); we + * never trust a team id from the request. + * + *

Defensive: when the team has no {@code stripe_customer_id} (not subscribed, or pre-checkout) + * or the {@code stripe} schema isn't synced (H2 tests, sync engine off), we return {@code 200} with + * an empty list rather than 500 — the UI renders "no invoices yet". This keeps the page working + * through every link/subscription state. + * + *

{@code hostedInvoiceUrl} + {@code invoicePdf} are Stripe-hosted links the portal can deep-link + * from. We don't proxy the PDF ourselves; Stripe handles auth + caching. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/payg") +@Profile("saas") +public class PaygInvoicesController { + + private static final int DEFAULT_LIMIT = 20; + private static final int MAX_LIMIT = 100; + + private final StripeInvoiceDao invoiceDao; + private final PaygTeamExtensionsRepository extRepo; + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + + public PaygInvoicesController( + StripeInvoiceDao invoiceDao, + PaygTeamExtensionsRepository extRepo, + TeamMembershipRepository memberRepo, + UserRepository userRepository) { + this.invoiceDao = Objects.requireNonNull(invoiceDao, "invoiceDao"); + this.extRepo = Objects.requireNonNull(extRepo, "extRepo"); + this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo"); + this.userRepository = Objects.requireNonNull(userRepository, "userRepository"); + } + + /** The shape the portal renders. Trimmed; never echoes raw Stripe object fields verbatim. */ + public record InvoiceResponse( + String id, + String number, + String status, + Long totalMinor, + String currency, + String createdAt, + String periodStart, + String periodEnd, + String hostedInvoiceUrl, + String invoicePdf, + String description, + Long pdfsProcessed) {} + + @GetMapping("/invoices") + @PreAuthorize("isAuthenticated()") + @Transactional(readOnly = true) + public ResponseEntity> list( + @RequestParam(name = "limit", required = false) Integer limit, Authentication auth) { + + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + // Resolve the caller's team from their primary membership — same pattern as + // PaygWalletController. The team id NEVER comes from the request. + List rows = memberRepo.findPrimaryMembership(user.getId()); + if (rows.isEmpty()) { + return ResponseEntity.ok(List.of()); + } + Long teamId = rows.get(0).getTeam().getId(); + + // No PAYG extension row OR no Stripe customer id → team has never subscribed → no + // invoices. Empty list, not 404 — the UI distinguishes "no invoices yet" from a + // genuine error and we don't want to error a happy free team. + Optional ext = extRepo.findById(teamId); + if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) { + return ResponseEntity.ok(List.of()); + } + + int safeLimit = clampLimit(limit); + List body = + invoiceDao.findRecentByCustomer(ext.get().getStripeCustomerId(), safeLimit).stream() + .map(PaygInvoicesController::toResponse) + .toList(); + return ResponseEntity.ok(body); + } + + private static int clampLimit(Integer requested) { + if (requested == null) return DEFAULT_LIMIT; + return Math.max(1, Math.min(requested, MAX_LIMIT)); + } + + private static InvoiceResponse toResponse(StripeInvoiceDao.InvoiceRow r) { + return new InvoiceResponse( + r.id(), + r.number(), + r.status(), + r.totalMinor(), + r.currency(), + iso(r.createdAt()), + iso(r.periodStart()), + iso(r.periodEnd()), + r.hostedInvoiceUrl(), + r.invoicePdf(), + r.description(), + r.pdfsProcessed()); + } + + private static String iso(LocalDateTime ldt) { + return ldt == null ? null : ldt.toString(); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java new file mode 100644 index 0000000000..724d6715d3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java @@ -0,0 +1,108 @@ +package stirling.software.saas.payg.api; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.stripe.StripePaymentMethodDao; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Read-only default-payment-method surface for the subscribed billing page. + * + *

{@code GET /api/v1/payg/payment-method} returns the team's default card (brand / last4 / + * expiry), sourced from {@code stripe.payment_methods} (Sync Engine mirror). The caller's team is + * resolved from the authenticated principal — never trusted from the request — exactly as {@link + * PaygInvoicesController} does. + * + *

Defensive: no team, no {@code stripe_customer_id} (free / pre-checkout), or the card simply + * not in the mirror all degrade to {@code 200 present=false} rather than an error. Card edits never + * happen here; the portal deep-links to Stripe's hosted customer portal for that. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/payg") +@Profile("saas") +public class PaygPaymentMethodController { + + /** Trimmed default-card shape. {@code present=false} carries no card fields. */ + public record PaymentMethodResponse( + boolean present, String brand, String last4, Integer expMonth, Integer expYear) { + static PaymentMethodResponse absent() { + return new PaymentMethodResponse(false, null, null, null, null); + } + } + + private final StripePaymentMethodDao paymentMethodDao; + private final PaygTeamExtensionsRepository extRepo; + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + + public PaygPaymentMethodController( + StripePaymentMethodDao paymentMethodDao, + PaygTeamExtensionsRepository extRepo, + TeamMembershipRepository memberRepo, + UserRepository userRepository) { + this.paymentMethodDao = Objects.requireNonNull(paymentMethodDao, "paymentMethodDao"); + this.extRepo = Objects.requireNonNull(extRepo, "extRepo"); + this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo"); + this.userRepository = Objects.requireNonNull(userRepository, "userRepository"); + } + + @GetMapping("/payment-method") + @PreAuthorize("isAuthenticated()") + @Transactional(readOnly = true) + public ResponseEntity get(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + List rows = memberRepo.findPrimaryMembership(user.getId()); + if (rows.isEmpty()) { + return ResponseEntity.ok(PaymentMethodResponse.absent()); + } + Long teamId = rows.get(0).getTeam().getId(); + + Optional ext = extRepo.findById(teamId); + if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) { + return ResponseEntity.ok(PaymentMethodResponse.absent()); + } + + return ResponseEntity.ok( + paymentMethodDao + .findDefaultCard(ext.get().getStripeCustomerId()) + .map( + c -> + new PaymentMethodResponse( + true, + c.brand(), + c.last4(), + c.expMonth(), + c.expYear())) + .orElseGet(PaymentMethodResponse::absent)); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java b/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java index d47b6e7365..29148a5ddb 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java @@ -15,7 +15,9 @@ import stirling.software.saas.payg.model.FeatureSet; *

State transitions: * *

    - *
  • {@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} unconditionally. + *
  • {@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} (uncapped). + *
  • {@code capUnits <= 0} (an explicit $0 cap) → {@code DEGRADED}: metered work blocked, only + * the free grant + manual tools run. *
  • {@code spend / cap < warnPct} → {@code FULL}. *
  • MINIMAL semantics: under DEGRADED+MINIMAL manual server-side tools (gated by {@link * FeatureGate#OFFSITE_PROCESSING}) and client-side tools still work; only {@link @@ -49,9 +51,19 @@ public final class CapEvaluator { int degradeAtPct, FeatureSet degradedFeatureSet) { - if (capUnits == null || capUnits <= 0) { + if (capUnits == null) { + // No cap configured → uncapped, full feature set. return full(); } + if (capUnits <= 0) { + // An explicit cap that buys zero paid documents (a $0 cap, or one set + // below the per-document rate): metered work is blocked outright — + // only the free grant and manual tools run. DEGRADED, same as hitting + // a positive cap. + FeatureSet effective = + degradedFeatureSet != null ? degradedFeatureSet : FeatureSet.MINIMAL; + return new Evaluation(EntitlementState.DEGRADED, effective, gatesFor(effective)); + } if (warnAtPct < 0 || degradeAtPct <= 0 || degradeAtPct < warnAtPct) { // Defensive: misconfigured thresholds → treat as no-cap-effect to avoid surprise // degradation. The admin endpoints that set the policy should validate; this diff --git a/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeInvoiceDao.java b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeInvoiceDao.java new file mode 100644 index 0000000000..8e6b59f9ce --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeInvoiceDao.java @@ -0,0 +1,215 @@ +package stirling.software.saas.payg.stripe; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.springframework.context.annotation.Profile; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import lombok.extern.slf4j.Slf4j; + +/** + * Read-only accessor for {@code stripe.invoices} (synced into Postgres by the Stripe Sync Engine). + * + *

    Same defensive posture as {@link StripeSubscriptionDao}: when the {@code stripe} schema is + * absent (H2 unit tests, sync engine not yet provisioned, or invoices not in the Sync Engine's + * target list), the lookup degrades to an empty list with a WARN — the caller renders "no invoices + * yet" rather than 500ing the page. + */ +@Slf4j +@Repository +@Profile("saas") +public class StripeInvoiceDao { + + /** + * One invoice row as the portal needs it. Money is in minor units of {@code currency} (e.g. + * cents for USD). {@code hostedInvoiceUrl} and {@code invoicePdf} are Stripe-hosted links that + * are stable for the lifetime of the invoice; safe to use as deep links from the UI. + * + *

    {@code description} is the product name from the subscription chain — the portal renders + * this as the row label (matching Stripe's customer-portal row layout). Falls back to the + * invoice's own {@code description} field, then to null when neither is set. + */ + public record InvoiceRow( + String id, + String number, + String status, + Long totalMinor, + String currency, + LocalDateTime createdAt, + LocalDateTime periodStart, + LocalDateTime periodEnd, + String hostedInvoiceUrl, + String invoicePdf, + String description, + /** Billed units (PDFs) on this invoice — summed line-item quantity; null if unknown. */ + Long pdfsProcessed) {} + + // Drafts are excluded: Stripe's API returns null for both + // {@code hosted_invoice_url} and {@code invoice_pdf} on unfinalized + // invoices, and Stripe's own customer portal hides drafts too — there's no + // user-facing artefact to surface yet. The next finalize / webhook flips + // the status and the invoice shows up automatically. + // + // The LATERAL join walks the same subscription → subscription_items → prices + // → products chain {@link StripeSubscriptionDao} uses to get the per-doc + // rate; here we use it to get the product NAME (e.g. "Stirling Processor + // Plan") so the portal can render Stripe's row label rather than the + // monospace invoice id. Falls back to {@code i.description}, then null. + private static final String QUERY = + "SELECT i.id, i.number, i.status::text AS status," + + " i.total, i.currency," + + " i.created, i.period_start, i.period_end," + + " i.hosted_invoice_url, i.invoice_pdf," + + " COALESCE(prod.name, i.description) AS description" + + " FROM stripe.invoices i" + + " LEFT JOIN LATERAL (" + + " SELECT si.price FROM stripe.subscription_items si" + + " WHERE si.subscription = i.subscription" + + " AND COALESCE(si.deleted, false) = false" + + " ORDER BY si.created DESC NULLS LAST LIMIT 1" + + " ) item ON true" + + " LEFT JOIN stripe.prices p ON p.id = item.price" + + " LEFT JOIN stripe.products prod ON prod.id = p.product" + + " WHERE i.customer = ?" + + " AND i.status::text <> 'draft'" + + " ORDER BY i.created DESC NULLS LAST" + + " LIMIT ?"; + + private final JdbcTemplate jdbcTemplate; + + public StripeInvoiceDao(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate"); + } + + /** + * The most recent {@code limit} invoices for {@code stripeCustomerId}, newest first. Empty list + * on missing schema / no rows / connectivity blip — the controller surfaces this as 200 with an + * empty body rather than 500. + */ + public List findRecentByCustomer(String stripeCustomerId, int limit) { + if (stripeCustomerId == null || stripeCustomerId.isBlank()) { + return List.of(); + } + int safeLimit = Math.max(1, Math.min(limit, 100)); + List rows; + try { + rows = + jdbcTemplate.query( + QUERY, + (rs, i) -> + new InvoiceRow( + rs.getString("id"), + rs.getString("number"), + rs.getString("status"), + nullableLong(rs, "total"), + rs.getString("currency"), + toLocal(rs.getLong("created"), rs.wasNull()), + toLocal(rs.getLong("period_start"), rs.wasNull()), + toLocal(rs.getLong("period_end"), rs.wasNull()), + rs.getString("hosted_invoice_url"), + rs.getString("invoice_pdf"), + rs.getString("description"), + null), + stripeCustomerId, + safeLimit); + } catch (DataAccessException e) { + log.warn( + "stripe.invoices lookup failed for customer {}: {}", + stripeCustomerId, + e.getMessage()); + return List.of(); + } + if (rows.isEmpty()) { + return rows; + } + Map billed = sumBilledUnits(rows.stream().map(InvoiceRow::id).toList()); + if (billed.isEmpty()) { + return rows; + } + return rows.stream() + .map( + r -> + new InvoiceRow( + r.id(), + r.number(), + r.status(), + r.totalMinor(), + r.currency(), + r.createdAt(), + r.periodStart(), + r.periodEnd(), + r.hostedInvoiceUrl(), + r.invoicePdf(), + r.description(), + billed.get(r.id()))) + .toList(); + } + + /** + * Sums billed quantity (PDFs) per invoice from the {@code stripe.invoices.lines} JSONB the Sync + * Engine mirrors — line items live in {@code lines->'data'}, NOT a separate {@code + * invoice_line_items} table (the sync engine never creates one). + * + *

    Only the metered usage line counts: a Processor invoice can also carry flat + * subscription-fee, proration and tax lines, each with its own {@code quantity}, so summing + * every line would inflate the headline PDF count (usage 500 + a fee line of 1 → "501"). We + * filter on {@code price.recurring.usage_type = 'metered'}. When no metered line is present the + * subquery is {@code NULL} and the invoice is omitted from the map, so {@code + * InvoiceRow.pdfsProcessed} stays {@code null} and the column renders "—" rather than "0". + * + *

    Run SEPARATELY from the invoice query and defensively wrapped, so a missing/changed schema + * degrades to an empty map (every row renders "—") instead of failing the whole invoice list. + */ + private Map sumBilledUnits(List invoiceIds) { + if (invoiceIds.isEmpty()) { + return Map.of(); + } + String placeholders = invoiceIds.stream().map(id -> "?").collect(Collectors.joining(",")); + String sql = + "SELECT i.id AS invoice_id," + + " (SELECT SUM((l->>'quantity')::int)" + + " FROM jsonb_array_elements(COALESCE(i.lines->'data', '[]'::jsonb)) AS l" + + " WHERE l->'price'->'recurring'->>'usage_type' = 'metered') AS qty" + + " FROM stripe.invoices i" + + " WHERE i.id IN (" + + placeholders + + ")"; + try { + Map map = new HashMap<>(); + jdbcTemplate.query( + sql, + (java.sql.ResultSet rs) -> { + long qty = rs.getLong("qty"); + if (!rs.wasNull()) { + // null (no metered line) → leave the key absent → renders "—". + map.put(rs.getString("invoice_id"), qty); + } + }, + invoiceIds.toArray()); + return map; + } catch (DataAccessException e) { + log.warn("stripe.invoices line-quantity sum failed: {}", e.getMessage()); + return Map.of(); + } + } + + private static Long nullableLong(java.sql.ResultSet rs, String column) + throws java.sql.SQLException { + long v = rs.getLong(column); + return rs.wasNull() ? null : v; + } + + private static LocalDateTime toLocal(long epochSeconds, boolean wasNull) { + if (wasNull) return null; + return LocalDateTime.ofInstant(Instant.ofEpochSecond(epochSeconds), ZoneId.systemDefault()); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripePaymentMethodDao.java b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripePaymentMethodDao.java new file mode 100644 index 0000000000..756e7dceb8 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripePaymentMethodDao.java @@ -0,0 +1,91 @@ +package stirling.software.saas.payg.stripe; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import lombok.extern.slf4j.Slf4j; + +/** + * Read-only accessor for a team's default card off the Stripe Sync Engine schema ({@code + * stripe.payment_methods}). Prefers the customer's {@code invoice_settings.default_payment_method}; + * falls back to their most recently created card. Card details (brand / last4 / expiry) live in the + * {@code card} JSONB column the sync engine mirrors. + * + *

    Same defensive posture as {@link StripeInvoiceDao}/{@link StripeSubscriptionDao}: a missing + * schema or table — H2 unit tests, sync engine not provisioned, or {@code payment_methods} simply + * absent from the sync target list — degrades to {@link Optional#empty()} with a WARN, so the + * endpoint reports "no card on file" rather than 500ing the page. Editing always happens in + * Stripe's hosted portal; this never writes. + */ +@Slf4j +@Repository +@Profile("saas") +public class StripePaymentMethodDao { + + /** Card brand (e.g. "visa"), last 4 digits, and numeric expiry; any field may be null. */ + public record CardSummary(String brand, String last4, Integer expMonth, Integer expYear) {} + + private static final String QUERY = + "SELECT pm.card->>'brand' AS brand, pm.card->>'last4' AS last4," + + " pm.card->>'exp_month' AS exp_month, pm.card->>'exp_year' AS exp_year" + + " FROM stripe.payment_methods pm" + + " WHERE pm.customer = ? AND pm.type = 'card'" + + " ORDER BY (pm.id = (" + + " SELECT c.invoice_settings->>'default_payment_method'" + + " FROM stripe.customers c WHERE c.id = ?" + + " )) DESC NULLS LAST, pm.created DESC NULLS LAST" + + " LIMIT 1"; + + private final JdbcTemplate jdbcTemplate; + + public StripePaymentMethodDao(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate"); + } + + /** The customer's default card; empty on missing schema / no card / connectivity blip. */ + public Optional findDefaultCard(String stripeCustomerId) { + if (stripeCustomerId == null || stripeCustomerId.isBlank()) { + return Optional.empty(); + } + try { + List rows = + jdbcTemplate.query( + QUERY, + (rs, i) -> + new CardSummary( + rs.getString("brand"), + rs.getString("last4"), + parseIntOrNull(rs, "exp_month"), + parseIntOrNull(rs, "exp_year")), + stripeCustomerId, + stripeCustomerId); + return rows.stream().filter(Objects::nonNull).findFirst(); + } catch (DataAccessException e) { + log.warn( + "stripe.payment_methods lookup failed for customer {}: {}", + stripeCustomerId, + e.getMessage()); + return Optional.empty(); + } + } + + private static Integer parseIntOrNull(ResultSet rs, String column) throws SQLException { + String raw = rs.getString(column); + if (raw == null || raw.isBlank()) { + return null; + } + try { + return Integer.valueOf(raw.trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java index 967b725b5e..5fe27a11e8 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java @@ -10,6 +10,7 @@ import java.util.Locale; import java.util.Objects; import java.util.stream.Collectors; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -49,6 +50,7 @@ import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; +import stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter; import stirling.software.saas.service.SaasTeamService; import stirling.software.saas.service.SupabaseUserService; @@ -80,7 +82,10 @@ public class SupabaseSecurityConfig { private long clockSkewSeconds; @Bean - SecurityFilterChain saasSecurityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder) + SecurityFilterChain saasSecurityFilterChain( + HttpSecurity http, + JwtDecoder jwtDecoder, + ObjectProvider deviceCredentialFilterProvider) throws Exception { // CSRF protection intentionally disabled: this chain is bearer-token only (Supabase JWT in // Authorization header / X-API-KEY) with SessionCreationPolicy.STATELESS, so there is no @@ -135,6 +140,16 @@ public class SupabaseSecurityConfig { .jwtAuthenticationConverter( SupabaseSecurityConfig ::toAuthentication))); + + // Device-credential auth for linked self-hosted instances (combined-billing Mode A). + // The filter bean exists only when stirling.billing.account-link.enabled=true; when off it + // is absent here, so the instance surface cannot authenticate at all until release. + DeviceCredentialAuthenticationFilter deviceFilter = + deviceCredentialFilterProvider.getIfAvailable(); + if (deviceFilter != null) { + http.addFilterBefore(deviceFilter, BearerTokenAuthenticationFilter.class); + } + return http.build(); } diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java index e54aa00df2..6d3e42e806 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java @@ -19,6 +19,7 @@ import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.saas.accountlink.LinkedInstanceRepository; import stirling.software.saas.billing.repository.BillingSubscriptionRepository; import stirling.software.saas.config.SupabaseConfigurationProperties; import stirling.software.saas.model.TeamInvitation; @@ -45,6 +46,7 @@ public class SaasTeamService { private final UserRoleService userRoleService; private final SaasTeamExtensionService saasTeamExtensionService; private final SaasTeamExtensionsRepository saasTeamExtensionsRepository; + private final LinkedInstanceRepository linkedInstanceRepository; private final stirling.software.proprietary.security.service.UserService userService; public static final String DEFAULT_TEAM_NAME = "Default"; @@ -458,22 +460,42 @@ public class SaasTeamService { * accept. The message points them at the right remedy — cancel the plan if the team is paid, * otherwise transfer leadership first. * + *

    Linked self-hosted instances (combined-billing "Mode A") bind to a team via {@code + * linked_instance.team_id}, so they too orphan a team that is left memberless — a personal team + * that accept deletes, or a non-personal team left by its last leader. They're checked in that + * same orphaning branch (not for a non-leader leaving a team that lives on); the remedy is to + * revoke them. + * * @param user the user attempting to accept an invitation - * @throws IllegalStateException if accepting would orphan a team the user leads + * @throws IllegalStateException if accepting would orphan a team the user leads or its + * instances */ private void assertCanLeaveCurrentTeamsToJoinAnother(User user) { for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) { Team team = membership.getTeam(); - if (saasTeamExtensionService.isPersonal(team) || !membership.isLeader()) { - // Personal teams are deleted on accept; non-leaders leaving never orphans a team. + boolean personal = saasTeamExtensionService.isPersonal(team); + if (!personal && !membership.isLeader()) { + // A non-leader leaving a shared team never orphans it. continue; } - // Only reached for a non-personal team the user leads — at most one such team in the - // one-team-per-user model — so this count runs ~once, not per membership. - if (membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) > 1) { + if (!personal + && membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) + > 1) { // Another leader remains, so the team keeps an owner. continue; } + // Leaving here orphans the team: a personal team is deleted on accept; a non-personal + // team is being left by its last leader. Either way its linked self-hosted instances + // lose their billing team, so block until they're revoked. + if (linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(team.getId()) > 0) { + throw new IllegalStateException( + "Revoke linked self-hosted instances on this team before joining another" + + " team."); + } + if (personal) { + // Personal teams are disposable (deleted on accept) and never billed/shared. + continue; + } if (hasActivePaidSubscription(team)) { throw new IllegalStateException( "Your team has an active plan and you are its last leader. Cancel the plan" diff --git a/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql b/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql new file mode 100644 index 0000000000..9c975f1ce7 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql @@ -0,0 +1,44 @@ +-- Account-link instances. One row per self-hosted instance that has linked a SaaS account. +-- +-- Part of the combined-billing "Mode A" (connected self-hosted) flow: +-- 1. An admin signs into their SaaS account in the Stirling Portal via the Supabase JS SDK +-- (a short-lived Supabase JWT, refreshed client-side — it never reaches the server long-term). +-- 2. That JWT is used ONCE to call POST /api/v1/account-link/register, which mints a +-- device_id + device_secret bound to the admin's team. The secret is returned once and +-- stored only on the instance; we keep a SHA-256 hash here (the secret is high-entropy, +-- so an unsalted hash is sufficient — same posture as API keys). +-- 3. The instance authenticates all unattended metering / entitlement calls with that device +-- credential. No long-lived user JWT lives on the server side. +-- +-- Twin of supabase/migrations/20260619000000_account_link_instances.sql (Stirling-PDF-SaaS). +-- Inert until release: the AccountLinkController + device-credential filter are gated behind +-- stirling.billing.account-link.enabled (default off). The table itself is harmless additive. + +CREATE TABLE IF NOT EXISTS stirling_pdf.linked_instance ( + instance_id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE, + created_by_user_id BIGINT, + -- admin who registered the instance; informational only (no FK so a user delete never + -- cascades a working instance offline). + device_id VARCHAR(64) NOT NULL UNIQUE, + -- public, non-secret identifier the instance presents on every request. + device_secret_hash VARCHAR(64) NOT NULL, + -- SHA-256 hex of the device secret; the secret itself is never stored. + name VARCHAR(255), + -- operator-set display label (hostname etc.) for the "Linked instances" list. + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP, + -- stamped when the device credential last authenticated; powers staleness display. + revoked_at TIMESTAMP + -- NULL = active. Set on unlink/revoke; a revoked credential fails authentication. +); + +CREATE INDEX IF NOT EXISTS idx_linked_instance_team + ON stirling_pdf.linked_instance (team_id); + +COMMENT ON TABLE stirling_pdf.linked_instance IS + 'One row per self-hosted instance linked to a SaaS account (combined-billing Mode A). ' + 'device_id is the public identifier; device_secret_hash is the SHA-256 of the bearer ' + 'secret (returned once at registration, stored only on the instance). The instance ' + 'authenticates unattended metering / entitlement calls with this credential; revoked_at ' + 'IS NULL means active.'; diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java new file mode 100644 index 0000000000..0de790cac0 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java @@ -0,0 +1,169 @@ +package stirling.software.saas.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest; +import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Pure-Mockito unit tests for {@link AccountLinkController} — the leader-only auth ladder, and that + * the team is always derived from the caller's membership (never the request). Mirrors {@code + * PaygInvoicesControllerTest}'s static-mock of {@link AuthenticationUtils}. + */ +@ExtendWith(MockitoExtension.class) +class AccountLinkControllerTest { + + @Mock private AccountLinkService service; + @Mock private TeamMembershipRepository memberRepo; + @Mock private UserRepository userRepository; + + private AccountLinkController controller; + private Authentication auth; + + @BeforeEach + void setUp() { + controller = new AccountLinkController(service, memberRepo, userRepository); + auth = + new AnonymousAuthenticationToken( + "k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } + + @Test + void register_unauthenticated_returns401() { + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenThrow(new SecurityException("not authenticated")); + + ResponseEntity resp = + controller.register(new RegisterRequest("host"), auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(service); + } + } + + @Test + void register_noMembership_returns403() { + User user = mockUser(42L); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of()); + + ResponseEntity resp = controller.register(null, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verifyNoInteractions(service); + } + } + + @Test + void register_nonLeader_returns403() { + User user = mockUser(42L); + TeamMembership member = membership(7L, TeamRole.MEMBER); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(member)); + + ResponseEntity resp = controller.register(null, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verifyNoInteractions(service); + } + } + + @Test + void register_leader_mintsCredentialForCallerTeam() { + User user = mockUser(42L); + TeamMembership leader = membership(7L, TeamRole.LEADER); + when(service.register(7L, 42L, "host")) + .thenReturn( + new AccountLinkService.RegisteredInstance(99L, "dev-x", "sec-x", "host")); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader)); + + ResponseEntity resp = + controller.register(new RegisterRequest("host"), auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); + RegisterResponse body = resp.getBody(); + assertThat(body).isNotNull(); + // Team comes from the caller's membership and is surfaced in the response. + assertThat(body.teamId()).isEqualTo(7L); + assertThat(body.instanceId()).isEqualTo(99L); + assertThat(body.deviceSecret()).isEqualTo("sec-x"); + } + } + + @Test + void revoke_leader_returns204WhenServiceRevokes() { + User user = mockUser(42L); + TeamMembership leader = membership(7L, TeamRole.LEADER); + when(service.revoke(7L, 11L)).thenReturn(true); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader)); + + ResponseEntity resp = controller.revoke(11L, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + } + + @Test + void revoke_leader_returns404WhenServiceReportsNotFound() { + User user = mockUser(42L); + TeamMembership leader = membership(7L, TeamRole.LEADER); + when(service.revoke(7L, 11L)).thenReturn(false); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader)); + + ResponseEntity resp = controller.revoke(11L, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + private static User mockUser(long id) { + User u = new User(); + u.setId(id); + return u; + } + + private static TeamMembership membership(long teamId, TeamRole role) { + Team team = new Team(); + team.setId(teamId); + TeamMembership tm = new TeamMembership(); + tm.setTeam(team); + tm.setRole(role); + return tm; + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkServiceTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkServiceTest.java new file mode 100644 index 0000000000..3d725b98d6 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkServiceTest.java @@ -0,0 +1,102 @@ +package stirling.software.saas.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.saas.accountlink.AccountLinkService.RegisteredInstance; + +/** + * Pure-Mockito unit tests for {@link AccountLinkService}: register returns the plaintext secret + * once but persists only its hash, and revoke is team-scoped + idempotent — a caller can never + * revoke another team's instance. + */ +@ExtendWith(MockitoExtension.class) +class AccountLinkServiceTest { + + @Mock private LinkedInstanceRepository repo; + + private AccountLinkService service; + + @BeforeEach + void setUp() { + service = new AccountLinkService(repo); + } + + @Test + void register_returnsPlaintextSecretOnce_persistsOnlyHash() { + ArgumentCaptor captor = ArgumentCaptor.forClass(LinkedInstance.class); + + RegisteredInstance reg = service.register(42L, 7L, "host-a"); + + verify(repo).save(captor.capture()); + LinkedInstance saved = captor.getValue(); + assertThat(reg.deviceSecret()).isNotBlank(); + assertThat(reg.deviceId()).isEqualTo(saved.getDeviceId()); + assertThat(saved.getDeviceSecretHash()) + .isEqualTo(AccountLinkService.sha256Hex(reg.deviceSecret())) + .isNotEqualTo(reg.deviceSecret()); + assertThat(saved.getTeamId()).isEqualTo(42L); + assertThat(saved.getCreatedByUserId()).isEqualTo(7L); + assertThat(saved.getName()).isEqualTo("host-a"); + } + + @Test + void revoke_owningTeam_setsRevokedAtAndReturnsTrue() { + LinkedInstance inst = instance(11L, 42L, null); + when(repo.findById(11L)).thenReturn(Optional.of(inst)); + + assertThat(service.revoke(42L, 11L)).isTrue(); + assertThat(inst.getRevokedAt()).isNotNull(); + verify(repo).save(inst); + } + + @Test + void revoke_alreadyRevoked_isIdempotentAndDoesNotResave() { + LocalDateTime revoked = LocalDateTime.now().minusDays(1); + LinkedInstance inst = instance(11L, 42L, revoked); + when(repo.findById(11L)).thenReturn(Optional.of(inst)); + + assertThat(service.revoke(42L, 11L)).isTrue(); + assertThat(inst.getRevokedAt()).isEqualTo(revoked); + verify(repo, never()).save(any()); + } + + @Test + void revoke_otherTeamsInstance_returnsFalseAndDoesNotSave() { + LinkedInstance inst = instance(11L, 99L, null); + when(repo.findById(11L)).thenReturn(Optional.of(inst)); + + assertThat(service.revoke(42L, 11L)).isFalse(); + assertThat(inst.getRevokedAt()).isNull(); + verify(repo, never()).save(any()); + } + + @Test + void revoke_unknownInstance_returnsFalse() { + when(repo.findById(404L)).thenReturn(Optional.empty()); + + assertThat(service.revoke(42L, 404L)).isFalse(); + verify(repo, never()).save(any()); + } + + private static LinkedInstance instance(Long id, Long teamId, LocalDateTime revokedAt) { + LinkedInstance i = new LinkedInstance(); + i.setInstanceId(id); + i.setTeamId(teamId); + i.setRevokedAt(revokedAt); + return i; + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilterTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilterTest.java new file mode 100644 index 0000000000..5b42e41a49 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilterTest.java @@ -0,0 +1,162 @@ +package stirling.software.saas.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import jakarta.servlet.ServletException; + +@ExtendWith(MockitoExtension.class) +class DeviceCredentialAuthenticationFilterTest { + + @Mock private LinkedInstanceRepository repo; + + private DeviceCredentialAuthenticationFilter filter; + + @BeforeEach + void setUp() { + filter = new DeviceCredentialAuthenticationFilter(repo); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private static LinkedInstance instanceWithSecret(String secret) { + LinkedInstance i = new LinkedInstance(); + i.setInstanceId(1L); + i.setTeamId(42L); + i.setDeviceId("dev-1"); + i.setDeviceSecretHash(AccountLinkService.sha256Hex(secret)); + return i; + } + + private static MockHttpServletRequest instanceRequest(String deviceId, String secret) { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/instance/whoami"); + if (deviceId != null) { + req.addHeader("X-Device-Id", deviceId); + } + if (secret != null) { + req.addHeader("X-Device-Secret", secret); + } + return req; + } + + @Test + void validCredentialAuthenticatesAsInstanceBoundToTeam() throws ServletException, IOException { + when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")) + .thenReturn(Optional.of(instanceWithSecret("s3cr3t"))); + + filter.doFilter( + instanceRequest("dev-1", "s3cr3t"), + new MockHttpServletResponse(), + new MockFilterChain()); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertInstanceOf(LinkedInstanceAuthenticationToken.class, auth); + LinkedInstanceAuthenticationToken token = (LinkedInstanceAuthenticationToken) auth; + assertEquals(42L, token.getTeamId()); + assertEquals(1L, token.getInstanceId()); + assertEquals( + "ROLE_LINKED_INSTANCE", token.getAuthorities().iterator().next().getAuthority()); + } + + @Test + void successfulAuthStampsLastSeen() throws ServletException, IOException { + LinkedInstance instance = instanceWithSecret("s3cr3t"); + when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.of(instance)); + + filter.doFilter( + instanceRequest("dev-1", "s3cr3t"), + new MockHttpServletResponse(), + new MockFilterChain()); + + // Targeted single-column update (guarded by revoked_at IS NULL), not a full-entity save. + verify(repo).touchLastSeen(eq(1L), any(LocalDateTime.class)); + verify(repo, never()).save(any()); + } + + @Test + void lastSeenWriteFailureDoesNotBreakAuth() throws ServletException, IOException { + LinkedInstance instance = instanceWithSecret("s3cr3t"); + when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.of(instance)); + doThrow(new RuntimeException("transient db")) + .when(repo) + .touchLastSeen(anyLong(), any(LocalDateTime.class)); + + // A liveness-write failure must NOT propagate — auth is already set, so the + // request stays authenticated rather than 500ing. + filter.doFilter( + instanceRequest("dev-1", "s3cr3t"), + new MockHttpServletResponse(), + new MockFilterChain()); + + assertInstanceOf( + LinkedInstanceAuthenticationToken.class, + SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void wrongSecretDoesNotAuthenticate() throws ServletException, IOException { + when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")) + .thenReturn(Optional.of(instanceWithSecret("right-secret"))); + + filter.doFilter( + instanceRequest("dev-1", "wrong-secret"), + new MockHttpServletResponse(), + new MockFilterChain()); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void unknownOrRevokedDeviceDoesNotAuthenticate() throws ServletException, IOException { + when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.empty()); + + filter.doFilter( + instanceRequest("dev-1", "whatever"), + new MockHttpServletResponse(), + new MockFilterChain()); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void nonInstancePathIsSkippedEntirely() throws ServletException, IOException { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/payg/wallet"); + req.addHeader("X-Device-Id", "dev-1"); + req.addHeader("X-Device-Secret", "s3cr3t"); + + filter.doFilter(req, new MockHttpServletResponse(), new MockFilterChain()); + + // Path-scoped: the device credential never even reaches the repo on a non-instance path. + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verifyNoInteractions(repo); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java new file mode 100644 index 0000000000..d221ac8603 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java @@ -0,0 +1,190 @@ +package stirling.software.saas.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import stirling.software.saas.accountlink.InstanceController.EntitlementResponse; +import stirling.software.saas.payg.billing.TeamBillingContext; +import stirling.software.saas.payg.billing.TeamBillingService; +import stirling.software.saas.payg.entitlement.EntitlementService; +import stirling.software.saas.payg.entitlement.EntitlementSnapshot; +import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.payg.model.FeatureSet; + +/** + * Pure-Mockito unit tests for {@link InstanceController} — the device-credential entitlement read. + * The team is resolved from the {@link LinkedInstanceAuthenticationToken} principal, never a path + * or body, and the minimal DTO maps straight off the billing context + entitlement snapshot. + */ +@ExtendWith(MockitoExtension.class) +class InstanceControllerTest { + + @Mock private EntitlementService entitlementService; + @Mock private TeamBillingService billingService; + @Mock private AccountLinkService accountLinkService; + + private InstanceController controller() { + return new InstanceController(entitlementService, billingService, accountLinkService); + } + + @Test + void entitlement_resolvesTeamFromTokenAndMapsSnapshot() { + Authentication token = new LinkedInstanceAuthenticationToken(1L, 42L); + when(billingService.forTeam(42L)).thenReturn(subscribedBilling("sub_42", 120L)); + when(entitlementService.getSnapshot(42L)) + .thenReturn(snapshot(EntitlementState.WARNED, 90L, 1250L)); + + ResponseEntity resp = controller().entitlement(token); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + EntitlementResponse body = resp.getBody(); + assertThat(body).isNotNull(); + assertThat(body.subscribed()).isTrue(); + assertThat(body.freeRemainingUnits()).isEqualTo(120L); + assertThat(body.periodSpendUnits()).isEqualTo(90L); + assertThat(body.periodCapUnits()).isEqualTo(1250L); + // WARNED is still within budget for the gate's purposes → coarse OK. + assertThat(body.state()).isEqualTo("OK"); + } + + @Test + void entitlement_uncapped_returnsNullCapUnits() { + Authentication token = new LinkedInstanceAuthenticationToken(2L, 7L); + when(billingService.forTeam(7L)).thenReturn(freeBilling(500L)); + when(entitlementService.getSnapshot(7L)) + .thenReturn(snapshot(EntitlementState.FULL, 0L, null)); + + ResponseEntity resp = controller().entitlement(token); + + EntitlementResponse body = resp.getBody(); + assertThat(body).isNotNull(); + assertThat(body.subscribed()).isFalse(); + assertThat(body.freeRemainingUnits()).isEqualTo(500L); + assertThat(body.periodCapUnits()).isNull(); + assertThat(body.state()).isEqualTo("OK"); + } + + @Test + void entitlement_degradedMapsToOverLimit() { + // The instance gate parses OK / OVER_LIMIT, never the SaaS FULL/WARNED/DEGRADED enum. + // DEGRADED (automation + AI gated) must reach the wire as OVER_LIMIT. + Authentication token = new LinkedInstanceAuthenticationToken(3L, 8L); + when(billingService.forTeam(8L)).thenReturn(subscribedBilling("sub_8", 0L)); + when(entitlementService.getSnapshot(8L)) + .thenReturn(snapshot(EntitlementState.DEGRADED, 1300L, 1250L)); + + EntitlementResponse body = controller().entitlement(token).getBody(); + + assertThat(body).isNotNull(); + assertThat(body.state()).isEqualTo("OVER_LIMIT"); + } + + @Test + void entitlement_nonInstancePrincipalIsRejected() { + Authentication anon = + new AnonymousAuthenticationToken( + "k", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + ResponseEntity resp = controller().entitlement(anon); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(entitlementService, billingService); + } + + @Test + void revokeSelf_callsServiceWithTokenIdentityAndReturns204() { + Authentication token = new LinkedInstanceAuthenticationToken(11L, 22L); + + ResponseEntity resp = controller().revokeSelf(token); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(accountLinkService).revoke(22L, 11L); + } + + @Test + void revokeSelf_rejectsNonInstancePrincipal() { + Authentication anon = + new AnonymousAuthenticationToken( + "k", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + ResponseEntity resp = controller().revokeSelf(anon); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(accountLinkService); + } + + @Test + void whoami_returnsResolvedInstanceAndTeam() { + Authentication token = new LinkedInstanceAuthenticationToken(5L, 9L); + + ResponseEntity resp = controller().whoami(token); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody().instanceId()).isEqualTo(5L); + assertThat(resp.getBody().teamId()).isEqualTo(9L); + } + + private static TeamBillingContext freeBilling(long freeRemaining) { + LocalDateTime start = LocalDateTime.now().withDayOfMonth(1); + return new TeamBillingContext( + false, + null, + start, + start.plusMonths(1), + freeRemaining, + freeRemaining, + null, + null, + null, + null); + } + + private static TeamBillingContext subscribedBilling(String subId, long freeRemaining) { + LocalDateTime start = LocalDateTime.now().withDayOfMonth(1); + return new TeamBillingContext( + true, + subId, + start, + start.plusMonths(1), + 500L, + freeRemaining, + BigDecimal.valueOf(2), + "usd", + 2500L, + 1250L); + } + + private static EntitlementSnapshot snapshot(EntitlementState state, long spend, Long cap) { + LocalDateTime start = LocalDateTime.now().withDayOfMonth(1); + return new EntitlementSnapshot( + state, + FeatureSet.FULL, + List.of(FeatureGate.OFFSITE_PROCESSING), + spend, + cap, + start, + start.plusMonths(1), + false); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java new file mode 100644 index 0000000000..9266b02336 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java @@ -0,0 +1,189 @@ +package stirling.software.saas.payg.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.payg.api.PaygInvoicesController.InvoiceResponse; +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.stripe.StripeInvoiceDao; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Pure-Mockito unit tests for {@link PaygInvoicesController}. Confirms team is resolved from the + * authenticated principal (never request), and the empty-list degrade paths (no team, no Stripe + * customer, no rows) all return 200 + [] rather than 4xx/5xx. + */ +@ExtendWith(MockitoExtension.class) +class PaygInvoicesControllerTest { + + @Mock private StripeInvoiceDao invoiceDao; + @Mock private PaygTeamExtensionsRepository extRepo; + @Mock private TeamMembershipRepository memberRepo; + @Mock private UserRepository userRepository; + + private PaygInvoicesController controller; + private Authentication auth; + + @BeforeEach + void setUp() { + controller = new PaygInvoicesController(invoiceDao, extRepo, memberRepo, userRepository); + auth = + new AnonymousAuthenticationToken( + "k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } + + @Test + void list_unauthenticated_returns401() { + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenThrow(new SecurityException("not authenticated")); + + ResponseEntity> resp = controller.list(null, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(invoiceDao, extRepo, memberRepo); + } + } + + @Test + void list_noTeam_returnsEmpty() { + User user = mockUser(42L); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of()); + + ResponseEntity> resp = controller.list(null, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).isEmpty(); + verifyNoInteractions(invoiceDao, extRepo); + } + } + + @Test + void list_noStripeCustomer_returnsEmpty() { + User user = mockUser(42L); + TeamMembership tm = mockMembership(7L); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm)); + when(extRepo.findById(7L)).thenReturn(Optional.empty()); + + ResponseEntity> resp = controller.list(null, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).isEmpty(); + verifyNoInteractions(invoiceDao); + } + } + + @Test + void list_mapsRowsAndClampsLimit() { + User user = mockUser(42L); + TeamMembership tm = mockMembership(7L); + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(7L); + ext.setStripeCustomerId("cus_abc"); + + StripeInvoiceDao.InvoiceRow row = + new StripeInvoiceDao.InvoiceRow( + "in_1", + "STIR-0001", + "paid", + 2500L, + "usd", + LocalDateTime.of(2026, 6, 1, 10, 0), + LocalDateTime.of(2026, 5, 1, 0, 0), + LocalDateTime.of(2026, 5, 31, 23, 59), + "https://stripe/invoice/1", + "https://stripe/invoice/1.pdf", + "Stirling Processor Plan", + 50000L); + + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm)); + when(extRepo.findById(7L)).thenReturn(Optional.of(ext)); + // 1000 should clamp to MAX_LIMIT (100) inside the controller. + when(invoiceDao.findRecentByCustomer(eq("cus_abc"), eq(100))).thenReturn(List.of(row)); + + ResponseEntity> resp = controller.list(1000, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).hasSize(1); + InvoiceResponse body = resp.getBody().get(0); + assertThat(body.id()).isEqualTo("in_1"); + assertThat(body.number()).isEqualTo("STIR-0001"); + assertThat(body.status()).isEqualTo("paid"); + assertThat(body.totalMinor()).isEqualTo(2500L); + assertThat(body.currency()).isEqualTo("usd"); + assertThat(body.hostedInvoiceUrl()).isEqualTo("https://stripe/invoice/1"); + assertThat(body.description()).isEqualTo("Stirling Processor Plan"); + assertThat(body.pdfsProcessed()).isEqualTo(50000L); + } + } + + @Test + void list_emptyDaoResult_returnsEmpty() { + User user = mockUser(42L); + TeamMembership tm = mockMembership(7L); + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(7L); + ext.setStripeCustomerId("cus_xyz"); + + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm)); + when(extRepo.findById(7L)).thenReturn(Optional.of(ext)); + when(invoiceDao.findRecentByCustomer(anyString(), anyInt())).thenReturn(List.of()); + + ResponseEntity> resp = controller.list(null, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).isEmpty(); + } + } + + private static User mockUser(long id) { + User u = new User(); + u.setId(id); + return u; + } + + private static TeamMembership mockMembership(long teamId) { + Team team = new Team(); + team.setId(teamId); + TeamMembership tm = new TeamMembership(); + tm.setTeam(team); + return tm; + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java new file mode 100644 index 0000000000..c4c0780b86 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java @@ -0,0 +1,184 @@ +package stirling.software.saas.payg.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.payg.api.PaygPaymentMethodController.PaymentMethodResponse; +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.stripe.StripePaymentMethodDao; +import stirling.software.saas.payg.stripe.StripePaymentMethodDao.CardSummary; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.security.EnhancedJwtAuthenticationToken; + +/** + * Pure-Mockito unit tests for {@link PaygPaymentMethodController}: the auth/team-resolution and + * defensive-degrade branches, plus the happy path mapping a DAO {@link CardSummary} to the trimmed + * response. + */ +@ExtendWith(MockitoExtension.class) +class PaygPaymentMethodControllerTest { + + @Mock private StripePaymentMethodDao paymentMethodDao; + @Mock private PaygTeamExtensionsRepository extRepo; + @Mock private TeamMembershipRepository memberRepo; + @Mock private UserRepository userRepository; + + private PaygPaymentMethodController controller; + + @BeforeEach + void setUp() { + controller = + new PaygPaymentMethodController( + paymentMethodDao, extRepo, memberRepo, userRepository); + } + + @Test + void anonymousIsRejected() { + Authentication anon = + new AnonymousAuthenticationToken( + "k", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + ResponseEntity resp = controller.get(anon); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(paymentMethodDao, extRepo, memberRepo); + } + + @Test + void noTeam_returnsAbsent() { + User user = userWithId(5L, UUID.randomUUID()); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(5L)).thenReturn(List.of()); + + ResponseEntity resp = controller.get(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).isNotNull(); + assertThat(resp.getBody().present()).isFalse(); + verifyNoInteractions(paymentMethodDao); + } + + @Test + void noStripeCustomer_returnsAbsent() { + User user = userWithId(6L, UUID.randomUUID()); + Team team = teamWithId(60L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(6L)) + .thenReturn(List.of(membership(team, user, TeamRole.LEADER))); + PaygTeamExtensions ext = mock(PaygTeamExtensions.class); + when(ext.getStripeCustomerId()).thenReturn(null); + when(extRepo.findById(60L)).thenReturn(Optional.of(ext)); + + ResponseEntity resp = controller.get(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getBody()).isNotNull(); + assertThat(resp.getBody().present()).isFalse(); + verifyNoInteractions(paymentMethodDao); + } + + @Test + void cardOnFile_returnsPresentWithFields() { + User user = userWithId(7L, UUID.randomUUID()); + Team team = teamWithId(70L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(7L)) + .thenReturn(List.of(membership(team, user, TeamRole.LEADER))); + PaygTeamExtensions ext = mock(PaygTeamExtensions.class); + when(ext.getStripeCustomerId()).thenReturn("cus_123"); + when(extRepo.findById(70L)).thenReturn(Optional.of(ext)); + when(paymentMethodDao.findDefaultCard("cus_123")) + .thenReturn(Optional.of(new CardSummary("visa", "4242", 8, 2027))); + + ResponseEntity resp = controller.get(jwtAuth(user.getSupabaseId())); + + PaymentMethodResponse body = resp.getBody(); + assertThat(body).isNotNull(); + assertThat(body.present()).isTrue(); + assertThat(body.brand()).isEqualTo("visa"); + assertThat(body.last4()).isEqualTo("4242"); + assertThat(body.expMonth()).isEqualTo(8); + assertThat(body.expYear()).isEqualTo(2027); + } + + @Test + void mirrorMissingCard_returnsAbsent() { + User user = userWithId(8L, UUID.randomUUID()); + Team team = teamWithId(80L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(8L)) + .thenReturn(List.of(membership(team, user, TeamRole.LEADER))); + PaygTeamExtensions ext = mock(PaygTeamExtensions.class); + when(ext.getStripeCustomerId()).thenReturn("cus_456"); + when(extRepo.findById(80L)).thenReturn(Optional.of(ext)); + when(paymentMethodDao.findDefaultCard("cus_456")).thenReturn(Optional.empty()); + + ResponseEntity resp = controller.get(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getBody()).isNotNull(); + assertThat(resp.getBody().present()).isFalse(); + } + + // ----------------------------------------------------------------------------------------- + // Fixtures (mirroring PaygWalletControllerTest) + // ----------------------------------------------------------------------------------------- + + private static User userWithId(Long id, UUID supabaseId) { + User u = new User(); + u.setId(id); + u.setSupabaseId(supabaseId); + return u; + } + + private static Team teamWithId(Long id) { + Team t = new Team(); + t.setId(id); + t.setName("t-" + id); + return t; + } + + private static TeamMembership membership(Team team, User user, TeamRole role) { + TeamMembership m = new TeamMembership(); + m.setTeam(team); + m.setUser(user); + m.setRole(role); + return m; + } + + private static Authentication jwtAuth(UUID supabaseId) { + Jwt jwt = + Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", supabaseId.toString()) + .claim("email", "user@example.com") + .build(); + return new EnhancedJwtAuthenticationToken( + jwt, List.of(), "user@example.com", supabaseId.toString()); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java b/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java index 2118f85794..39c244f762 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java @@ -29,10 +29,22 @@ class CapEvaluatorTest { } @Test - void zeroCap_treatedAsUnlimitedForSafety() { - // Defensive: a zero cap would divide-by-zero. The guard treats it as null (FULL). + void zeroCap_blocksMeteredWork() { + // An explicit $0 cap buys zero paid documents → metered work is blocked + // (DEGRADED/MINIMAL); only the free grant + manual tools run. (Uncapped is the + // separate capUnits==null case, covered by nullCap_returnsFullStateAndFullGates.) Evaluation e = CapEvaluator.evaluate(50L, 0L, 80, 100, FeatureSet.MINIMAL); - assertThat(e.state()).isEqualTo(EntitlementState.FULL); + assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED); + assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL); + assertThat(e.enabledGates()) + .containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE); + } + + @Test + void zeroCap_blocksEvenAtZeroSpend() { + // A $0 cap blocks from the first metered op — not gated on spend. + Evaluation e = CapEvaluator.evaluate(0L, 0L, 80, 100, FeatureSet.MINIMAL); + assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED); } @Test diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java index 1aa9e783b6..54b6e56391 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java @@ -243,6 +243,7 @@ class SupabaseSecurityConfigMoreTest { @Test @DisplayName("builds and returns the SecurityFilterChain from http.build()") + @SuppressWarnings("unchecked") void buildsFilterChain() throws Exception { HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); // http.build() returns DefaultSecurityFilterChain, so stub with that concrete type. @@ -250,8 +251,16 @@ class SupabaseSecurityConfigMoreTest { mock(org.springframework.security.web.DefaultSecurityFilterChain.class); when(http.build()).thenReturn(built); + // Device-credential filter is wired via an ObjectProvider; getIfAvailable() returns + // null here, so the optional filter is simply not added (fine for a build-only check). + org.springframework.beans.factory.ObjectProvider< + stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter> + deviceFilterProvider = + mock(org.springframework.beans.factory.ObjectProvider.class); + SecurityFilterChain result = - config(new ApplicationProperties()).saasSecurityFilterChain(http, jwtDecoder); + config(new ApplicationProperties()) + .saasSecurityFilterChain(http, jwtDecoder, deviceFilterProvider); assertThat(result).isSameAs(built); } diff --git a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java index 31c4f4be5c..ac2a4cecba 100644 --- a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java @@ -23,6 +23,8 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import stirling.software.common.model.enumeration.InvitationStatus; import stirling.software.common.model.enumeration.Role; @@ -32,6 +34,7 @@ import stirling.software.proprietary.security.database.repository.UserRepository import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.saas.accountlink.LinkedInstanceRepository; import stirling.software.saas.billing.repository.BillingSubscriptionRepository; import stirling.software.saas.config.SupabaseConfigurationProperties; import stirling.software.saas.model.TeamInvitation; @@ -62,6 +65,7 @@ class SaasTeamServiceTest { @Mock private UserRoleService userRoleService; @Mock private SaasTeamExtensionService saasTeamExtensionService; @Mock private SaasTeamExtensionsRepository saasTeamExtensionsRepository; + @Mock private LinkedInstanceRepository linkedInstanceRepository; @Mock private stirling.software.proprietary.security.service.UserService userService; @InjectMocks private SaasTeamService service; @@ -1383,4 +1387,92 @@ class SaasTeamServiceTest { return saved; }); } + + /** + * acceptInvitation's orphan guard against linked self-hosted instances (combined-billing "Mode + * A"). The guard ({@code assertCanLeaveCurrentTeamsToJoinAnother}) is private; it's exercised + * through its only caller up to the point where a team with active linked instances must block + * the move. LENIENT because the pass-through case stubs the full leave/join path while the + * blocking case short-circuits before reaching all of it. + */ + @Nested + @DisplayName("acceptInvitation - linked self-hosted instance orphan guard") + @MockitoSettings(strictness = Strictness.LENIENT) + class AcceptInvitationLinkedInstanceGuard { + + private static final long USER_ID = 7L; + private static final long OLD_TEAM_ID = 100L; + private static final long NEW_TEAM_ID = 200L; + private static final String TOKEN = "tok-1"; + private static final String EMAIL = "joiner@example.com"; + + @Test + @DisplayName("blocks accept when the current team has active linked instances") + void blocksWhenCurrentTeamHasActiveLinkedInstances() { + User joiner = user(USER_ID, EMAIL, EMAIL); + Team oldTeam = team(OLD_TEAM_ID, "old-team"); + Team newTeam = team(NEW_TEAM_ID, "new-team"); + TeamInvitation invitation = pendingInvitation(newTeam, joiner); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(joiner)); + when(invitationRepository.findByInvitationToken(TOKEN)) + .thenReturn(Optional.of(invitation)); + when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); + when(membershipRepository.findByUserId(USER_ID)) + .thenReturn(List.of(membership(oldTeam, joiner, TeamRole.LEADER))); + when(linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(OLD_TEAM_ID)) + .thenReturn(1L); + + assertThatThrownBy(() -> service.acceptInvitation(TOKEN, joiner)) + .isInstanceOf(IllegalStateException.class) + .hasMessage( + "Revoke linked self-hosted instances on this team before joining another" + + " team."); + + // Guard fires before any team mutation. + verify(membershipRepository, never()).delete(any()); + verify(userRepository, never()).updateUserTeamId(anyLong(), anyLong()); + } + + @Test + @DisplayName("lets accept through when the current team has no linked instances") + void passesGuardWhenNoLinkedInstances() { + User joiner = user(USER_ID, EMAIL, EMAIL); + Team oldTeam = team(OLD_TEAM_ID, "old-team"); + Team newTeam = team(NEW_TEAM_ID, "new-team"); + TeamInvitation invitation = pendingInvitation(newTeam, joiner); + TeamMembership oldMembership = membership(oldTeam, joiner, TeamRole.LEADER); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(joiner)); + when(invitationRepository.findByInvitationToken(TOKEN)) + .thenReturn(Optional.of(invitation)); + when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); + when(membershipRepository.findByUserId(USER_ID)).thenReturn(List.of(oldMembership)); + when(linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(OLD_TEAM_ID)) + .thenReturn(0L); + // Personal old team → guard skips the last-leader check and leave/join proceeds. + when(saasTeamExtensionService.isPersonal(oldTeam)).thenReturn(true); + when(membershipRepository.countByTeamId(OLD_TEAM_ID)).thenReturn(0L); + when(saasTeamExtensionsRepository.incrementSeatsUsed(NEW_TEAM_ID)).thenReturn(1); + + service.acceptInvitation(TOKEN, joiner); + + // Guard let the move through: the old membership was left and the user re-pointed. + verify(membershipRepository).delete(oldMembership); + verify(userRepository).updateUserTeamId(USER_ID, NEW_TEAM_ID); + verify(invitationRepository).save(invitation); + assertThat(invitation.getStatus()).isEqualTo(InvitationStatus.ACCEPTED); + } + + private TeamInvitation pendingInvitation(Team team, User invitee) { + TeamInvitation inv = new TeamInvitation(); + inv.setTeam(team); + inv.setInviter(invitee); + inv.setInviteeEmail(invitee.getEmail()); + inv.setStatus(InvitationStatus.PENDING); + inv.setInvitationToken(TOKEN); + inv.setExpiresAt(LocalDateTime.now().plusDays(1)); + return inv; + } + } } diff --git a/frontend/.gitignore b/frontend/.gitignore index 6d958ac439..3a5de11c57 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -24,10 +24,10 @@ /editor/.env.local /editor/.env.*.local -# Root .gitignore ignores all .env* - whitelist our committed ones here -!.env -!.env.desktop -!.env.saas +# Root .gitignore ignores all .env* - whitelist only our committed ones, anchored +# to their app so a stray top-level frontend/.env stays ignored (Storybook's SaaS +# mock env is injected via .storybook/main.ts, not a file). +!/portal/.env !/editor/.env !/editor/.env.desktop !/editor/.env.saas diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 0797a367a3..c016a5c37d 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -51,6 +51,16 @@ const config: StorybookConfig = { ], }), ); + // Point apiClient.saas at a mock origin so the SaaS-backed billing stories + // (SubscribedPlanView, PaymentMethodCard, InvoicesList) resolve a base URL and + // their MSW handlers (which match "*/api/v1/payg/...") can intercept. The host + // never receives a real request — MSW answers first. Injected here, next to the + // MSW setup, rather than via a frontend/.env so no stray env file can leak into a + // real portal/editor build (those load env from their own roots). + config.define = { + ...(config.define ?? {}), + "import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"), + }; return config; }, }; diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index c94b169737..1f9c935fe6 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -14,10 +14,12 @@ import { MantineProvider } from "@mantine/core"; void React; import { TierProvider, type Tier } from "@portal/contexts/TierContext"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; import { ThemeProvider } from "@portal/contexts/ThemeContext"; import { UIProvider } from "@portal/contexts/UIContext"; import { mantineTheme } from "@portal/theme/mantineTheme"; import { handlers } from "@portal/mocks/handlers"; +import { configureSupabase } from "@shared/auth/supabase/supabaseClient"; import "@mantine/core/styles.css"; import "@shared/tokens/tokens.css"; @@ -26,6 +28,27 @@ import "@shared/tokens/base.css"; // Start MSW once. Storybook runs in a browser so this uses the service worker. initialize({ onUnhandledRequest: "bypass" }, handlers); +// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment +// method, wallet) clear the session check and reach the MSW handlers instead of +// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally +// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only +// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via +// .storybook/main.ts's viteFinal define, not a frontend/.env file. +const saasStub = configureSupabase({ + url: "http://saas.mock", + key: "storybook-anon-key", + authOptions: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, +}); +saasStub.auth.getSession = async () => + ({ + data: { session: { access_token: "storybook-fake-jwt" } }, + error: null, + }) as Awaited>; + /** * Bridge between Storybook's `tier` global toolbar and the actual TierProvider. * Without this the toolbar would just change a label; with it, every story @@ -67,6 +90,8 @@ function ThemeWatcher() { const withProviders: Decorator = (Story, context) => { const tier = (context.globals.tier as Tier) ?? "pro"; + const linkState = + (context.globals.linkState as LinkState) ?? "linked-subscribed"; // withThemeByDataAttribute exposes the toolbar theme as the `theme` global. // Bind Mantine's color scheme to it so Mantine chrome (inputs, focus rings, // default surfaces) follows the dark toggle alongside the SUI CSS variables. @@ -78,12 +103,16 @@ const withProviders: Decorator = (Story, context) => { - - - - - - + {/* LinkProvider must wrap TierProvider: TierContext derives its tier + from useLink() (matches App.tsx's nesting). */} + + + + + + + + @@ -128,6 +157,20 @@ const preview: Preview = { dynamicTitle: true, }, }, + linkState: { + name: "Link", + description: "Account-link state — drives useLink() everywhere", + defaultValue: "linked-subscribed", + toolbar: { + icon: "link", + items: [ + { value: "unlinked", title: "Unlinked" }, + { value: "linked-free", title: "Linked · Free" }, + { value: "linked-subscribed", title: "Linked · PAYG" }, + ], + dynamicTitle: true, + }, + }, }, decorators: [ withProviders, diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index dba8fa41cc..67779cc07d 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -8003,6 +8003,8 @@ zoomIn = "Zoom In" zoomOut = "Zoom Out" [viewer.attachments] +addAttachment = "Add attachment" +close = "Close attachments" empty = "No attachments in this document" loading = "Loading attachments..." noDocument = "Open a PDF to view its attachments." @@ -8016,6 +8018,7 @@ addComment = "Add comment" addCommentPlaceholder = "Add comment..." addLink = "Add link" addReplyPlaceholder = "Add reply..." +close = "Close comments" deleteAnnotationAndComment = "Delete annotation & comment" deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything." deleteTitle = "Remove annotation from comments?" @@ -8026,6 +8029,7 @@ moreActions = "More actions" nComments_one = "{{count}} comment" nComments_other = "{{count}} comments" pageLabel = "Page {{page}}" +placingHint = "Click a page to place… (cancel)" removeCommentOnly = "Remove comment only" saveReply = "Save reply" title = "Comments" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 5440077934..41a2f220e5 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -5914,12 +5914,40 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" +[policies.enforcement] +applying = "Applying {{names}}" +applyingProgress = "Applying {{names}} ({{done}} of {{total}})" +exportFailureBody = "Security policies couldn't be applied. Files were exported as-is." +exportFailureTitle = "Exported without enforcement" +failureBody = "{{failures}} of {{total}} file(s) couldn't be processed and were exported as-is." +failureTitle = "Exported without full enforcement" +printPolicyAppliedBody = "This PDF was updated to meet a policy. Review the changes, then print again." +printPolicyAppliedTitle = "Policy applied before printing" +queued = "+{{count}} queued" +successTitle = "{{names}} applied" +summaryMore = "{{first}}, {{second}} and {{more}} more" +summaryTwo = "{{first}} and {{second}}" + +[policies.enforcement.triggerVerb] +convert = "Enforcing before convert" +default = "Enforcing" +export = "Enforcing before export" +input = "Enforcing on import" +print = "Enforcing before print" + [policies.fields] selectedCount = "{{count}} selected" [policies.pii] +account = "Account numbers (labelled)" +card = "Credit / debit cards" +email = "Email addresses" fieldLabel = "PII to redact" +iban = "IBANs" +phone = "Phone numbers" placeholder = "Select PII types" +routing = "US routing numbers (ABA)" +ssn = "Social Security numbers" [policies.sidebar] activeCount = "{{count}} active" @@ -5942,6 +5970,11 @@ setup = "Set up" enableAriaLabel = "Enable {{tool}}" infoAriaLabel = "What does {{tool}} do?" +[policies.toolConfig.info] +redact = "Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document." +sanitize = "Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it." +watermark = "Stamps a visible mark (e.g. \"Confidential\") across every page." + [policies.wizard] allDocTypesDescription = "Enable the Classification policy to filter by document type." allDocTypesTitle = "All document types" @@ -8027,6 +8060,8 @@ zoomIn = "Zoom In" zoomOut = "Zoom Out" [viewer.attachments] +addAttachment = "Add attachment" +close = "Close attachments" empty = "No attachments in this document" loading = "Loading attachments..." noDocument = "Open a PDF to view its attachments." @@ -8044,6 +8079,7 @@ cancelClearAll = "Cancel" clearAll = "Clear all comments" clearAllDescription = "This removes comments and replies from the sidebar while keeping any attached annotations in the document." clearAllTitle = "Clear all comments?" +close = "Close comments" deleteAnnotationAndComment = "Delete annotation & comment" deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything." deleteTitle = "Remove annotation from comments?" @@ -8054,6 +8090,7 @@ moreActions = "More actions" nComments_one = "{{count}} comment" nComments_other = "{{count}} comments" pageLabel = "Page {{page}}" +placingHint = "Click a page to place… (cancel)" removeCommentOnly = "Remove comment only" saveReply = "Save reply" title = "Comments" diff --git a/frontend/editor/src-tauri/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json index abbdff32f2..f5ca893db9 100644 --- a/frontend/editor/src-tauri/tauri.conf.json +++ b/frontend/editor/src-tauri/tauri.conf.json @@ -1,6 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", - "productName": "Stirling-PDF", + "productName": "Stirling PDF", + "mainBinaryName": "Stirling-PDF", "version": "2.13.2", "identifier": "stirling.pdf.dev", "build": { @@ -12,7 +13,7 @@ "app": { "windows": [ { - "title": "Stirling-PDF", + "title": "Stirling PDF", "width": 1280, "height": 800, "resizable": true, @@ -63,6 +64,9 @@ "digestAlgorithm": "sha256", "timestampUrl": "http://timestamp.digicert.com", "wix": { + "upgradeCode": "3305fba9-7e5e-5c09-bc71-eca0a65f4fee", + "bannerPath": "windows/wix/banner.bmp", + "dialogImagePath": "windows/wix/dialog.bmp", "fragmentPaths": ["windows/wix/provisioning.wxs"], "componentGroupRefs": ["ProvisioningComponentGroup"] } diff --git a/frontend/editor/src-tauri/windows/wix/banner.bmp b/frontend/editor/src-tauri/windows/wix/banner.bmp new file mode 100644 index 0000000000..8c120d1fe2 Binary files /dev/null and b/frontend/editor/src-tauri/windows/wix/banner.bmp differ diff --git a/frontend/editor/src-tauri/windows/wix/dialog.bmp b/frontend/editor/src-tauri/windows/wix/dialog.bmp new file mode 100644 index 0000000000..680241e9e7 Binary files /dev/null and b/frontend/editor/src-tauri/windows/wix/dialog.bmp differ diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx index bee9d0474d..f26d40f743 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx @@ -1,250 +1,59 @@ /** - * Reusable monthly spend-cap control. - * - * One inline row — preset chips, a custom-entry pill that matches the presets, - * a "No cap" chip, and (optionally) a Save button — over a live "≈ N PDFs / - * month" estimate. Extracted from the subscribed plan-page cap editor so the - * exact same control drives the upgrade checkout flow. - * - *

    Currency-agnostic by design

    - * - * The control never decides a currency. It takes {@code pricePerDocMinor} + - * {@code currency} and renders whatever it's handed: the subscribed plan page - * passes the team's real Stripe-subscription rate/currency; the unsubscribed - * checkout flow passes a USD rate (Stripe hasn't assigned the team a currency - * yet) plus a {@code note} explaining the cap is editable later. When no rate - * is supplied the estimate simply hides. - * - *

    Controlled

    - * - * Fully controlled via {@code capUsd} ({@code null} = no cap, {@code 0} = a - * real $0 cap that keeps everything free) + {@code onChange}. The parent owns - * the working value. When {@code onSave} is provided the control renders the - * inline Save button and computes "dirty" against {@code savedCapUsd}. + * Editor cloud adapter over the shared {@code @shared/billing} spend-cap control: + * supplies the i18n copy (the shared control is copy-agnostic) and the editor's + * {@code scc-*} styling. The public API (controlled {@code capUsd}/{@code + * onChange}, optional {@code onSave}/{@code saveLabel}, {@code note}) is + * unchanged, so the plan-page cap editor and the upgrade-checkout flow keep + * consuming it as before. */ -import React, { useState } from "react"; -import { Button } from "@mantine/core"; -import DescriptionIcon from "@mui/icons-material/DescriptionOutlined"; -import LocalIcon from "@app/components/shared/LocalIcon"; +import React from "react"; import { useTranslation } from "react-i18next"; +import { + DEFAULT_CAP_PRESETS, + SpendCapControl as SharedSpendCapControl, +} from "@shared/billing"; // eslint-disable-next-line no-restricted-imports import "./SpendCapControl.css"; -// Quick amounts offered everywhere — recognition over recall. -export const DEFAULT_CAP_PRESETS = [500, 1000, 2500, 5000] as const; +export { DEFAULT_CAP_PRESETS }; export interface SpendCapControlProps { - /** Current cap in major currency units; {@code null} = no cap. Controlled. */ capUsd: number | null; - /** Working-value setter. {@code null} signals no-cap. */ onChange: (capUsd: number | null) => void; - /** Per-document rate in minor units; null/0 hides the estimate. May be fractional. */ pricePerDocMinor?: number | null; - /** Lower-case ISO currency of the rate; pairs with {@link #pricePerDocMinor}. */ currency?: string | null; - /** Quick-amount presets (major units). Defaults to {@link DEFAULT_CAP_PRESETS}. */ presets?: readonly number[]; - /** - * When provided, the control renders an inline Save button. Receives whole - * major units, or {@code null} for no-cap. - */ onSave?: (capUsd: number | null) => Promise | void; - /** Label for the Save button. */ saveLabel?: string; - /** - * The persisted value to diff against for the dirty check. Same encoding as - * {@link #capUsd} ({@code null} = persisted no-cap). Only used with - * {@link #onSave}. - */ savedCapUsd?: number | null; - /** Quiet helper line under the estimate (e.g. the USD / editable-later note). */ note?: React.ReactNode; } -/** Format minor units of an ISO currency ("$2.24", "£0.40"). */ -function formatMinor( - minor: number, - currency: string | null | undefined, -): string { - const code = (currency ?? "usd").toUpperCase(); - try { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency: code, - // Per-doc rates are often sub-cent (e.g. $0.02 → 2 minor, but a half-cent - // rate is 0.5). Allow up to 3 fraction digits so they don't round to $0. - maximumFractionDigits: 3, - }).format(minor / 100); - } catch { - return `${(minor / 100).toFixed(2)} ${code}`; - } -} - -/** Currency symbol for compact inline use; falls back to the ISO code. */ -function currencySymbol(currency: string | null | undefined): string { - switch ((currency ?? "").toLowerCase()) { - case "usd": - case "": - return "$"; - case "eur": - return "€"; - case "gbp": - return "£"; - default: - return currency!.toUpperCase() + " "; - } -} - const SpendCapControl: React.FC = ({ - capUsd, - onChange, - pricePerDocMinor, - currency, - presets = DEFAULT_CAP_PRESETS, - onSave, saveLabel, - savedCapUsd, - note, + ...rest }) => { const { t } = useTranslation(); - const [saving, setSaving] = useState(false); - - const sym = currencySymbol(currency); - const isNoCap = capUsd === null; - const presetSelected = capUsd != null && presets.includes(capUsd); - // Custom is "active" when a cap is set that isn't one of the presets — i.e. - // the value came from the custom pill. - const customActive = capUsd != null && !presets.includes(capUsd); - - // Local mirror of the custom field's text so partial/empty entry doesn't get - // clobbered by the controlled value. Seeded from a non-preset incoming cap. - const [customText, setCustomText] = useState( - customActive ? String(capUsd) : "", - ); - - // Mirror of the backend's docCapForMoney: floor(capMinor / rate). The - // one-time free grant is a separate lifetime pool and is NOT added here — - // this is the paid PDFs the monthly cap buys. - const rate = - pricePerDocMinor != null && pricePerDocMinor > 0 ? pricePerDocMinor : null; - const previewDocs = - capUsd != null && rate != null ? Math.floor((capUsd * 100) / rate) : null; - - const dirty = onSave != null && capUsd !== (savedCapUsd ?? null); - - const selectPreset = (preset: number) => { - setCustomText(""); - onChange(preset); - }; - const selectNoCap = () => { - setCustomText(""); - onChange(null); - }; - const onCustomInput = (raw: string) => { - // Digits only; an empty field reads as "no custom value yet" → 0 so the - // estimate still renders sensibly without flipping to no-cap. - const cleaned = raw.replace(/[^0-9]/g, ""); - setCustomText(cleaned); - const v = cleaned === "" ? 0 : parseInt(cleaned, 10); - onChange(Number.isNaN(v) ? 0 : v); - }; - - const handleSave = async () => { - if (!onSave) return; - setSaving(true); - try { - await onSave(isNoCap ? null : Math.round(capUsd ?? 0)); - } finally { - setSaving(false); - } - }; - return ( -
    -
    - {presets.map((preset) => ( - - ))} - - {/* Custom-entry pill — dashed until it carries a value, then it fills - like a selected chip. */} - - - - - {onSave && ( - - )} -
    - - {previewDocs != null && ( -
    - -
    -
    - {t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", { - docs: previewDocs.toLocaleString(), - })} -
    -
    - {t("payg.cap.docsRate", "at {{rate}} / PDF", { - rate: formatMinor(pricePerDocMinor ?? 0, currency), - })} -
    -
    -
    - )} - - {isNoCap && ( -
    - {t( - "payg.cap.noCapDesc", - "Usage is billed without an upper limit. You can re-enable a cap at any time.", - )} -
    - )} - - {note &&
    {note}
    } -
    + + t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", { + docs, + }), + docsRate: (rate) => + t("payg.cap.docsRate", "at {{rate}} / PDF", { rate }), + noCapDesc: t( + "payg.cap.noCapDesc", + "Usage is billed without an upper limit. You can re-enable a cap at any time.", + ), + }} + /> ); }; diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx index f1012bd2dd..308fc97d32 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx @@ -7,36 +7,10 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useWallet, type Wallet } from "@app/hooks/useWallet"; +import { currencySymbol, MeterBar, meterState } from "@shared/billing"; import "@app/components/shared/config/configSections/Payg.css"; import "@app/components/shared/config/configSections/PaygFree.css"; -export type MeterState = "FULL" | "WARNED" | "DEGRADED"; - -/** Warn/degrade band for a usage meter (mirrors the BE thresholds). */ -export function meterState( - used: number, - limit: number, -): { state: MeterState; pct: number } { - const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100; - const state: MeterState = - pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL"; - return { state, pct }; -} - -/** Currency symbol for compact inline use; falls back to the ISO code. */ -function currencySymbol(currency: string | null): string { - switch ((currency ?? "").toLowerCase()) { - case "usd": - return "$"; - case "eur": - return "€"; - case "gbp": - return "£"; - default: - return currency ? currency.toUpperCase() + " " : "$"; - } -} - // ─── One-time free grant meter ────────────────────────────────────────────── export interface FreeSnapshot { @@ -78,38 +52,20 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { : t("payg.free.state.plentyLeft", "Plenty left"); return ( -
    -
    -
    - - {snap.billableUsed.toLocaleString()} - - - {t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", { - limit: snap.billableLimit.toLocaleString(), - })} - -
    - - - {stateLabel} - -
    - -
    -
    -
    - -
    + {t("payg.free.hero.metaCategories", "Automation · AI · API requests")} -
    -
    + } + /> ); } @@ -158,45 +114,28 @@ export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) { const symbol = currencySymbol(snap.currency); return ( -
    -
    -
    - - {symbol} - {snap.spent.toLocaleString()} + + + {t( + "payg.spendCapMeter.metaCategories", + "Automation · AI · API spend", + )} - - {t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", { - amount: `${symbol}${snap.cap.toLocaleString()}`, - })} + • + + {t("payg.spendCapMeter.resets", "Resets each billing period")} -
    - - - {stateLabel} - -
    - -
    -
    -
    - -
    - - {t( - "payg.spendCapMeter.metaCategories", - "Automation · AI · API spend", - )} - - • - - {t("payg.spendCapMeter.resets", "Resets each billing period")} - -
    -
    + + } + /> ); } diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts index 0222cc77c0..5a1b1d21cb 100644 --- a/frontend/editor/src/cloud/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -50,123 +50,26 @@ import apiClient from "@app/services/apiClient"; import { createPortalSession } from "@app/services/billing"; import { openExternal } from "@app/platform/openExternal"; import { getWalletDevPreview } from "@app/hooks/walletDevPreview"; +import type { + Wallet, + WalletStatus, + WalletRole, + WalletMember, + WalletCategoryBreakdown, + WalletActivityRow, +} from "@shared/billing"; // ─── Public types ─────────────────────────────────────────────────────── - -export type WalletStatus = "free" | "subscribed"; -export type WalletRole = "leader" | "member"; - -/** - * A single team member's billing-relevant info — name + email for the avatar - * row, {@code spendUnits} for their per-member usage display. Mirrors a row of - * the backend's {@code members} array on {@code WalletSnapshot} (joined with - * {@code team_memberships}). - */ -export interface WalletMember { - /** Supabase user id of the member. */ - userId: string; - name: string; - email: string; - /** Member's current-period billable spend. */ - spendUnits: number; -} - -/** - * Per-category breakdown of current-period spend in billable units. The - * categories mirror the {@code FeatureGate} buckets the backend tracks: - * server-side tool calls ({@code api}), AI-backed tools ({@code ai}), and - * pipeline / automation runs ({@code automation}). Numbers sum to {@code - * billableUsed} (modulo rounding in mock data). - */ -export interface WalletCategoryBreakdown { - api: number; - ai: number; - automation: number; -} - -/** Mirror of the backend's {@code WalletSnapshot} record (the JSON returned from {@code GET /api/v1/payg/wallet}). */ -export interface Wallet { - /** - * The caller's primary team_id. Needed when invoking Supabase edge functions - * (create-checkout-session, etc.) that run outside Spring Security and have - * no other way to resolve the caller's team. May be null on the synthetic - * empty snapshot returned to anonymous / team-less callers. - */ - teamId: number | null; - status: WalletStatus; - role: WalletRole; - /** - * ISO yyyy-mm-dd. The Stripe subscription's current period when subscribed; - * the calendar month for free teams. - */ - billingPeriodStart: string; - billingPeriodEnd: string; - /** - * For a free team: the one-time free documents used so far ({@code - * freeAllowance − freeRemaining}). For a subscribed team: documents - * processed this month across automation + AI + API. - */ - billableUsed: number; - /** - * The team's document ceiling for the matching window: the one-time free - * grant ({@code freeAllowance}) for free teams; the monthly paid-doc cap - * {@code floor(cap / perDocRate)} for capped subscribed teams; null when - * subscribed with no cap (uncapped). - */ - billableLimit: number | null; - /** - * The team's one-time free document grant size — the "N" in "X of N free". - * A lifetime grant ({@code pricing_policy.free_tier_units}): it never resets - * and is not lost when the team subscribes. - */ - freeAllowance: number; - /** - * One-time free documents still available to the team - * ({@code payg_team_extensions.free_units_remaining}). 0 = grant exhausted. - * Survives subscribing — a subscribed team keeps any unused grant. - */ - freeRemaining: number; - /** - * Paid per-document rate in minor units of {@link Wallet#currency} (may be - * fractional); null when the rate can't be resolved — render "unknown", - * never substitute. - */ - pricePerDocMinor: number | null; - /** Lower-case ISO 4217 currency of the subscription's Stripe Price; null when unknown. */ - currency: string | null; - /** - * Estimated charges so far this period in minor units of currency: paid - * (Stripe-metered) documents this period × rate. The free portion was - * already netted out at charge time. Informational — the Stripe invoice - * is authoritative. Null when the rate is unknown. - */ - estimatedBillMinor: number | null; - /** Monthly cap in major currency units when subscribed; null when noCap or status=='free'. */ - capUsd: number | null; - /** Only meaningful when status=='subscribed'. */ - noCap: boolean; - /** Stripe subscription id when subscribed; null when free. */ - stripeSubscriptionId: string | null; - /** Current-period spend in billable units. */ - spendUnitsThisPeriod: number; - /** Per-category spend breakdown (api / ai / automation). */ - categoryBreakdown: WalletCategoryBreakdown; - /** - * Team members, populated for the leader view; empty for members or - * single-seat tenants. Leader-vs-member is still resolved via {@link - * Wallet#role} — this field just carries the per-member rows the leader's - * sub-cap table needs. - */ - members: WalletMember[]; - /** - * Recent billable-activity rows. V1 returns {@code []} from the backend; - * the field exists so the Plan page can render an empty state without - * branching on undefined. Each entry is a {@code Record} - * because the activity-row shape is not yet finalised — when the meter- - * event surface lands, this widens to a real interface. - */ - recent: Array>; -} +// The wallet contract lives in @shared/billing (shared with the admin portal). +// Re-exported so existing `@app/hooks/useWallet` importers keep their imports. +export type { + Wallet, + WalletStatus, + WalletRole, + WalletMember, + WalletCategoryBreakdown, + WalletActivityRow, +}; export interface UseWalletResult { wallet: Wallet | null; diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 3a14b0fb59..1eb51b759b 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -989,6 +989,35 @@ const FileSidebar = forwardRef(
    ) : filteredFileStubs.length > 0 ? (
    + {filteredFileStubs.map((stub) => { const workbenchFileId = state.files.ids.find( (id) => (id as string) === (stub.id as string), diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 2f4cb72997..deba2f1200 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -30,6 +30,9 @@ import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench"; import { Tooltip } from "@app/components/shared/Tooltip"; import LocalIcon from "@app/components/shared/LocalIcon"; import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; +import { enforceExportPolicies } from "@app/services/policyExport"; +import { downloadFile as downloadRaw } from "@app/services/downloadService"; +import { alert as showAlert } from "@app/components/toast"; import { WorkbenchBarButtonConfig, WorkbenchBarRenderContext, @@ -171,13 +174,35 @@ export default function WorkbenchBar({ const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles; - for (const file of filesToExport) { - const stub = isStirlingFile(file) + const stubs = filesToExport.map((file) => + isStirlingFile(file) ? selectors.getStirlingFileStub(file.fileId) - : undefined; + : undefined, + ); + + // Enforce all files in one batch so the toast shows progress across the + // whole set (e.g. "report.pdf (2 of 5)") rather than N invisible solo runs. + let enforced: File[]; + try { + enforced = await enforceExportPolicies( + filesToExport as File[], + stubs.map((s) => s?.id), + ); + } catch { + enforced = filesToExport as File[]; + showAlert({ + alertType: "warning", + title: t("policies.enforcement.exportFailureTitle"), + body: t("policies.enforcement.exportFailureBody"), + }); + } + + for (let idx = 0; idx < filesToExport.length; idx++) { + const file = filesToExport[idx]; + const stub = stubs[idx]; try { - const result = await downloadFile({ - data: file, + const result = await downloadRaw({ + data: enforced[idx], filename: file.name, localPath: forceNewFile ? undefined : stub?.localFilePath, fileId: stub?.id, diff --git a/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx b/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx index 29f9806342..a858498bb6 100644 --- a/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx @@ -4,12 +4,14 @@ import { ScrollArea, Text, ActionIcon, + Button, Loader, Stack, TextInput, } from "@mantine/core"; import LocalIcon from "@app/components/shared/LocalIcon"; import { useViewer } from "@app/contexts/ViewerContext"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { PdfAttachmentObject } from "@embedpdf/models"; import AttachmentIcon from "@mui/icons-material/AttachmentRounded"; import DownloadIcon from "@mui/icons-material/DownloadRounded"; @@ -52,7 +54,9 @@ export const AttachmentSidebar = ({ preloadCacheKeys = [], }: AttachmentSidebarProps) => { const { t } = useTranslation(); - const { attachmentActions, hasAttachmentSupport } = useViewer(); + const { attachmentActions, hasAttachmentSupport, toggleAttachmentSidebar } = + useViewer(); + const { handleToolSelectForced } = useToolWorkflow(); const [searchTerm, setSearchTerm] = useState(""); const [attachmentSupport, setAttachmentSupport] = useState(() => hasAttachmentSupport(), @@ -139,16 +143,28 @@ export const AttachmentSidebar = ({ const key = documentCacheKey; const cached = cacheRef.current.get(key); - if ( - cached && - (cached.status === "loading" || cached.status === "success") - ) { + // Only short-circuit on a finalised success cache. Skipping when + // cached.status === "loading" caused the sidebar to get stuck: if + // the previous fetch was cancelled (by a parent re-render that + // changed the attachmentActions reference - createViewerActions + // builds a new object every viewer render), the cache still says + // "loading" but no live fetch is in flight. On the re-run we'd + // early-return and never refetch, so the UI would sit on the + // "Loading attachments..." state forever. Same change applied in + // BookmarkSidebar. + if (cached && cached.status === "success") { return; } let cancelled = false; + // Don't write "loading" into the cache - keep the cache for + // terminal states (success/error) only, so a cancelled run can + // never leave a stale "loading" entry behind. The visible + // sidebar state still goes through setActiveEntry below. const updateEntry = (entry: AttachmentCacheEntry) => { - cacheRef.current.set(key, entry); + if (entry.status === "success" || entry.status === "error") { + cacheRef.current.set(key, entry); + } if (!cancelled && currentKeyRef.current === key) { setActiveEntry(entry); } @@ -163,10 +179,20 @@ export const AttachmentSidebar = ({ ); const fetchWithRetry = async () => { - const maxAttempts = 10; + // See BookmarkSidebar - matching change. After a file swap the + // attachment bridge briefly unregisters and the action returns + // null until the new document is loaded; without retrying on + // null we'd cache an empty success and miss freshly-added + // attachments. + const maxAttempts = 30; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const result = await attachmentActions.getAttachments(); + if (result === null) { + if (attempt === maxAttempts - 1) return []; + await new Promise((resolve) => setTimeout(resolve, 50)); + continue; + } return Array.isArray(result) ? result : []; } catch (error: any) { const message = @@ -239,6 +265,14 @@ export const AttachmentSidebar = ({ attachmentActions.downloadAttachment(attachment); }; + const handleAddAttachment = useCallback(() => { + // Close the attachment sidebar before opening the tool so the user + // doesn't end up looking at two stacked side panels (the sidebar on + // the right + the tool's settings on the left). + toggleAttachmentSidebar(); + handleToolSelectForced("addAttachments"); + }, [handleToolSelectForced, toggleAttachmentSidebar]); + const filteredAttachments = useMemo(() => { const attachments = Array.isArray(activeEntry.attachments) ? activeEntry.attachments @@ -352,6 +386,18 @@ export const AttachmentSidebar = ({ {t("viewer.attachments.title", "Attachments")}
    + + + + +
    + + {t( "viewer.attachments.empty", "No attachments in this document", )} - + + )} {showAttachmentList && ( -
    - {renderAttachments(filteredAttachments)} -
    + <> + +
    + {renderAttachments(filteredAttachments)} +
    + )} {showSearchEmpty && ( diff --git a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx index c87c783bdf..40bd9d3045 100644 --- a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx @@ -7,10 +7,18 @@ import { Loader, Stack, TextInput, + NumberInput, Button, + Group, + UnstyledButton, } from "@mantine/core"; import LocalIcon from "@app/components/shared/LocalIcon"; import { useViewer } from "@app/contexts/ViewerContext"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { useFileContext } from "@app/contexts/FileContext"; +import { isStirlingFile, type FileId } from "@app/types/fileContext"; +import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; +import apiClient from "@app/services/apiClient"; import { PdfBookmarkObject, PdfActionType } from "@embedpdf/models"; import BookmarksIcon from "@mui/icons-material/BookmarksRounded"; import "@app/components/viewer/SidebarBase.css"; @@ -75,9 +83,25 @@ export const BookmarkSidebar = ({ documentCacheKey, preloadCacheKeys = [], }: BookmarkSidebarProps) => { - const { bookmarkActions, scrollActions, hasBookmarkSupport } = useViewer(); + const { + bookmarkActions, + scrollActions, + hasBookmarkSupport, + activeFileId, + activeFileIndex, + setActiveFileId, + getScrollState, + toggleBookmarkSidebar, + } = useViewer(); + const { handleToolSelectForced } = useToolWorkflow(); + const { selectors, actions: fileActions } = useFileContext(); const [expanded, setExpanded] = useState>({}); const [searchTerm, setSearchTerm] = useState(""); + const [isAddingBookmark, setIsAddingBookmark] = useState(false); + const [newBookmarkTitle, setNewBookmarkTitle] = useState(""); + const [newBookmarkPage, setNewBookmarkPage] = useState(1); + const [isSavingBookmark, setIsSavingBookmark] = useState(false); + const [addBookmarkError, setAddBookmarkError] = useState(null); const [bookmarkSupport, setBookmarkSupport] = useState(() => hasBookmarkSupport(), ); @@ -164,16 +188,23 @@ export const BookmarkSidebar = ({ const key = documentCacheKey; const cached = cacheRef.current.get(key); - if ( - cached && - (cached.status === "loading" || cached.status === "success") - ) { + // Only short-circuit on a finalised success cache. Skipping when + // cached.status === "loading" causes the sidebar to get stuck if + // the previous fetch was cancelled by a parent re-render (the + // bookmarkActions reference changes every viewer render because + // createViewerActions rebuilds the object). See matching change + // in AttachmentSidebar. + if (cached && cached.status === "success") { return; } let cancelled = false; + // Don't write "loading" into the cache - cache only terminal + // states so a cancelled run can't poison the cache. const updateEntry = (entry: BookmarkCacheEntry) => { - cacheRef.current.set(key, entry); + if (entry.status === "success" || entry.status === "error") { + cacheRef.current.set(key, entry); + } if (!cancelled && currentKeyRef.current === key) { setActiveEntry(entry); } @@ -188,10 +219,24 @@ export const BookmarkSidebar = ({ ); const fetchWithRetry = async () => { - const maxAttempts = 10; + // 30 × 50ms = 1.5s window. After consumeFiles swaps the file the + // embedpdf bookmark plugin tears down for the old document and + // re-registers for the new one; until the bridge is back the + // action returns null. Without retrying on null we'd cache an + // empty "success" and the just-added bookmark would never show + // up in the sidebar. + const maxAttempts = 30; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const result = await bookmarkActions.fetchBookmarks(); + if (result === null) { + // Bridge not registered yet (document still loading). Wait + // and retry instead of caching this as a successful empty + // list. + if (attempt === maxAttempts - 1) return []; + await new Promise((resolve) => setTimeout(resolve, 50)); + continue; + } return Array.isArray(result) ? result : []; } catch (error: any) { const message = @@ -256,6 +301,143 @@ export const BookmarkSidebar = ({ setFetchNonce((value) => value + 1); }, [documentCacheKey, bookmarkActions]); + const handleOpenAddBookmark = useCallback(() => { + setAddBookmarkError(null); + setNewBookmarkTitle(""); + // Default the new bookmark's target page to whatever page the user is + // currently viewing - matches Acrobat / Foxit behaviour. + const currentPage = getScrollState?.()?.currentPage ?? 1; + setNewBookmarkPage(currentPage); + setIsAddingBookmark(true); + }, [getScrollState]); + + const handleCancelAddBookmark = useCallback(() => { + setIsAddingBookmark(false); + setAddBookmarkError(null); + setNewBookmarkTitle(""); + }, []); + + // Fallback: open the full Edit Table of Contents tool when inline add is + // not viable (e.g. the active file is a preview / unmanaged file we + // cannot consume + replace via FileContext). + const handleFallbackToTool = useCallback(() => { + handleToolSelectForced("editTableOfContents"); + }, [handleToolSelectForced]); + + const handleSubmitAddBookmark = useCallback(async () => { + const title = newBookmarkTitle.trim(); + if (!title) { + setAddBookmarkError("Bookmark title is required"); + return; + } + // Resolve the file the viewer is currently displaying. activeFileId + // is only set explicitly (user clicked a thumbnail / a tool ran); + // on a fresh /read upload it stays null and the viewer falls back + // to activeFileIndex - so we mirror that here. Without this, Save + // would silently route to the full editor every time on a fresh + // upload. + const allFiles = selectors.getFiles(); + const resolvedFile = activeFileId + ? allFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId) + : (allFiles[activeFileIndex] ?? allFiles[0]); + const resolvedFileId = + resolvedFile && isStirlingFile(resolvedFile) + ? (resolvedFile.fileId as FileId) + : null; + if (!resolvedFileId) { + handleFallbackToTool(); + return; + } + const fileId = resolvedFileId; + const file = selectors.getFile(fileId); + const parentStub = selectors.getStirlingFileStub(fileId); + if (!file || !parentStub) { + handleFallbackToTool(); + return; + } + + setIsSavingBookmark(true); + setAddBookmarkError(null); + try { + // Convert existing PDF bookmarks (from embedpdf) to the backend's + // payload shape, then append the new one. + const toPayload = ( + b: PdfBookmarkObject, + ): { + title: string; + pageNumber: number; + children: any[]; + } => ({ + title: b.title ?? "", + pageNumber: resolvePageNumber(b) ?? 1, + children: (b.children ?? []).map(toPayload), + }); + const existing = (activeEntry.bookmarks ?? []).map(toPayload); + const bookmarkData = [ + ...existing, + { title, pageNumber: newBookmarkPage, children: [] }, + ]; + + const formData = new FormData(); + formData.append("fileInput", file); + formData.append("replaceExisting", "true"); + formData.append("bookmarkData", JSON.stringify(bookmarkData)); + + const response = await apiClient.post( + "/api/v1/general/edit-table-of-contents", + formData, + { responseType: "blob" }, + ); + + const newFile = new File([response.data as Blob], file.name, { + type: "application/pdf", + }); + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( + [newFile], + parentStub, + "editTableOfContents", + ); + const outputFileIds = await fileActions.consumeFiles( + [fileId], + stirlingFiles, + stubs, + ); + + // Point the viewer at the new file. Without this the viewer's + // activeFileId-removed effect nulls activeFileId (old file is + // gone) and the activeFileIndex falls back to 0, which races + // against the embedpdf plugin reloading - the bookmark / + // attachment bridges can end up stuck in a "loading" state. + // useToolOperation does the same thing after consumeFiles. + if (outputFileIds.length === 1) { + setActiveFileId(outputFileIds[0]); + } + + // Reset form. The cache is keyed by documentCacheKey (== fileId); + // the new fileId triggers our document-switch effect, which + // resets state and re-fetches once the embedpdf bookmark + // capability has the new document loaded. + setIsAddingBookmark(false); + setNewBookmarkTitle(""); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to save bookmark"; + setAddBookmarkError(message); + } finally { + setIsSavingBookmark(false); + } + }, [ + newBookmarkTitle, + newBookmarkPage, + activeFileId, + activeFileIndex, + selectors, + fileActions, + setActiveFileId, + activeEntry.bookmarks, + handleFallbackToTool, + ]); + const bookmarksWithIds = useMemo(() => { const assignIds = ( nodes: PdfBookmarkObject[], @@ -293,34 +475,6 @@ export const BookmarkSidebar = ({ })); }; - const expandAll = useCallback(() => { - const allExpanded: Record = {}; - const expandRecursive = (nodes: BookmarkNode[]) => { - nodes.forEach((node) => { - if (node.children && node.children.length > 0) { - allExpanded[node.id] = true; - expandRecursive(node.children as BookmarkNode[]); - } - }); - }; - expandRecursive(bookmarksWithIds); - setExpanded(allExpanded); - }, [bookmarksWithIds]); - - const collapseAll = useCallback(() => { - const allCollapsed: Record = {}; - const collapseRecursive = (nodes: BookmarkNode[]) => { - nodes.forEach((node) => { - if (node.children && node.children.length > 0) { - allCollapsed[node.id] = false; - collapseRecursive(node.children as BookmarkNode[]); - } - }); - }; - collapseRecursive(bookmarksWithIds); - setExpanded(allCollapsed); - }, [bookmarksWithIds]); - const handleBookmarkClick = ( bookmark: PdfBookmarkObject, event: React.MouseEvent, @@ -499,31 +653,18 @@ export const BookmarkSidebar = ({ Bookmarks - {bookmarkSupport && bookmarksWithIds.length > 0 && ( - <> - {Object.values(expanded).some((val) => val === false) ? ( - - - - ) : ( - - - - )} - - )} + + + + + )} - {showEmptyState && ( -
    + {showEmptyState && !isAddingBookmark && ( + + No bookmarks in this document -
    + + + )} + + {isAddingBookmark && ( + + + + Add bookmark + + setNewBookmarkTitle(e.currentTarget.value)} + autoFocus + disabled={isSavingBookmark} + /> + + setNewBookmarkPage(typeof v === "number" ? v : 1) + } + disabled={isSavingBookmark} + /> + {addBookmarkError && ( + + {addBookmarkError} + + )} + + + + + + )} {showBookmarkList && ( -
    - {renderBookmarks(filteredBookmarks)} -
    + <> + {!isAddingBookmark && ( + + )} +
    + {renderBookmarks(filteredBookmarks)} +
    + )} {showSearchEmpty && ( @@ -609,6 +851,41 @@ export const BookmarkSidebar = ({ )}
    + + {bookmarkSupport && documentCacheKey && ( + + + + + + Need to reorder or nest? Open the Bookmark Editor + + + + + )}
    ); }; diff --git a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx index f74ad806c6..cc470b3ea6 100644 --- a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx @@ -311,11 +311,17 @@ export function CommentsSidebar({ clearHighlightCommentRequest, scrollActions, getZoomState, + toggleCommentsSidebar, } = useViewer() ?? {}; const scrollViewportRef = useRef(null); const { state, provides } = useAnnotation(documentId); const { handleToolSelectForced } = useToolWorkflow(); - const { activateAnnotationToolRef } = useAnnotationContext(); + const { + activateAnnotationToolRef, + activeAnnotationToolId, + setActiveAnnotationToolId, + } = useAnnotationContext(); + const isPlacingComment = activeAnnotationToolId === TEXT_COMMENT_TOOL_ID; const [draftContents, setDraftContents] = useState>( {}, ); @@ -639,12 +645,34 @@ export function CommentsSidebar({ ); const handleAddComment = useCallback(() => { + // Keep the sidebar open this time - the button morphs into a + // "Click on a page... cancel" hint so the user can see exactly + // what state the viewer is in. handleToolSelectForced(ANNOTATE_PANEL_ID); requestAnimationFrame(() => { activateAnnotationToolRef.current?.(TEXT_COMMENT_TOOL_ID); }); }, [handleToolSelectForced, activateAnnotationToolRef]); + const handleCancelPlacingComment = useCallback(() => { + // De-arm the textComment tool. The panel's activateAnnotationTool + // takes the AnnotationToolId "select" to reset to no-tool state. + activateAnnotationToolRef.current?.("select" as never); + setActiveAnnotationToolId(null); + }, [activateAnnotationToolRef, setActiveAnnotationToolId]); + + // ESC cancels placement mode while the sidebar is open. + useEffect(() => { + if (!visible || !isPlacingComment) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + handleCancelPlacingComment(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [visible, isPlacingComment, handleCancelPlacingComment]); + if (!visible) return null; return ( @@ -716,6 +744,18 @@ export function CommentsSidebar({ )} + {toggleCommentsSidebar && ( + + + + )} @@ -733,434 +773,517 @@ export function CommentsSidebar({ "Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.", )} - + {isPlacingComment ? ( + + ) : ( + + )} ) : ( - pageNumbers.map((pageIndex) => { - const entries = byPage[pageIndex] ?? []; - const pageNum = pageIndex + 1; - return ( - - - {t("viewer.comments.pageLabel", "Page {{page}}", { - page: pageNum, - })} - - - {t("viewer.comments.nComments", "{{count}} comment(s)", { - count: entries.length, - })} - - - - {entries.map((entry) => { - const ann = entry.annotation?.object; - const id = ann?.id; - if (!id) return null; - const key = `${pageIndex}_${id}`; - const replyKey = `${pageIndex}_${id}_reply`; - const displayContent = getCommentDisplayContent(entry); - const draft = - draftContents[key] !== undefined - ? draftContents[key] - : displayContent; - const replyDraft = replyDrafts[replyKey] ?? ""; - const authorName = getAuthorName(ann, displayName); - /** Only treat as "comment posted" when annotation actually has content (user clicked Send), not on every keystroke. */ - const hasMainContent = - (displayContent ?? "").trim().length > 0; - const isEditingMain = editingMainKey === key; + <> + {isPlacingComment ? ( + + ) : ( + + )} + {pageNumbers.map((pageIndex) => { + const entries = byPage[pageIndex] ?? []; + const pageNum = pageIndex + 1; + return ( + + + {t("viewer.comments.pageLabel", "Page {{page}}", { + page: pageNum, + })} + + + {t("viewer.comments.nComments", "{{count}} comments", { + count: entries.length, + })} + + + + {entries.map((entry) => { + const ann = entry.annotation?.object; + const id = ann?.id; + if (!id) return null; + const key = `${pageIndex}_${id}`; + const replyKey = `${pageIndex}_${id}_reply`; + const displayContent = getCommentDisplayContent(entry); + const draft = + draftContents[key] !== undefined + ? draftContents[key] + : displayContent; + const replyDraft = replyDrafts[replyKey] ?? ""; + const authorName = getAuthorName(ann, displayName); + /** Only treat as "comment posted" when annotation actually has content (user clicked Send), not on every keystroke. */ + const hasMainContent = + (displayContent ?? "").trim().length > 0; + const isEditingMain = editingMainKey === key; - const mainTimestamp = formatCommentDate(ann); - const typeLabel = getAnnotationTypeLabel(ann, t); + const mainTimestamp = formatCommentDate(ann); + const typeLabel = getAnnotationTypeLabel(ann, t); - return ( - - - - - - {authorName} - - - {typeLabel} - {mainTimestamp ? ` · ${mainTimestamp}` : ""} - - - - - + + + + {authorName} + + + {typeLabel} + {mainTimestamp ? ` · ${mainTimestamp}` : ""} + + + + - - handleLocateAnnotation(pageIndex, ann) - } - > - - - - - - - - - - - - - - } - onClick={() => setEditingMainKey(key)} - > - {t("annotation.editText", "Edit")} - - - } - color="red" - onClick={() => - handleDeleteClick(pageIndex, id, ann) - } - > - {t("annotation.delete", "Delete")} - - - - - - - {!hasMainContent || isEditingMain ? ( - <> -