mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
382 lines
12 KiB
Go
382 lines
12 KiB
Go
package licensing
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/securityutil"
|
|
)
|
|
|
|
// LicenseServerClient communicates with the Pulse license server for activation and grant refresh.
|
|
type LicenseServerClient struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
initErr error
|
|
}
|
|
|
|
// CheckoutSessionResult is the canonical purchase-fulfillment response returned
|
|
// by the commercial checkout session endpoint.
|
|
type CheckoutSessionResult struct {
|
|
Status string `json:"status"`
|
|
Message string `json:"message,omitempty"`
|
|
CheckoutStatus string `json:"checkout_status,omitempty"`
|
|
PaymentStatus string `json:"payment_status,omitempty"`
|
|
PortalHandoffID string `json:"portal_handoff_id,omitempty"`
|
|
PurchaseReturnJTI string `json:"purchase_return_jti,omitempty"`
|
|
LicenseID string `json:"license_id,omitempty"`
|
|
ActivationKey string `json:"activation_key,omitempty"`
|
|
ActivationKeyPrefix string `json:"activation_key_prefix,omitempty"`
|
|
OwnerEmail string `json:"owner_email,omitempty"`
|
|
Tier string `json:"tier,omitempty"`
|
|
PlanKey string `json:"plan_key,omitempty"`
|
|
}
|
|
|
|
// CheckoutPortalHandoffRequest is the canonical server-owned request for a
|
|
// Pulse Account portal bootstrap record that hides commercial flow state from
|
|
// the browser.
|
|
type CheckoutPortalHandoffRequest struct {
|
|
Feature string `json:"feature,omitempty"`
|
|
SuccessURL string `json:"success_url"`
|
|
CancelURL string `json:"cancel_url"`
|
|
PurchaseReturnJTI string `json:"purchase_return_jti"`
|
|
}
|
|
|
|
// CheckoutPortalHandoffResponse is the canonical opaque portal handoff used to
|
|
// bootstrap the hosted Pulse Account billing flow without exposing the bound
|
|
// checkout intent to the browser.
|
|
type CheckoutPortalHandoffResponse struct {
|
|
PortalHandoffID string `json:"portal_handoff_id"`
|
|
Feature string `json:"feature,omitempty"`
|
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
// NewLicenseServerClient creates a client for the license server.
|
|
// The base URL defaults to DefaultLicenseServerURL. The PULSE_LICENSE_SERVER_URL
|
|
// env var override is only allowed in non-release builds.
|
|
func NewLicenseServerClient(baseURL string) *LicenseServerClient {
|
|
if baseURL == "" && allowLicenseServerURLEnvOverride() {
|
|
baseURL = os.Getenv("PULSE_LICENSE_SERVER_URL")
|
|
}
|
|
if baseURL == "" {
|
|
baseURL = DefaultLicenseServerURL
|
|
}
|
|
|
|
var initErr error
|
|
normalizedBaseURL, err := securityutil.NormalizeSecureHTTPBaseURL(baseURL)
|
|
if err != nil {
|
|
initErr = fmt.Errorf("license server URL %q is invalid: %w", baseURL, err)
|
|
baseURL = ""
|
|
} else {
|
|
baseURL = normalizedBaseURL.String()
|
|
}
|
|
|
|
return &LicenseServerClient{
|
|
baseURL: baseURL,
|
|
httpClient: securityutil.NewRestrictedOutboundHTTPClient(15*time.Second, securityutil.RestrictedOutboundHTTPOptions{
|
|
AllowedSchemes: []string{"http", "https"},
|
|
AllowLoopback: true,
|
|
}),
|
|
initErr: initErr,
|
|
}
|
|
}
|
|
|
|
func (c *LicenseServerClient) ready() error {
|
|
if c == nil {
|
|
return fmt.Errorf("license server client is nil")
|
|
}
|
|
if c.initErr != nil {
|
|
return c.initErr
|
|
}
|
|
if strings.TrimSpace(c.baseURL) == "" {
|
|
return fmt.Errorf("license server URL is unavailable")
|
|
}
|
|
if c.httpClient == nil {
|
|
return fmt.Errorf("license server HTTP client is unavailable")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// postActivationRequest POSTs an idempotent JSON request to the license
|
|
// server and decodes the shared activation response shape. label feeds error
|
|
// wrapping ("activate", "exchange").
|
|
func (c *LicenseServerClient) postActivationRequest(ctx context.Context, path, label string, req any) (*ActivateInstallationResponse, error) {
|
|
if err := c.ready(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal %s request: %w", label, err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create %s request: %w", label, err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Idempotency-Key", generateIdempotencyKey())
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s request failed: %w", label, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
return nil, c.parseError(resp)
|
|
}
|
|
|
|
var result ActivateInstallationResponse
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode %s response: %w", label, err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// Activate calls the license server to create an installation and receive a relay grant.
|
|
func (c *LicenseServerClient) Activate(ctx context.Context, req ActivateInstallationRequest) (*ActivateInstallationResponse, error) {
|
|
return c.postActivationRequest(ctx, "/v1/activate", "activate", req)
|
|
}
|
|
|
|
// ExchangeLegacyLicense converts a legacy v5 JWT-style license into a v6 activation.
|
|
// The response shape matches normal activation so the runtime can persist activation
|
|
// state and start using grant refresh immediately.
|
|
func (c *LicenseServerClient) ExchangeLegacyLicense(ctx context.Context, req ExchangeLegacyLicenseRequest) (*ActivateInstallationResponse, error) {
|
|
return c.postActivationRequest(ctx, "/v1/licenses/exchange", "exchange", req)
|
|
}
|
|
|
|
// RefreshGrant calls the license server to refresh a relay grant.
|
|
// The installationToken is sent as a Bearer token in the Authorization header.
|
|
func (c *LicenseServerClient) RefreshGrant(ctx context.Context, installationID, installationToken string, req RefreshGrantRequest) (*RefreshGrantResponse, error) {
|
|
if err := c.ready(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal refresh request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/grants/refresh", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create refresh request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Authorization", "Bearer "+installationToken)
|
|
|
|
// Use a shorter timeout for refresh since it's a background operation.
|
|
refreshClient := *c.httpClient
|
|
refreshClient.Timeout = 10 * time.Second
|
|
|
|
resp, err := refreshClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("refresh request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, c.parseError(resp)
|
|
}
|
|
|
|
var result RefreshGrantResponse
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode refresh response: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// GetCheckoutSessionResult resolves a commercial checkout session into its
|
|
// authoritative fulfillment payload.
|
|
func (c *LicenseServerClient) GetCheckoutSessionResult(ctx context.Context, sessionID string) (*CheckoutSessionResult, error) {
|
|
if err := c.ready(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sessionID = strings.TrimSpace(sessionID)
|
|
if sessionID == "" {
|
|
return nil, fmt.Errorf("checkout session id is required")
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
c.baseURL+"/v1/checkout/session?session_id="+url.QueryEscape(sessionID),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create checkout session result request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("checkout session result request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, c.parseError(resp)
|
|
}
|
|
|
|
var result CheckoutSessionResult
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode checkout session result response: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// CreateCheckoutPortalHandoff stores a server-owned Pulse Account bootstrap
|
|
// record and returns an opaque portal_handoff_id for browser navigation.
|
|
func (c *LicenseServerClient) CreateCheckoutPortalHandoff(ctx context.Context, req CheckoutPortalHandoffRequest) (*CheckoutPortalHandoffResponse, error) {
|
|
if err := c.ready(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal checkout portal handoff request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/checkout/portal-handoff", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create checkout portal handoff request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("checkout portal handoff request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
return nil, c.parseError(resp)
|
|
}
|
|
|
|
var result CheckoutPortalHandoffResponse
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode checkout portal handoff response: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// FetchRevocations polls the revocation feed for events after the given sequence number.
|
|
// The feedToken authenticates access to the revocation feed.
|
|
func (c *LicenseServerClient) FetchRevocations(ctx context.Context, feedToken string, sinceSeq int64, limit int) (*RevocationFeedResponse, error) {
|
|
if err := c.ready(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if limit <= 0 {
|
|
limit = 500
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/v1/revocations?since_seq=%d&limit=%d", c.baseURL, sinceSeq, limit)
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create revocations request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Authorization", "Bearer "+feedToken)
|
|
|
|
// Use a shorter timeout for polling.
|
|
pollClient := *c.httpClient
|
|
pollClient.Timeout = 10 * time.Second
|
|
|
|
resp, err := pollClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("revocations request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, c.parseError(resp)
|
|
}
|
|
|
|
var result RevocationFeedResponse
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode revocations response: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// BaseURL returns the configured license server base URL.
|
|
func (c *LicenseServerClient) BaseURL() string {
|
|
return c.baseURL
|
|
}
|
|
|
|
// generateIdempotencyKey creates a unique key for idempotent requests.
|
|
func generateIdempotencyKey() string {
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// parseError reads an error response from the license server and returns a structured LicenseServerError.
|
|
func (c *LicenseServerClient) parseError(resp *http.Response) error {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
|
|
|
apiErr := &LicenseServerError{
|
|
StatusCode: resp.StatusCode,
|
|
Code: fmt.Sprintf("http_%d", resp.StatusCode),
|
|
Message: http.StatusText(resp.StatusCode),
|
|
}
|
|
|
|
// Try to parse structured error response from the license server.
|
|
if len(body) > 0 {
|
|
var parsed struct {
|
|
Code string `json:"code"`
|
|
Error json.RawMessage `json:"error"`
|
|
Message string `json:"message"`
|
|
Retryable bool `json:"retryable"`
|
|
}
|
|
if json.Unmarshal(body, &parsed) == nil {
|
|
code := strings.TrimSpace(parsed.Code)
|
|
message := strings.TrimSpace(parsed.Message)
|
|
retryable := parsed.Retryable
|
|
if code == "" && len(parsed.Error) > 0 {
|
|
var legacyCode string
|
|
if json.Unmarshal(parsed.Error, &legacyCode) == nil {
|
|
code = strings.TrimSpace(legacyCode)
|
|
} else {
|
|
var nested struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Retryable bool `json:"retryable"`
|
|
}
|
|
if json.Unmarshal(parsed.Error, &nested) == nil {
|
|
code = strings.TrimSpace(nested.Code)
|
|
if strings.TrimSpace(nested.Message) != "" {
|
|
message = strings.TrimSpace(nested.Message)
|
|
}
|
|
retryable = nested.Retryable
|
|
}
|
|
}
|
|
}
|
|
if code != "" {
|
|
apiErr.Code = code
|
|
if message != "" {
|
|
apiErr.Message = message
|
|
}
|
|
apiErr.Retryable = retryable
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark server errors as retryable by default.
|
|
if resp.StatusCode >= 500 {
|
|
apiErr.Retryable = true
|
|
}
|
|
|
|
return apiErr
|
|
}
|