mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-09 19:32:24 +00:00
fix(hosted): normalize AI defaults and seed quickstart credits
This commit is contained in:
parent
b4c99a182d
commit
00a3817d9e
12 changed files with 167 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
75
internal/api/hosted_entitlement_refresh_quickstart_test.go
Normal file
75
internal/api/hosted_entitlement_refresh_quickstart_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue