fix(hosted): normalize AI defaults and seed quickstart credits

This commit is contained in:
rcourtman 2026-03-25 15:22:17 +00:00
parent b4c99a182d
commit 00a3817d9e
12 changed files with 167 additions and 22 deletions

View file

@ -223,6 +223,11 @@ before any API-only token fallback or optional-auth anonymous fallback so
operators can mint relay-mobile credentials and continue onboarding from the
hosted runtime itself even after that tenant has already minted managed API
tokens.
That same lifecycle-adjacent hosted entitlement path must also preserve the
trial quickstart grant fields already seeded into billing state. Hosted setup
and pairing may depend on the shared `internal/api/` entitlement refresh, but
that refresh must not erase quickstart bootstrap inventory while it rewrites
lease-backed plan and capability data.
The same setup boundary also depends on canonical org-management privilege
surviving the next step: once the request is scoped to a hosted tenant org,
shared `internal/api/security_setup_fix.go` helpers must allow that org's

View file

@ -142,6 +142,7 @@ Own canonical runtime payload shapes between backend and frontend.
17. Keep hosted tenant browser-session precedence on the shared auth boundary: `internal/api/auth.go`, `internal/api/contract_test.go`, and hosted tenant callers must treat a valid `pulse_session` as authoritative before any API-only token fallback or no-local-auth anonymous fallback, so cloud handoff can continue into protected hosted routes without flattening the operator back to `anonymous` or forcing a browser session through bearer-token-only mode after the tenant has minted API tokens.
18. Keep tenant settings-scope authorization aligned with org management: `internal/api/security_setup_fix.go`, `internal/api/contract_test.go`, and settings-bound hosted callers must allow the current non-default org owner/admin membership to exercise privileged tenant routes, rather than requiring a separate configured local admin identity after hosted handoff.
19. Keep mobile onboarding payload reads aligned with the server-owned relay-mobile credential: `internal/api/router_routes_ai_relay.go`, `internal/api/onboarding_handlers.go`, and `internal/api/contract_test.go` must allow the dedicated `relay:mobile:access` scope to reach the governed QR, deep-link, and connection-validation payloads without reintroducing a broader `settings:read` requirement for token-authenticated pairing clients.
20. Keep hosted billing-state quickstart payload fields on the shared API contract: `internal/api/hosted_entitlement_refresh.go`, `internal/api/subscription_state_handlers.go`, and `internal/api/contract_test.go` must preserve `quickstart_credits_granted`, `quickstart_credits_used`, and `quickstart_credits_granted_at` through hosted signup, hosted lease refresh, and billing-state reads instead of letting lease rewrites silently erase seeded quickstart inventory.
## Forbidden Paths
@ -1235,6 +1236,13 @@ That quickstart transport contract must also preserve the distinction between
credit inventory and live runtime path: zero remaining credits alone must not
force a blocked or exhausted operator presentation while Patrol is active on a
configured non-quickstart provider path.
Hosted billing-state payloads now also carry the canonical quickstart grant
metadata used by hosted bootstrap and refresh flows. Billing reads and contract
proofs must preserve `quickstart_credits_granted`,
`quickstart_credits_used`, and `quickstart_credits_granted_at` as backend-
owned fields, so hosted entitlement refresh cannot silently drop a workspace
back to "no quickstart inventory" just because the lease or trial state was
rewritten.
That same Patrol status contract now also carries a canonical `runtime_state`
field, so the frontend can distinguish blocked, running, disabled, active,
and unavailable Patrol runtime states without deriving operator status from

View file

@ -583,6 +583,17 @@ bootstrap/admin-role assignment and rollback path for hosted signup failures.
Hosted billing-state normalization now follows the same rule: a missing
`plan_version` must remain missing instead of being synthesized from
`subscription_state`, while explicit trial defaults remain explicit.
Hosted trial bootstrap and hosted entitlement refresh now also own quickstart
credit seeding as part of that same persisted billing boundary. New hosted
trial workspaces and later lease-refresh rewrites must preserve
`quickstart_credits_granted` plus its grant timestamp instead of resetting the
workspace to zero hosted quickstart inventory after signup or entitlement
renewal.
Hosted AI runtime defaults are part of the same boundary as well: when a cloud
tenant falls back to provider defaults, the persisted model identifier must
remain canonical `provider:model` data rather than a bare provider-local alias,
so hosted enterprise runtime startup does not fail before chat or approvals can
initialize.
Hosted release builds must also accept the trial-activation public key from
runtime environment when `PULSE_HOSTED_MODE=true`, because hosted tenants
receive that verification key from control-plane deployment rather than from

View file

@ -166,6 +166,12 @@ ledger explanation reads: storage- and recovery-adjacent surfaces may coexist
with counted monitored-system inventory, but any support-facing count
reasoning must come from the canonical unified-resource grouping explanation
payload rather than from storage or recovery heuristics.
That same shared hosted-entitlement refresh path must also preserve the
canonical quickstart grant metadata carried in billing state. Storage- and
recovery-adjacent hosted tenants may share Patrol-backed investigation and
recovery context with the rest of the app, but the shared `internal/api/`
lease refresh must not clear quickstart inventory and leave adjacent product
surfaces inferring a fake "unavailable" runtime from rewritten billing state.
That adjacent ledger read must also preserve canonical grouped system status,
including `warning`, so recovery- and storage-adjacent support views do not
flatten governed degraded state into a fake `unknown` label when the shared

View file

@ -210,6 +210,39 @@ func TestContract_HostedSignupResponseJSONSnapshot(t *testing.T) {
assertJSONSnapshot(t, got, want)
}
func TestContract_BillingStateQuickstartJSONSnapshot(t *testing.T) {
grantedAt := time.Date(2026, 3, 25, 14, 30, 0, 0, time.UTC).Unix()
payload := billingState{
Capabilities: []string{"ai_autofix", "ai_patrol"},
Limits: map[string]int64{"max_monitored_systems": 25},
MetersEnabled: []string{},
PlanVersion: "cloud_starter",
SubscriptionState: subscriptionStateActiveValue,
QuickstartCreditsGranted: true,
QuickstartCreditsUsed: 3,
QuickstartCreditsGrantedAt: &grantedAt,
}
got, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal billing state: %v", err)
}
const want = `{
"capabilities":["ai_autofix","ai_patrol"],
"limits":{"max_monitored_systems":25},
"meters_enabled":[],
"plan_version":"cloud_starter",
"subscription_state":"active",
"quickstart_credits_granted":true,
"quickstart_credits_used":3,
"quickstart_credits_granted_at":1774449000
}`
assertJSONSnapshot(t, got, want)
}
func TestContract_StripeWebhookHandlersUseCanonicalRuntimeDataDir(t *testing.T) {
envDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", envDir)

View file

@ -357,6 +357,7 @@ func (h *LicenseHandlers) refreshHostedEntitlementLeaseOnce(orgID string, servic
updated.TrialStartedAt = leaseClaims.TrialStartedAt
updated.TrialEndsAt = nil
updated.TrialExtendedAt = nil
updated.GrantQuickstartCredits()
if err := billingStore.SaveBillingState(orgID, updated); err != nil {
return false, false, fmt.Errorf("save refreshed entitlement lease: %w", err)
}

View file

@ -0,0 +1,75 @@
package api
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
entitlements "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
func TestRefreshHostedEntitlementLeaseOnce_GrantsQuickstartCredits(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
t.Setenv(entitlements.TrialActivationPublicKeyEnvVar, base64.StdEncoding.EncodeToString(pub))
refreshServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/entitlements/refresh" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(hostedTrialLeaseRefreshResponse{
EntitlementJWT: issueTrialEntitlementLease(t, priv, "default", "pulse.example.com", "owner@example.com", time.Now()),
})
}))
defer refreshServer.Close()
h := NewLicenseHandlers(mtp, false, &config.Config{
PublicURL: "https://pulse.example.com",
ProTrialSignupURL: refreshServer.URL + "/start-pro-trial",
})
store := config.NewFileBillingStore(baseDir)
startedAt := time.Now().Add(-13 * 24 * time.Hour).Unix()
expiredLease := issueTrialEntitlementLease(t, priv, "default", "pulse.example.com", "owner@example.com", time.Now().Add(-15*24*time.Hour))
if err := store.SaveBillingState("default", &entitlements.BillingState{
EntitlementJWT: expiredLease,
EntitlementRefreshToken: "etr_test_default",
TrialStartedAt: &startedAt,
}); err != nil {
t.Fatalf("SaveBillingState: %v", err)
}
refreshed, permanent, err := h.refreshHostedEntitlementLeaseOnce("default", nil)
if err != nil {
t.Fatalf("refreshHostedEntitlementLeaseOnce: %v", err)
}
if !refreshed || permanent {
t.Fatalf("refreshed=%v permanent=%v, want refreshed=true permanent=false", refreshed, permanent)
}
state, err := store.GetBillingState("default")
if err != nil {
t.Fatalf("GetBillingState: %v", err)
}
if state == nil {
t.Fatal("expected billing state after hosted entitlement refresh")
}
if !state.QuickstartCreditsGranted {
t.Fatal("expected hosted entitlement refresh to grant quickstart credits")
}
if state.QuickstartCreditsGrantedAt == nil {
t.Fatal("expected hosted entitlement refresh to record quickstart grant timestamp")
}
}

View file

@ -75,6 +75,12 @@ func TestHostedLifecycle(t *testing.T) {
if stored.TrialEndsAt == nil {
t.Fatal("expected trial_ends_at to be populated")
}
if !stored.QuickstartCreditsGranted {
t.Fatal("expected hosted signup billing state to grant quickstart credits")
}
if stored.QuickstartCreditsGrantedAt == nil {
t.Fatal("expected hosted signup quickstart grant timestamp to be populated")
}
expectedCaps := append([]string(nil), cloudCapabilitiesFromLicensing()...)
sort.Strings(expectedCaps)

View file

@ -141,7 +141,7 @@ const (
func NewDefaultAIConfig() *AIConfig {
return &AIConfig{
Enabled: false,
Model: DefaultAIModelAnthropic,
Model: DefaultModelForProvider(AIProviderAnthropic),
AuthMethod: AuthMethodAPIKey,
// Patrol defaults - enabled when AI is enabled
// Default to 6 hour intervals (much more token-efficient than 15 min)
@ -346,19 +346,8 @@ func (c *AIConfig) GetModel() string {
// This handles the case where user configures Ollama but doesn't explicitly select a model
configured := c.GetConfiguredProviders()
if len(configured) == 1 {
switch configured[0] {
case AIProviderAnthropic:
return DefaultAIModelAnthropic
case AIProviderOpenAI:
return DefaultAIModelOpenAI
case AIProviderOpenRouter:
return DefaultAIModelOpenRouter
case AIProviderOllama:
return DefaultAIModelOllama
case AIProviderDeepSeek:
return DefaultAIModelDeepSeek
case AIProviderGemini:
return DefaultAIModelGemini
if defaultModel := DefaultModelForProvider(configured[0]); defaultModel != "" {
return defaultModel
}
}

View file

@ -394,42 +394,42 @@ func TestAIConfig_GetModel(t *testing.T) {
config: AIConfig{
AnthropicAPIKey: "key",
},
expected: DefaultAIModelAnthropic,
expected: DefaultModelForProvider(AIProviderAnthropic),
},
{
name: "single provider configured - openai",
config: AIConfig{
OpenAIAPIKey: "key",
},
expected: DefaultAIModelOpenAI,
expected: DefaultModelForProvider(AIProviderOpenAI),
},
{
name: "single provider configured - openrouter",
config: AIConfig{
OpenRouterAPIKey: "key",
},
expected: DefaultAIModelOpenRouter,
expected: DefaultModelForProvider(AIProviderOpenRouter),
},
{
name: "single provider configured - deepseek",
config: AIConfig{
DeepSeekAPIKey: "key",
},
expected: DefaultAIModelDeepSeek,
expected: DefaultModelForProvider(AIProviderDeepSeek),
},
{
name: "single provider configured - gemini",
config: AIConfig{
GeminiAPIKey: "key",
},
expected: DefaultAIModelGemini,
expected: DefaultModelForProvider(AIProviderGemini),
},
{
name: "single provider configured - ollama",
config: AIConfig{
OllamaBaseURL: "http://localhost:11434",
},
expected: DefaultAIModelOllama,
expected: DefaultModelForProvider(AIProviderOllama),
},
{
name: "multiple providers configured (no default)",
@ -444,7 +444,7 @@ func TestAIConfig_GetModel(t *testing.T) {
config: AIConfig{
OllamaBaseURL: "http://localhost:11434",
},
expected: DefaultAIModelOllama,
expected: DefaultModelForProvider(AIProviderOllama),
},
{
name: "no model/provider",
@ -674,6 +674,9 @@ func TestNewDefaultAIConfig(t *testing.T) {
if config.PatrolIntervalMinutes != 360 {
t.Errorf("Default patrol interval should be 360, got %d", config.PatrolIntervalMinutes)
}
if config.Model != DefaultModelForProvider(AIProviderAnthropic) {
t.Errorf("Default model should be %q, got %q", DefaultModelForProvider(AIProviderAnthropic), config.Model)
}
if !config.PatrolEnabled {
t.Error("Default patrol should be enabled")
}

View file

@ -86,7 +86,7 @@ func BuildTrialBillingStateWithPlan(now time.Time, capabilities []string, planVe
planVersion = string(SubStateTrial)
}
return &BillingState{
state := &BillingState{
Capabilities: append([]string(nil), capabilities...),
Limits: map[string]int64{},
MetersEnabled: []string{},
@ -95,4 +95,6 @@ func BuildTrialBillingStateWithPlan(now time.Time, capabilities []string, planVe
TrialStartedAt: &startedAt,
TrialEndsAt: &endsAt,
}
state.GrantQuickstartCredits()
return state
}

View file

@ -234,4 +234,10 @@ func TestBuildTrialBillingStateWithPlan(t *testing.T) {
if *state.TrialEndsAt != now.Add(72*time.Hour).Unix() {
t.Fatalf("trial_ends_at=%d, want %d", *state.TrialEndsAt, now.Add(72*time.Hour).Unix())
}
if !state.QuickstartCreditsGranted {
t.Fatal("expected quickstart credits to be granted for new trial workspaces")
}
if state.QuickstartCreditsGrantedAt == nil {
t.Fatal("expected quickstart credits grant timestamp to be populated")
}
}