Require installation auth for Patrol quickstart

This commit is contained in:
rcourtman
2026-04-03 00:45:05 +01:00
parent 3300ece8dd
commit 57c8757011
21 changed files with 479 additions and 150 deletions
@@ -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.
@@ -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 <quickstart_token>`,
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 <quickstart_token>`, 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.
@@ -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
@@ -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
@@ -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.
@@ -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',
+3 -2
View File
@@ -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 {
+1
View File
@@ -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 = ""
}
+122 -46
View File
@@ -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) {
+138 -33
View File
@@ -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())
}
}
+25
View File
@@ -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")
}
}
+6 -1
View File
@@ -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
}
+43 -8
View File
@@ -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}
+14
View File
@@ -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 <base>/orgs/<org>, but activation remains rooted at <base>.
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
@@ -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
}
@@ -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())
}
}
-2
View File
@@ -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)
+1 -5
View File
@@ -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)
}
}
+11 -4
View File
@@ -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
+14 -25
View File
@@ -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)
}
})
}
+5 -6
View File
@@ -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