diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 7f446984e..eaaf6b426 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -129,7 +129,10 @@ management, and fleet control surfaces. may be invoked by lifecycle-adjacent surfaces, but server-issued quickstart tokens, Patrol quickstart credit snapshots, and AI runtime auth remain `ai-runtime` plus `api-contracts` concerns rather than install-token - or lifecycle credential state. + or lifecycle credential state. Installation activation remains the only + authority for Patrol quickstart bootstrap, so lifecycle flows must not + reintroduce anonymous bootstrap identity or tenant-local commercial-owner + surrogates when they traverse those shared handlers. 2. Add or change update continuity and persisted-version handoff through `internal/agentupdate/`. 3. Add or change runtime-side Unified Agent startup, first-report assembly, and enroll/runtime continuity through `internal/hostagent/`. Proxmox host-agent setup must treat local `proxmox-registered` markers as a cache, not authority: before skipping token setup or node repair, `internal/hostagent/proxmox_setup.go` must revalidate the current type and candidate hosts against Pulse through the canonical auto-register contract. diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a5638f2c8..5a331c293 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -121,16 +121,19 @@ surface that lives behind the public license API rather than a tenant-local or mobile-local adapter. That same Patrol quickstart boundary is now server-authoritative end to end. `internal/ai/quickstart.go` must bootstrap before the first Patrol-only -quickstart use, prefer installation identity from `activation.enc` when an -activation token exists, and otherwise persist one stable community -`client_installation_id` plus the returned token snapshot under -`quickstart.enc` through the shared `internal/config/persistence.go` and -`internal/config/quickstart_state.go` helpers. `internal/ai/providers/quickstart.go` -must authenticate Patrol proxy calls with `Authorization: Bearer `, -sync `credits_remaining` / `credits_total` back into that cache on every server -response, and invalidate the cached token on auth rejection instead of reviving -local counter truth. Explicit BYOK provider credentials still outrank Patrol -quickstart whenever both are present. +quickstart use, resolve installation identity from the installation-scoped +`activation.enc` path even when Patrol runs under a tenant-local persistence +directory, and refuse bootstrap entirely when no valid installation token is +available. `quickstart.enc` may cache only the server-issued token snapshot +and latest server-reported inventory through the shared +`internal/config/persistence.go` and `internal/config/quickstart_state.go` +helpers; it must not persist anonymous bootstrap identity or revive local +counter truth. `internal/ai/providers/quickstart.go` must authenticate Patrol +proxy calls with `Authorization: Bearer `, sync +`credits_remaining` / `credits_total` back into that cache on every server +response, and invalidate the cached token on auth rejection. Explicit BYOK +provider credentials still outrank Patrol quickstart whenever both are +present. Public-facing copy that reflects those runtime fields must therefore speak in Patrol quickstart runs and Patrol-only no-key activation, not in generic AI credits or a promise of full hosted chat. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 19e8c0aec..99e96c01c 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -238,6 +238,10 @@ Own canonical runtime payload shapes between backend and frontend. than from local grant counters, and shared handlers must not invent client-authored commercial identity or synthetic credits when the quickstart server is unavailable + and the activation-gated availability rule, so missing installation + activation/trial identity must surface as the canonical activation-required + quickstart block reason for Patrol and AI settings enablement rather than + silently attempting anonymous bootstrap and the public interpretation rule, so those fields describe Patrol-only quickstart inventory and active runtime source rather than a generic hosted AI quota or full-chat entitlement diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 896152d3a..eb9a4ff0e 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -245,11 +245,13 @@ That same license-server transport boundary now owns Patrol quickstart bootstrap. `pkg/licensing/license_server_client.go` and `pkg/licensing/quickstart_bootstrap.go` must treat `POST /v1/quickstart/bootstrap` as the canonical exchange for a server-issued -quickstart token plus the authoritative quickstart credit snapshot. Activated -installs authenticate that bootstrap with the installation token, community -installs send a stable `client_installation_id`, and local runtime cache files -may memoize the returned token and counts but may not treat those cached counts -as commercial authority. +quickstart token plus the authoritative quickstart credit snapshot. The Pulse +runtime must authenticate that bootstrap with the installation token from the +installation-scoped activation state, whether the install is activated +self-hosted or hosted/trial-backed, and there is no anonymous +`client_installation_id` fallback in the v6 runtime contract. Local runtime +cache files may memoize the returned token and counts but may not treat those +cached counts as commercial authority. That quickstart allowance is therefore activation support, not the main commercial pitch: self-hosted pricing and docs may promise Patrol-only quickstart runs with no API key for first use, but they must not market that diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index b232b3525..d635c1310 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -109,8 +109,8 @@ querying, and the operator-facing storage health presentation layer. `quickstart_credits_total`, `using_quickstart`, and cached bearer-token state exposed through shared AI handlers are transport facts owned by the AI/runtime plus API-contract lanes, and storage/recovery surfaces must not - reintroduce local quickstart accounting, token lifecycle, or commercial - identity rules. + reintroduce local quickstart accounting, token lifecycle, anonymous + bootstrap identity, or commercial identity rules. 21. Keep provider-backed recovery onboarding on the adjacent platform-connections contract. When `internal/api/` grows or changes TrueNAS connection CRUD, masked-secret preservation, saved-connection retest routes, edit-form saved-test payload overlays, or similar provider setup flows, storage and recovery may consume the resulting recovery points but must not absorb that connection-management ownership into storage/recovery-local handlers or page flows. 22. Keep backend-native platform actions on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app control for Assistant, storage and recovery may consume the refreshed recovery points afterward, but they must not grow a parallel recovery-local action transport or action-specific payload shape. 23. Keep backend-native platform diagnostics on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app log reads for Assistant, storage and recovery may use those diagnostics during investigation, but they must not grow a parallel recovery-local log transport or diagnostic payload shape. diff --git a/frontend-modern/src/api/__tests__/patrol.test.ts b/frontend-modern/src/api/__tests__/patrol.test.ts index bd5835ce9..d394ceb14 100644 --- a/frontend-modern/src/api/__tests__/patrol.test.ts +++ b/frontend-modern/src/api/__tests__/patrol.test.ts @@ -75,6 +75,22 @@ describe('patrol api', () => { }); }); + it('preserves the activation-required quickstart blocked reason payload', async () => { + apiFetchJSONMock.mockResolvedValueOnce({ + runtime_state: 'blocked', + blocked_reason: + 'Activate this install or start a trial to use AI Patrol quickstart. Otherwise connect your API key.', + healthy: false, + } as any); + + await expect(getPatrolStatus()).resolves.toMatchObject({ + runtime_state: 'blocked', + blocked_reason: + 'Activate this install or start a trial to use AI Patrol quickstart. Otherwise connect your API key.', + healthy: false, + }); + }); + it('preserves the canonical patrol quickstart transport fields', async () => { apiFetchJSONMock.mockResolvedValueOnce({ runtime_state: 'active', diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go index 5c8d5ef45..0443ae5e3 100644 --- a/internal/ai/patrol.go +++ b/internal/ai/patrol.go @@ -89,8 +89,9 @@ const ( ) const ( - patrolQuickstartCreditsExhaustedReason = "Quickstart credits exhausted. Connect your API key to continue using AI Patrol." - patrolQuickstartUnavailableReason = "Quickstart credits require internet access. Connect your API key for offline AI Patrol." + patrolQuickstartCreditsExhaustedReason = "Quickstart credits exhausted. Connect your API key to continue using AI Patrol." + patrolQuickstartActivationRequiredReason = "Activate this install or start a trial to use AI Patrol quickstart. Otherwise connect your API key." + patrolQuickstartUnavailableReason = "Quickstart credits require internet access. Connect your API key for offline AI Patrol." ) type PatrolStatus struct { diff --git a/internal/ai/patrol_run.go b/internal/ai/patrol_run.go index 3e3cedc42..8d54e9357 100644 --- a/internal/ai/patrol_run.go +++ b/internal/ai/patrol_run.go @@ -1597,6 +1597,7 @@ func (p *PatrolService) GetStatus() PatrolStatus { if quickstartReason != "" { status.BlockedReason = quickstartReason } else if strings.TrimSpace(status.BlockedReason) == patrolQuickstartCreditsExhaustedReason || + strings.TrimSpace(status.BlockedReason) == patrolQuickstartActivationRequiredReason || strings.TrimSpace(status.BlockedReason) == patrolQuickstartUnavailableReason { status.BlockedReason = "" } diff --git a/internal/ai/quickstart.go b/internal/ai/quickstart.go index 55e8f614f..13f7e3366 100644 --- a/internal/ai/quickstart.go +++ b/internal/ai/quickstart.go @@ -10,7 +10,6 @@ import ( "sync" "time" - "github.com/google/uuid" "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/config" pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" @@ -49,7 +48,6 @@ type PersistentQuickstartCreditManager struct { client quickstartBootstrapClient now func() time.Time hostname func() (string, error) - newID func() string state *config.QuickstartState } @@ -88,7 +86,6 @@ func NewPersistentQuickstartCreditManagerWithClient( client: client, now: func() time.Time { return time.Now().UTC() }, hostname: os.Hostname, - newID: uuid.NewString, } } @@ -107,11 +104,15 @@ func (m *PersistentQuickstartCreditManager) EnsureBootstrap(ctx context.Context) if err != nil { return err } + bearerToken, fingerprint, err := m.secureBootstrapIdentityLocked() + if err != nil { + return err + } if !m.bootstrapNeededLocked(state, m.now()) { return nil } - bearerToken, req, err := m.bootstrapRequestLocked(state) + req, err := m.bootstrapRequestLocked(fingerprint) if err != nil { return err } @@ -134,23 +135,66 @@ func (m *PersistentQuickstartCreditManager) EnsureBootstrap(ctx context.Context) } func (m *PersistentQuickstartCreditManager) HasCredits() bool { - state := m.stateSnapshot() - return state != nil && state.QuickstartCreditsRemaining > 0 + if m == nil { + return false + } + + m.mu.Lock() + defer m.mu.Unlock() + + state, err := m.loadStateLocked() + if err != nil { + log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to read cached state") + return false + } + if state == nil || state.QuickstartCreditsRemaining <= 0 { + return false + } + _, _, err = m.secureBootstrapIdentityLocked() + return err == nil } func (m *PersistentQuickstartCreditManager) CreditsRemaining() int { - state := m.stateSnapshot() + if m == nil { + return 0 + } + + m.mu.Lock() + defer m.mu.Unlock() + + state, err := m.loadStateLocked() + if err != nil { + log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to read cached state") + return 0 + } if state == nil { return 0 } + if _, _, err := m.secureBootstrapIdentityLocked(); err != nil { + return 0 + } return state.QuickstartCreditsRemaining } func (m *PersistentQuickstartCreditManager) CreditsTotal() int { - state := m.stateSnapshot() + if m == nil { + return 0 + } + + m.mu.Lock() + defer m.mu.Unlock() + + state, err := m.loadStateLocked() + if err != nil { + log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to read cached state") + return 0 + } if state == nil { return 0 } + if _, _, err := m.secureBootstrapIdentityLocked(); err != nil { + return 0 + } return state.QuickstartCreditsTotal } @@ -181,6 +225,9 @@ func (m *PersistentQuickstartCreditManager) GetProvider() providers.Provider { if state == nil || state.QuickstartCreditsRemaining <= 0 { return nil } + if _, _, err := m.secureBootstrapIdentityLocked(); err != nil { + return nil + } if strings.TrimSpace(state.QuickstartToken) == "" || state.TokenExpired(m.now()) { return nil } @@ -241,49 +288,17 @@ func (m *PersistentQuickstartCreditManager) bootstrapNeededLocked(state *config. return now.Unix()-*state.LastSyncedAt >= int64(quickstartBootstrapRefreshWindow/time.Second) } -func (m *PersistentQuickstartCreditManager) bootstrapRequestLocked(state *config.QuickstartState) (string, pkglicensing.QuickstartBootstrapRequest, error) { - if state == nil { - return "", pkglicensing.QuickstartBootstrapRequest{}, fmt.Errorf("quickstart: missing state") - } - +func (m *PersistentQuickstartCreditManager) bootstrapRequestLocked(fingerprint string) (pkglicensing.QuickstartBootstrapRequest, error) { instanceName := "" if hostname, err := m.hostname(); err == nil { instanceName = strings.TrimSpace(hostname) } - req := pkglicensing.QuickstartBootstrapRequest{ - InstanceName: instanceName, - UseCase: quickstartBootstrapUseCase, - } - - if m.persistence == nil { - return "", req, fmt.Errorf("quickstart: persistence unavailable") - } - - licensePersistence, err := pkglicensing.NewPersistence(m.persistence.GetConfigDir()) - if err != nil { - return "", req, fmt.Errorf("quickstart: load license persistence: %w", err) - } - activationState, err := licensePersistence.LoadActivationState() - if err != nil { - return "", req, fmt.Errorf("quickstart: load activation state: %w", err) - } - - if activationState != nil && strings.TrimSpace(activationState.InstallationToken) != "" { - req.InstanceFingerprint = strings.TrimSpace(activationState.InstanceFingerprint) - return strings.TrimSpace(activationState.InstallationToken), req, nil - } - - if strings.TrimSpace(state.ClientInstallationID) == "" { - state.ClientInstallationID = m.newID() - if err := m.persistence.SaveQuickstartState(*config.NormalizeQuickstartState(state)); err != nil { - return "", req, fmt.Errorf("quickstart: persist client installation id: %w", err) - } - } - - req.ClientInstallationID = state.ClientInstallationID - req.InstanceFingerprint = state.ClientInstallationID - return "", req, nil + return pkglicensing.QuickstartBootstrapRequest{ + InstanceFingerprint: strings.TrimSpace(fingerprint), + InstanceName: instanceName, + UseCase: quickstartBootstrapUseCase, + }, nil } func (m *PersistentQuickstartCreditManager) applyBootstrapLocked(state *config.QuickstartState, resp *pkglicensing.QuickstartBootstrapResponse) { @@ -361,12 +376,40 @@ func (m *PersistentQuickstartCreditManager) invalidateToken() { } } +func (m *PersistentQuickstartCreditManager) secureBootstrapIdentityLocked() (string, string, error) { + if m.persistence == nil { + return "", "", fmt.Errorf("quickstart: persistence unavailable") + } + + licensePersistence, err := pkglicensing.NewPersistence(m.persistence.SharedInstallationDataDir()) + if err != nil { + return "", "", fmt.Errorf("quickstart: load license persistence: %w", err) + } + activationState, err := licensePersistence.LoadActivationState() + if err != nil { + return "", "", fmt.Errorf("quickstart: load activation state: %w", err) + } + if activationState == nil { + return "", "", quickstartActivationRequiredError() + } + + installationToken := strings.TrimSpace(activationState.InstallationToken) + instanceFingerprint := strings.TrimSpace(activationState.InstanceFingerprint) + if installationToken == "" || instanceFingerprint == "" { + return "", "", quickstartActivationRequiredError() + } + + return installationToken, instanceFingerprint, nil +} + func quickstartBlockedReasonFromError(err error) string { switch { case err == nil: return "" case providers.IsQuickstartCreditsExhausted(err), quickstartBootstrapCreditsExhausted(err): return patrolQuickstartCreditsExhaustedReason + case quickstartBootstrapActivationRequired(err): + return patrolQuickstartActivationRequiredReason case providers.IsQuickstartUnavailable(err), quickstartBootstrapUnavailable(err): return patrolQuickstartUnavailableReason default: @@ -387,6 +430,21 @@ func QuickstartUnavailableReason() string { return patrolQuickstartUnavailableReason } +// QuickstartActivationRequiredReason returns the canonical Patrol quickstart +// availability message shown when the install is not activated or trial-backed. +func QuickstartActivationRequiredReason() string { + return patrolQuickstartActivationRequiredReason +} + +func quickstartActivationRequiredError() error { + return &pkglicensing.LicenseServerError{ + StatusCode: http.StatusUnauthorized, + Code: "activation_required", + Message: "Quickstart bootstrap requires an activated or trial-backed installation", + Retryable: false, + } +} + func quickstartBootstrapCreditsExhausted(err error) bool { var serverErr *pkglicensing.LicenseServerError if !errors.As(err, &serverErr) { @@ -398,6 +456,24 @@ func quickstartBootstrapCreditsExhausted(err error) bool { return strings.EqualFold(strings.TrimSpace(serverErr.Code), "quickstart_credits_exhausted") } +func quickstartBootstrapActivationRequired(err error) bool { + var serverErr *pkglicensing.LicenseServerError + if !errors.As(err, &serverErr) { + return false + } + switch serverErr.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return true + } + + switch strings.ToLower(strings.TrimSpace(serverErr.Code)) { + case "activation_required", "installation_required", "invalid_installation_token", "invalid_token": + return true + default: + return false + } +} + func quickstartBootstrapUnavailable(err error) bool { var serverErr *pkglicensing.LicenseServerError if errors.As(err, &serverErr) { diff --git a/internal/ai/quickstart_test.go b/internal/ai/quickstart_test.go index 88b8a2745..8363fcbc1 100644 --- a/internal/ai/quickstart_test.go +++ b/internal/ai/quickstart_test.go @@ -63,9 +63,6 @@ func TestPersistentQuickstartCreditManager_EnsureBootstrapUsesActivationIdentity if client.bearer != "pit_live_test" { t.Fatalf("bearer = %q, want pit_live_test", client.bearer) } - if client.req.ClientInstallationID != "" { - t.Fatalf("ClientInstallationID = %q, want empty", client.req.ClientInstallationID) - } if client.req.InstanceFingerprint != "fp-live-test" { t.Fatalf("InstanceFingerprint = %q, want fp-live-test", client.req.InstanceFingerprint) } @@ -80,57 +77,53 @@ func TestPersistentQuickstartCreditManager_EnsureBootstrapUsesActivationIdentity } } -func TestPersistentQuickstartCreditManager_EnsureBootstrapPersistsCommunityInstallationID(t *testing.T) { +func TestPersistentQuickstartCreditManager_EnsureBootstrapRequiresInstallationToken(t *testing.T) { dir := t.TempDir() persistence := config.NewConfigPersistence(dir) client := &stubQuickstartBootstrapClient{ resp: &pkglicensing.QuickstartBootstrapResponse{ - QuickstartToken: "qst_live_community", - QuickstartTokenExpiresAt: time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339), - CreditsRemaining: 17, - CreditsTotal: 25, + QuickstartToken: "qst_should_not_be_used", + CreditsRemaining: 17, + CreditsTotal: 25, }, } mgr := NewPersistentQuickstartCreditManagerWithClient(persistence, "default", func() *config.AIConfig { return &config.AIConfig{Enabled: true} }, client) - mgr.hostname = func() (string, error) { return "pulse-community", nil } - mgr.newID = func() string { return "community-install-1" } - if err := mgr.EnsureBootstrap(context.Background()); err != nil { - t.Fatalf("EnsureBootstrap(): %v", err) + err := mgr.EnsureBootstrap(context.Background()) + if err == nil { + t.Fatal("expected activation-required error") } - if client.calls != 1 { - t.Fatalf("BootstrapQuickstart calls = %d, want 1", client.calls) + if QuickstartBlockedReasonForError(err) != patrolQuickstartActivationRequiredReason { + t.Fatalf("blocked reason = %q, want %q", QuickstartBlockedReasonForError(err), patrolQuickstartActivationRequiredReason) } - if client.bearer != "" { - t.Fatalf("bearer = %q, want empty", client.bearer) + if client.calls != 0 { + t.Fatalf("BootstrapQuickstart calls = %d, want 0", client.calls) } - if client.req.ClientInstallationID != "community-install-1" { - t.Fatalf("ClientInstallationID = %q, want community-install-1", client.req.ClientInstallationID) - } - if client.req.InstanceFingerprint != "community-install-1" { - t.Fatalf("InstanceFingerprint = %q, want community-install-1", client.req.InstanceFingerprint) - } - - loaded, err := persistence.LoadQuickstartState() - if err != nil { - t.Fatalf("LoadQuickstartState(): %v", err) - } - if loaded.ClientInstallationID != "community-install-1" { - t.Fatalf("persisted ClientInstallationID = %q, want community-install-1", loaded.ClientInstallationID) - } - if loaded.QuickstartToken != "qst_live_community" { - t.Fatalf("persisted QuickstartToken = %q, want qst_live_community", loaded.QuickstartToken) + if mgr.CreditsRemaining() != 0 || mgr.CreditsTotal() != 0 { + t.Fatalf("credits = %d/%d, want 0/0", mgr.CreditsRemaining(), mgr.CreditsTotal()) } } func TestPersistentQuickstartCreditManager_LoadsPersistedTokenWithoutBootstrap(t *testing.T) { dir := t.TempDir() persistence := config.NewConfigPersistence(dir) + + licensePersistence, err := pkglicensing.NewPersistence(persistence.GetConfigDir()) + if err != nil { + t.Fatalf("NewPersistence(): %v", err) + } + if err := licensePersistence.SaveActivationState(&pkglicensing.ActivationState{ + InstallationID: "inst_live_test", + InstallationToken: "pit_live_test", + InstanceFingerprint: "fp-live-test", + }); err != nil { + t.Fatalf("SaveActivationState(): %v", err) + } + expiresAt := time.Now().Add(30 * time.Minute).UTC().Unix() lastSyncedAt := time.Now().UTC().Unix() if err := persistence.SaveQuickstartState(config.QuickstartState{ - ClientInstallationID: "community-install-1", QuickstartToken: "qst_live_cached", QuickstartTokenExpiresAt: &expiresAt, QuickstartCreditsRemaining: 11, @@ -162,3 +155,115 @@ func TestPersistentQuickstartCreditManager_LoadsPersistedTokenWithoutBootstrap(t t.Fatal("expected provider from cached quickstart token") } } + +func TestPersistentQuickstartCreditManager_ExpiredTokenRebootstrapsWithInstallationToken(t *testing.T) { + dir := t.TempDir() + persistence := config.NewConfigPersistence(dir) + + licensePersistence, err := pkglicensing.NewPersistence(persistence.GetConfigDir()) + if err != nil { + t.Fatalf("NewPersistence(): %v", err) + } + if err := licensePersistence.SaveActivationState(&pkglicensing.ActivationState{ + InstallationID: "inst_live_test", + InstallationToken: "pit_live_test", + InstanceFingerprint: "fp-live-test", + }); err != nil { + t.Fatalf("SaveActivationState(): %v", err) + } + + expiredAt := time.Now().Add(-5 * time.Minute).UTC().Unix() + lastSyncedAt := time.Now().Add(-10 * time.Minute).UTC().Unix() + if err := persistence.SaveQuickstartState(config.QuickstartState{ + QuickstartToken: "qst_live_expired", + QuickstartTokenExpiresAt: &expiredAt, + QuickstartCreditsRemaining: 11, + QuickstartCreditsTotal: 25, + LastSyncedAt: &lastSyncedAt, + }); err != nil { + t.Fatalf("SaveQuickstartState(): %v", err) + } + + client := &stubQuickstartBootstrapClient{ + resp: &pkglicensing.QuickstartBootstrapResponse{ + QuickstartToken: "qst_live_rotated", + QuickstartTokenExpiresAt: time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339), + CreditsRemaining: 9, + CreditsTotal: 25, + }, + } + mgr := NewPersistentQuickstartCreditManagerWithClient(persistence, "default", func() *config.AIConfig { return &config.AIConfig{Enabled: true} }, client) + + if err := mgr.EnsureBootstrap(context.Background()); err != nil { + t.Fatalf("EnsureBootstrap(): %v", err) + } + if client.calls != 1 { + t.Fatalf("BootstrapQuickstart calls = %d, want 1", client.calls) + } + if client.bearer != "pit_live_test" { + t.Fatalf("bearer = %q, want pit_live_test", client.bearer) + } + if client.req.InstanceFingerprint != "fp-live-test" { + t.Fatalf("InstanceFingerprint = %q, want fp-live-test", client.req.InstanceFingerprint) + } + + loaded, err := persistence.LoadQuickstartState() + if err != nil { + t.Fatalf("LoadQuickstartState(): %v", err) + } + if loaded.QuickstartToken != "qst_live_rotated" { + t.Fatalf("QuickstartToken = %q, want qst_live_rotated", loaded.QuickstartToken) + } + if loaded.QuickstartCreditsRemaining != 9 { + t.Fatalf("QuickstartCreditsRemaining = %d, want 9", loaded.QuickstartCreditsRemaining) + } +} + +func TestPersistentQuickstartCreditManager_TenantUsesSharedInstallationActivationState(t *testing.T) { + dir := t.TempDir() + mtp := config.NewMultiTenantPersistence(dir) + + defaultPersistence, err := mtp.GetPersistence("default") + if err != nil { + t.Fatalf("GetPersistence(default): %v", err) + } + tenantPersistence, err := mtp.GetPersistence("t-tenant") + if err != nil { + t.Fatalf("GetPersistence(t-tenant): %v", err) + } + + licensePersistence, err := pkglicensing.NewPersistence(defaultPersistence.SharedInstallationDataDir()) + if err != nil { + t.Fatalf("NewPersistence(): %v", err) + } + if err := licensePersistence.SaveActivationState(&pkglicensing.ActivationState{ + InstallationID: "inst_live_test", + InstallationToken: "pit_live_test", + InstanceFingerprint: "fp-live-test", + }); err != nil { + t.Fatalf("SaveActivationState(): %v", err) + } + + client := &stubQuickstartBootstrapClient{ + resp: &pkglicensing.QuickstartBootstrapResponse{ + QuickstartToken: "qst_live_tenant", + QuickstartTokenExpiresAt: time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339), + CreditsRemaining: 14, + CreditsTotal: 25, + }, + } + mgr := NewPersistentQuickstartCreditManagerWithClient(tenantPersistence, "t-tenant", func() *config.AIConfig { return &config.AIConfig{Enabled: true} }, client) + + if err := mgr.EnsureBootstrap(context.Background()); err != nil { + t.Fatalf("EnsureBootstrap(): %v", err) + } + if client.calls != 1 { + t.Fatalf("BootstrapQuickstart calls = %d, want 1", client.calls) + } + if client.bearer != "pit_live_test" { + t.Fatalf("bearer = %q, want pit_live_test", client.bearer) + } + if mgr.CreditsRemaining() != 14 { + t.Fatalf("CreditsRemaining() = %d, want 14", mgr.CreditsRemaining()) + } +} diff --git a/internal/ai/service_quickstart_test.go b/internal/ai/service_quickstart_test.go index c1663b19f..7dea78fe3 100644 --- a/internal/ai/service_quickstart_test.go +++ b/internal/ai/service_quickstart_test.go @@ -87,3 +87,28 @@ func TestServiceLoadConfig_TracksQuickstartBootstrapFailure(t *testing.T) { t.Fatal("expected AI service to remain disabled when bootstrap failed and no BYOK exists") } } + +func TestServiceLoadConfig_TracksQuickstartActivationRequirement(t *testing.T) { + dir := t.TempDir() + persistence := config.NewConfigPersistence(dir) + cfg := config.NewDefaultAIConfig() + cfg.Enabled = true + if err := persistence.SaveAIConfig(*cfg); err != nil { + t.Fatalf("SaveAIConfig(): %v", err) + } + + svc := NewService(persistence, nil) + svc.SetQuickstartCredits(&stubServiceQuickstartManager{ + ensureErr: &pkglicensing.LicenseServerError{StatusCode: http.StatusUnauthorized, Code: "activation_required", Message: "activation required", Retryable: false}, + }) + + if err := svc.LoadConfig(); err != nil { + t.Fatalf("LoadConfig(): %v", err) + } + if svc.QuickstartBlockedReason() != patrolQuickstartActivationRequiredReason { + t.Fatalf("QuickstartBlockedReason() = %q, want %q", svc.QuickstartBlockedReason(), patrolQuickstartActivationRequiredReason) + } + if svc.IsEnabled() { + t.Fatal("expected AI service to remain disabled when activation is required and no BYOK exists") + } +} diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index c21022538..6ac8c66eb 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -2530,7 +2530,12 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt } } if !hasQuickstart { - if strings.TrimSpace(ai.QuickstartBlockedReasonForError(bootstrapErr)) == ai.QuickstartUnavailableReason() { + blockedReason := strings.TrimSpace(ai.QuickstartBlockedReasonForError(bootstrapErr)) + switch blockedReason { + case ai.QuickstartActivationRequiredReason(): + http.Error(w, blockedReason, http.StatusConflict) + return + case ai.QuickstartUnavailableReason(): http.Error(w, ai.QuickstartUnavailableReason(), http.StatusBadGateway) return } diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index 2507869a1..9dd80e5d2 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -17,6 +17,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts" + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -76,6 +77,18 @@ func useTestQuickstartBootstrapServer(t *testing.T, assertRequest func(*http.Req t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL) } +func persistQuickstartActivationState(t *testing.T, persistence *config.ConfigPersistence) { + t.Helper() + + licensePersistence, err := pkglicensing.NewPersistence(persistence.GetConfigDir()) + require.NoError(t, err) + require.NoError(t, licensePersistence.SaveActivationState(&pkglicensing.ActivationState{ + InstallationID: "inst_live_test", + InstallationToken: "pit_live_test", + InstanceFingerprint: "fp-live-test", + })) +} + func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) { t.Parallel() @@ -227,11 +240,9 @@ func TestAISettingsResponse_UsesCanonicalEmptyCollections(t *testing.T) { func TestAISettingsHandler_GetHostedSettings_AutoBootstrapsQuickstart(t *testing.T) { t.Setenv("PULSE_HOSTED_MODE", "true") useTestQuickstartBootstrapServer(t, func(r *http.Request, reqBody map[string]any) { - assert.Empty(t, strings.TrimSpace(r.Header.Get("Authorization"))) - clientInstallationID, _ := reqBody["client_installation_id"].(string) + assert.Equal(t, "Bearer pit_live_test", strings.TrimSpace(r.Header.Get("Authorization"))) instanceFingerprint, _ := reqBody["instance_fingerprint"].(string) - assert.NotEmpty(t, clientInstallationID) - assert.Equal(t, clientInstallationID, instanceFingerprint) + assert.Equal(t, "fp-live-test", instanceFingerprint) assert.Equal(t, "patrol", reqBody["use_case"]) }) @@ -241,6 +252,7 @@ func TestAISettingsHandler_GetHostedSettings_AutoBootstrapsQuickstart(t *testing require.NoError(t, err) seedHostedAIBillingState(t, mtp, "default") + persistQuickstartActivationState(t, persistence) handler := NewAISettingsHandler(mtp, nil, nil) handler.defaultConfig = &config.Config{DataPath: tmp} @@ -265,11 +277,9 @@ func TestAISettingsHandler_GetHostedSettings_AutoBootstrapsQuickstart(t *testing func TestAISettingsHandler_GetHostedTenantSettings_InheritsDefaultHostedBillingState(t *testing.T) { t.Setenv("PULSE_HOSTED_MODE", "true") useTestQuickstartBootstrapServer(t, func(r *http.Request, reqBody map[string]any) { - assert.Empty(t, strings.TrimSpace(r.Header.Get("Authorization"))) - clientInstallationID, _ := reqBody["client_installation_id"].(string) + assert.Equal(t, "Bearer pit_live_test", strings.TrimSpace(r.Header.Get("Authorization"))) instanceFingerprint, _ := reqBody["instance_fingerprint"].(string) - assert.NotEmpty(t, clientInstallationID) - assert.Equal(t, clientInstallationID, instanceFingerprint) + assert.Equal(t, "fp-live-test", instanceFingerprint) assert.Equal(t, "patrol", reqBody["use_case"]) }) @@ -277,8 +287,11 @@ func TestAISettingsHandler_GetHostedTenantSettings_InheritsDefaultHostedBillingS mtp := config.NewMultiTenantPersistence(tmp) persistence, err := mtp.GetPersistence("t-tenant") require.NoError(t, err) + defaultPersistence, err := mtp.GetPersistence("default") + require.NoError(t, err) seedHostedAIBillingState(t, mtp, "default") + persistQuickstartActivationState(t, defaultPersistence) handler := NewAISettingsHandler(mtp, nil, nil) handler.defaultConfig = &config.Config{DataPath: tmp} @@ -301,6 +314,28 @@ func TestAISettingsHandler_GetHostedTenantSettings_InheritsDefaultHostedBillingS assert.True(t, persistence.HasAIConfig()) } +func TestAISettingsHandler_UpdateSettings_QuickstartRequiresActivationBeforeEnable(t *testing.T) { + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := newTestAISettingsHandler(cfg, persistence, nil) + handler.defaultAIService.SetQuickstartCredits(ai.NewPersistentQuickstartCreditManager( + persistence, + "default", + func() *config.AIConfig { return &config.AIConfig{Enabled: true} }, + )) + + body, _ := json.Marshal(AISettingsUpdateRequest{ + Enabled: ptr(true), + }) + req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleUpdateAISettings(rec, req) + + require.Equal(t, http.StatusConflict, rec.Code, "body=%s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), ai.QuickstartActivationRequiredReason()) +} + func TestAISettingsHandler_UpdateSettings_QuickstartBootstrapBeforeEnable(t *testing.T) { tmp := t.TempDir() cfg := &config.Config{DataPath: tmp} diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 690739df0..34757e05b 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -295,6 +295,20 @@ func (c *ConfigPersistence) DataDir() string { return c.configDir } +// SharedInstallationDataDir returns the installation-scoped config directory +// that owns activation and licensing identity. Tenant config directories live +// under /orgs/, but activation remains rooted at . +func (c *ConfigPersistence) SharedInstallationDataDir() string { + if c == nil { + return "" + } + sharedDir, err := resolveSharedInstallationDataDir(c.configDir) + if err != nil { + return c.configDir + } + return sharedDir +} + // GetConfigDir returns the configuration directory path (alias for DataDir to match interface expectations) func (c *ConfigPersistence) GetConfigDir() string { return c.configDir diff --git a/internal/config/persistence_installation_scope.go b/internal/config/persistence_installation_scope.go new file mode 100644 index 000000000..ba07d1162 --- /dev/null +++ b/internal/config/persistence_installation_scope.go @@ -0,0 +1,26 @@ +package config + +import ( + "path/filepath" + + "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" +) + +func resolveSharedInstallationDataDir(configDir string) (string, error) { + normalizedConfigDir, err := securityutil.NormalizeStorageDir(ResolveRuntimeDataDir(configDir)) + if err != nil { + return "", err + } + + orgsDir := filepath.Dir(normalizedConfigDir) + if filepath.Base(orgsDir) != "orgs" { + return normalizedConfigDir, nil + } + + orgID := filepath.Base(normalizedConfigDir) + if !isValidOrgID(orgID) { + return normalizedConfigDir, nil + } + + return filepath.Dir(orgsDir), nil +} diff --git a/internal/config/persistence_installation_scope_test.go b/internal/config/persistence_installation_scope_test.go new file mode 100644 index 000000000..a87dd1ea9 --- /dev/null +++ b/internal/config/persistence_installation_scope_test.go @@ -0,0 +1,24 @@ +package config + +import "testing" + +func TestConfigPersistenceSharedInstallationDataDir_DefaultAndTenantPaths(t *testing.T) { + baseDir := t.TempDir() + mtp := NewMultiTenantPersistence(baseDir) + + defaultPersistence, err := mtp.GetPersistence("default") + if err != nil { + t.Fatalf("GetPersistence(default): %v", err) + } + if got := defaultPersistence.SharedInstallationDataDir(); got != mtp.BaseDataDir() { + t.Fatalf("default SharedInstallationDataDir() = %q, want %q", got, mtp.BaseDataDir()) + } + + tenantPersistence, err := mtp.GetPersistence("acme") + if err != nil { + t.Fatalf("GetPersistence(acme): %v", err) + } + if got := tenantPersistence.SharedInstallationDataDir(); got != mtp.BaseDataDir() { + t.Fatalf("tenant SharedInstallationDataDir() = %q, want %q", got, mtp.BaseDataDir()) + } +} diff --git a/internal/config/quickstart_state.go b/internal/config/quickstart_state.go index 23ed2e77a..0cb8353a0 100644 --- a/internal/config/quickstart_state.go +++ b/internal/config/quickstart_state.go @@ -11,7 +11,6 @@ import ( // QuickstartState is the machine-owned local cache for the server-authoritative // quickstart bootstrap contract. type QuickstartState struct { - ClientInstallationID string `json:"client_installation_id,omitempty"` QuickstartToken string `json:"quickstart_token,omitempty"` QuickstartTokenExpiresAt *int64 `json:"quickstart_token_expires_at,omitempty"` QuickstartCreditsTotal int `json:"quickstart_credits_total,omitempty"` @@ -27,7 +26,6 @@ func NormalizeQuickstartState(state *QuickstartState) *QuickstartState { cp := *state normalized := &cp - normalized.ClientInstallationID = strings.TrimSpace(normalized.ClientInstallationID) normalized.QuickstartToken = strings.TrimSpace(normalized.QuickstartToken) normalized.QuickstartTokenExpiresAt = cloneQuickstartInt64Ptr(state.QuickstartTokenExpiresAt) normalized.LastSyncedAt = cloneQuickstartInt64Ptr(state.LastSyncedAt) diff --git a/internal/config/quickstart_state_test.go b/internal/config/quickstart_state_test.go index be1add8e4..dd4c635d4 100644 --- a/internal/config/quickstart_state_test.go +++ b/internal/config/quickstart_state_test.go @@ -14,7 +14,6 @@ func TestQuickstartStateSaveAndLoad(t *testing.T) { expiresAt := int64(1774449000) lastSyncedAt := int64(1774448000) state := QuickstartState{ - ClientInstallationID: "community-install-1", QuickstartToken: "qst_live_test_123", QuickstartTokenExpiresAt: &expiresAt, QuickstartCreditsTotal: 25, @@ -30,9 +29,6 @@ func TestQuickstartStateSaveAndLoad(t *testing.T) { if err != nil { t.Fatalf("LoadQuickstartState: %v", err) } - if loaded.ClientInstallationID != state.ClientInstallationID { - t.Fatalf("ClientInstallationID = %q, want %q", loaded.ClientInstallationID, state.ClientInstallationID) - } if loaded.QuickstartToken != state.QuickstartToken { t.Fatalf("QuickstartToken = %q, want %q", loaded.QuickstartToken, state.QuickstartToken) } @@ -68,7 +64,7 @@ func TestLoadQuickstartState_MissingReturnsEmptyState(t *testing.T) { if loaded == nil { t.Fatal("expected empty quickstart state, got nil") } - if loaded.QuickstartToken != "" || loaded.QuickstartCreditsRemaining != 0 || loaded.ClientInstallationID != "" { + if loaded.QuickstartToken != "" || loaded.QuickstartCreditsRemaining != 0 { t.Fatalf("expected zero-value quickstart state, got %#v", loaded) } } diff --git a/pkg/licensing/license_server_client.go b/pkg/licensing/license_server_client.go index c67ad0453..685d4c96b 100644 --- a/pkg/licensing/license_server_client.go +++ b/pkg/licensing/license_server_client.go @@ -142,8 +142,17 @@ func (c *LicenseServerClient) RefreshGrant(ctx context.Context, installationID, // BootstrapQuickstart exchanges a runtime identity for a short-lived // quickstart token and the current authoritative quickstart inventory. -// Activated installs should pass their installation token as bearerToken. +// Callers must pass a server-verified installation token as bearerToken. func (c *LicenseServerClient) BootstrapQuickstart(ctx context.Context, bearerToken string, req QuickstartBootstrapRequest) (*QuickstartBootstrapResponse, error) { + if strings.TrimSpace(bearerToken) == "" { + return nil, &LicenseServerError{ + StatusCode: http.StatusUnauthorized, + Code: "activation_required", + Message: "Quickstart bootstrap requires an installation token", + Retryable: false, + } + } + body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("marshal quickstart bootstrap request: %w", err) @@ -154,9 +163,7 @@ func (c *LicenseServerClient) BootstrapQuickstart(ctx context.Context, bearerTok return nil, fmt.Errorf("create quickstart bootstrap request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") - if token := strings.TrimSpace(bearerToken); token != "" { - httpReq.Header.Set("Authorization", "Bearer "+token) - } + httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(bearerToken)) bootstrapClient := *c.httpClient bootstrapClient.Timeout = 10 * time.Second diff --git a/pkg/licensing/license_server_client_test.go b/pkg/licensing/license_server_client_test.go index d3e5952b8..00d472fb9 100644 --- a/pkg/licensing/license_server_client_test.go +++ b/pkg/licensing/license_server_client_test.go @@ -392,40 +392,29 @@ func TestClientBootstrapQuickstart(t *testing.T) { } }) - t.Run("uses community installation id when no auth token is present", func(t *testing.T) { - var seenAuthorization string - var seenRequest QuickstartBootstrapRequest - + t.Run("requires installation bearer token", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - seenAuthorization = r.Header.Get("Authorization") - if err := json.NewDecoder(r.Body).Decode(&seenRequest); err != nil { - t.Fatalf("decode request: %v", err) - } - json.NewEncoder(w).Encode(QuickstartBootstrapResponse{ - QuickstartToken: "qst_live_community", - CreditsRemaining: 25, - CreditsTotal: 25, - }) + t.Fatal("bootstrap request should not reach the server without an installation token") })) defer server.Close() client := NewLicenseServerClient(server.URL) - resp, err := client.BootstrapQuickstart(context.Background(), "", QuickstartBootstrapRequest{ - ClientInstallationID: "community-install-1", - InstanceFingerprint: "community-install-1", - UseCase: "patrol", + _, err := client.BootstrapQuickstart(context.Background(), "", QuickstartBootstrapRequest{ + InstanceFingerprint: "fp-123", + UseCase: "patrol", }) - if err != nil { - t.Fatalf("BootstrapQuickstart failed: %v", err) + if err == nil { + t.Fatal("expected missing installation token error") } - if seenAuthorization != "" { - t.Fatalf("Authorization = %q, want empty", seenAuthorization) + apiErr, ok := err.(*LicenseServerError) + if !ok { + t.Fatalf("expected *LicenseServerError, got %T", err) } - if seenRequest.ClientInstallationID != "community-install-1" { - t.Fatalf("ClientInstallationID = %q, want community-install-1", seenRequest.ClientInstallationID) + if apiErr.StatusCode != http.StatusUnauthorized { + t.Fatalf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusUnauthorized) } - if resp.QuickstartToken != "qst_live_community" { - t.Fatalf("QuickstartToken = %q, want qst_live_community", resp.QuickstartToken) + if apiErr.Code != "activation_required" { + t.Fatalf("Code = %q, want activation_required", apiErr.Code) } }) } diff --git a/pkg/licensing/quickstart_bootstrap.go b/pkg/licensing/quickstart_bootstrap.go index 5b56eaacd..281750db8 100644 --- a/pkg/licensing/quickstart_bootstrap.go +++ b/pkg/licensing/quickstart_bootstrap.go @@ -3,13 +3,12 @@ package licensing // QuickstartBootstrapRequest is sent to the license server to exchange a // runtime identity for a server-issued quickstart token. // -// Activated installs authenticate the request with their installation token. -// Community installs omit auth and instead send a stable client installation id. +// Activated or hosted/trial-backed installs authenticate the request with +// their installation token. The request body supplies only binding metadata. type QuickstartBootstrapRequest struct { - ClientInstallationID string `json:"client_installation_id,omitempty"` - InstanceFingerprint string `json:"instance_fingerprint,omitempty"` - InstanceName string `json:"instance_name,omitempty"` - UseCase string `json:"use_case,omitempty"` + InstanceFingerprint string `json:"instance_fingerprint,omitempty"` + InstanceName string `json:"instance_name,omitempty"` + UseCase string `json:"use_case,omitempty"` } // QuickstartBootstrapResponse contains the server-issued quickstart token and