package application import ( "context" "errors" "fmt" "regexp" "sort" "time" "softbox.local/core/licensing" ) var ( ErrAuthorizationConfig = errors.New("authorization configuration is invalid") ErrAuthorizationSourceUnconfigured = errors.New("authorization source is unconfigured") ErrAuthorizationUnavailable = errors.New("authorization is unavailable") ErrAuthorizationImport = errors.New("authorization import failed") ErrAuthorizationEventPayload = errors.New("authorization event payload is invalid") ) var authorizationProductIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`) // AuthorizationState is the sanitized state shown to adapters and callers. type AuthorizationState string const ( AuthorizationStateReady AuthorizationState = "ready" AuthorizationStateGrace AuthorizationState = "grace" AuthorizationStateNoLicense AuthorizationState = "no_license" AuthorizationStateRevoked AuthorizationState = "revoked" AuthorizationStateUnavailable AuthorizationState = "unavailable" AuthorizationStateUnconfigured AuthorizationState = "unconfigured" AuthorizationStateImportFailed AuthorizationState = "import_failed" ) // LicenseKind presents only the signed perpetual flag; it does not invent a // trial expiration which License v1 does not carry. type LicenseKind string const ( LicenseKindPerpetual LicenseKind = "perpetual" LicenseKindNonPerpetual LicenseKind = "non_perpetual" ) // AuthorizedProduct contains no license ID, source path, signature or source // document. The product ID is the same signed package identity used at launch. type AuthorizedProduct struct { ProductID string Kind LicenseKind RebindPolicy string } // AuthorizationSnapshot is an IO-free UI/launcher authorization view. type AuthorizationSnapshot struct { State AuthorizationState MachineHash string Products []AuthorizedProduct } // AuthorizationEvent is the typed LicenseChanged payload accepted by adapters. type AuthorizationEvent struct { Type EventType Snapshot AuthorizationSnapshot } // AuthorizationStore is the narrow persistence boundary required by the // authorization application service. type AuthorizationStore interface { Import([]byte, licensing.Verifier, string) (licensing.License, bool, error) List(licensing.Verifier, string) ([]licensing.License, error) StoreRevocations([]byte, licensing.RevocationVerifier) (licensing.RevocationList, error) LoadRevocations(licensing.RevocationVerifier) (licensing.RevocationList, bool, error) } // LicenseImportSource reads one user-selected document outside Gio Layout. // Platform file dialogs and file I/O belong to its composition implementation. type LicenseImportSource interface { ReadLicense(context.Context) ([]byte, error) } // AuthorizationServiceConfig makes every trust and local dependency explicit. type AuthorizationServiceConfig struct { Store AuthorizationStore LicenseVerifier licensing.Verifier RevocationVerifier licensing.RevocationVerifier MachineHash string Clock func() time.Time } // AuthorizationService revalidates cached licenses/revocations for every // snapshot and authorization decision. It is core-only and has no Gio or // platform imports. type AuthorizationService struct { store AuthorizationStore licenseVerifier licensing.Verifier revocationVerifier licensing.RevocationVerifier machineHash string clock func() time.Time } // NewAuthorizationService creates the configured offline authorization use // case. Invalid verifier values fail during a use rather than falling back. func NewAuthorizationService(config AuthorizationServiceConfig) (*AuthorizationService, error) { if config.Store == nil || !machineHashForAuthorization(config.MachineHash) { return nil, ErrAuthorizationConfig } if config.Clock == nil { config.Clock = time.Now } return &AuthorizationService{ store: config.Store, licenseVerifier: config.LicenseVerifier, revocationVerifier: config.RevocationVerifier, machineHash: config.MachineHash, clock: config.Clock, }, nil } // Snapshot loads a detached, sanitized authorization view. func (service *AuthorizationService) Snapshot(ctx context.Context) (AuthorizationSnapshot, error) { if service == nil || service.store == nil || service.clock == nil { return AuthorizationSnapshot{}, ErrAuthorizationConfig } if err := ctx.Err(); err != nil { return AuthorizationSnapshot{}, err } licenses, err := service.store.List(service.licenseVerifier, service.machineHash) if err != nil { return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w: cache", ErrAuthorizationUnavailable) } list, found, err := service.store.LoadRevocations(service.revocationVerifier) if err != nil || !found { return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w: revocation cache", ErrAuthorizationUnavailable) } snapshot := authorizationSnapshotFromVerified(licenses, list, service.machineHash, service.clock()) if snapshot.State == AuthorizationStateUnavailable { return snapshot, ErrAuthorizationUnavailable } return snapshot, nil } // Import validates and persists one document before returning the refreshed // authorization view. No unverified document is retained. func (service *AuthorizationService) Import(ctx context.Context, document []byte) (AuthorizationSnapshot, error) { if service == nil || service.store == nil { return AuthorizationSnapshot{}, ErrAuthorizationConfig } if err := ctx.Err(); err != nil { return AuthorizationSnapshot{}, err } if _, _, err := service.store.Import(document, service.licenseVerifier, service.machineHash); err != nil { return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w", ErrAuthorizationImport) } return service.Snapshot(ctx) } // UpdateRevocations validates and persists a signed list then refreshes state. func (service *AuthorizationService) UpdateRevocations(ctx context.Context, document []byte) (AuthorizationSnapshot, error) { if service == nil || service.store == nil { return AuthorizationSnapshot{}, ErrAuthorizationConfig } if err := ctx.Err(); err != nil { return AuthorizationSnapshot{}, err } if _, err := service.store.StoreRevocations(document, service.revocationVerifier); err != nil { return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w", ErrAuthorizationUnavailable) } return service.Snapshot(ctx) } // IsAuthorized implements the launch AuthorizationChecker contract for a // package product ID. Missing/stale revocations are unavailable, never allow. func (service *AuthorizationService) IsAuthorized(productID string) (bool, error) { if !authorizationProductIDPattern.MatchString(productID) { return false, ErrAuthorizationUnavailable } snapshot, err := service.Snapshot(context.Background()) if err != nil { return false, err } if snapshot.State != AuthorizationStateReady && snapshot.State != AuthorizationStateGrace { return false, nil } for _, product := range snapshot.Products { if product.ProductID == productID { return true, nil } } return false, nil } func authorizationSnapshotFromVerified( licenses []licensing.License, list licensing.RevocationList, machineHash string, now time.Time, ) AuthorizationSnapshot { if now.Before(list.GeneratedAt) || now.After(list.ExpiresAt.Add(licensing.RevocationGrace)) { return unavailableAuthorizationSnapshot(machineHash) } products := make(map[string]AuthorizedProduct) hasGrace := false hasRevoked := false for _, license := range licenses { switch list.StateFor(license.LicenseID, now) { case licensing.RevocationStateCurrent: addAuthorizedProducts(products, license) case licensing.RevocationStateGrace: hasGrace = true addAuthorizedProducts(products, license) case licensing.RevocationStateRevoked: hasRevoked = true } } if len(products) == 0 { state := AuthorizationStateNoLicense if hasRevoked { state = AuthorizationStateRevoked } return AuthorizationSnapshot{State: state, MachineHash: machineHash, Products: []AuthorizedProduct{}} } ordered := make([]AuthorizedProduct, 0, len(products)) for _, product := range products { ordered = append(ordered, product) } sort.Slice(ordered, func(left, right int) bool { return ordered[left].ProductID < ordered[right].ProductID }) state := AuthorizationStateReady if hasGrace { state = AuthorizationStateGrace } return AuthorizationSnapshot{State: state, MachineHash: machineHash, Products: ordered} } func addAuthorizedProducts(products map[string]AuthorizedProduct, license licensing.License) { kind := LicenseKindNonPerpetual if license.Perpetual { kind = LicenseKindPerpetual } for _, productID := range license.Products { candidate := AuthorizedProduct{ProductID: productID, Kind: kind, RebindPolicy: license.RebindPolicy} if existing, exists := products[productID]; !exists || (existing.Kind == LicenseKindNonPerpetual && candidate.Kind == LicenseKindPerpetual) { products[productID] = candidate } } } func unavailableAuthorizationSnapshot(machineHash string) AuthorizationSnapshot { return AuthorizationSnapshot{ State: AuthorizationStateUnavailable, MachineHash: machineHash, Products: []AuthorizedProduct{}, } } func machineHashForAuthorization(value string) bool { if len(value) != 64 { return false } for _, character := range value { if !(character >= '0' && character <= '9') && !(character >= 'a' && character <= 'f') { return false } } return true } // AuthorizationSnapshotLoader supplies one background-loaded authorization // state. It permits the explicit unconfigured default without test-key fallback. type AuthorizationSnapshotLoader interface { LoadAuthorizationSnapshot(context.Context) (AuthorizationSnapshot, error) } // LoadAuthorizationSnapshot adapts AuthorizationService for bootstrap use. func (service *AuthorizationService) LoadAuthorizationSnapshot(ctx context.Context) (AuthorizationSnapshot, error) { return service.Snapshot(ctx) } // UnconfiguredAuthorizationLoader is the safe default until release // composition supplies a trusted key, machine hash and revocation source. type UnconfiguredAuthorizationLoader struct{} func (UnconfiguredAuthorizationLoader) LoadAuthorizationSnapshot(ctx context.Context) (AuthorizationSnapshot, error) { if err := ctx.Err(); err != nil { return AuthorizationSnapshot{}, err } return AuthorizationSnapshot{State: AuthorizationStateUnconfigured, Products: []AuthorizedProduct{}}, ErrAuthorizationSourceUnconfigured } // AuthorizationBootstrap publishes exactly one sanitized startup state. type AuthorizationBootstrap struct { loader AuthorizationSnapshotLoader publisher EventPublisher } func NewAuthorizationBootstrap(loader AuthorizationSnapshotLoader, publisher EventPublisher) *AuthorizationBootstrap { return &AuthorizationBootstrap{loader: loader, publisher: publisher} } func (bootstrap *AuthorizationBootstrap) Run(ctx context.Context) error { if bootstrap == nil || bootstrap.loader == nil || bootstrap.publisher == nil { return ErrAuthorizationConfig } snapshot, err := bootstrap.loader.LoadAuthorizationSnapshot(ctx) if err != nil { if errors.Is(err, ErrAuthorizationSourceUnconfigured) { snapshot = AuthorizationSnapshot{State: AuthorizationStateUnconfigured, Products: []AuthorizedProduct{}} } else if !validAuthorizationSnapshot(snapshot) { snapshot = AuthorizationSnapshot{State: AuthorizationStateUnavailable, Products: []AuthorizedProduct{}} } } publishErr := bootstrap.publisher.Publish(ctx, NewAuthorizationEvent(snapshot)) if publishErr != nil { return errors.Join(err, publishErr) } return err } // LicenseImport publishes a refreshed authorization snapshot from one // background-only source read. type LicenseImport struct { service *AuthorizationService source LicenseImportSource publisher EventPublisher } func NewLicenseImport(service *AuthorizationService, source LicenseImportSource, publisher EventPublisher) *LicenseImport { return &LicenseImport{service: service, source: source, publisher: publisher} } func (useCase *LicenseImport) Run(ctx context.Context) error { if useCase == nil || useCase.service == nil || useCase.source == nil || useCase.publisher == nil { return ErrAuthorizationConfig } document, err := useCase.source.ReadLicense(ctx) if err == nil { _, err = useCase.service.Import(ctx, document) } snapshot, snapshotErr := useCase.service.Snapshot(ctx) if snapshotErr != nil || err != nil { snapshot = AuthorizationSnapshot{State: AuthorizationStateImportFailed, MachineHash: useCase.service.machineHash, Products: []AuthorizedProduct{}} } publishErr := useCase.publisher.Publish(ctx, NewAuthorizationEvent(snapshot)) return errors.Join(err, snapshotErr, publishErr) } // NewAuthorizationEvent deep-copies a validated snapshot into LicenseChanged. func NewAuthorizationEvent(snapshot AuthorizationSnapshot) Event { return Event{Type: EventLicenseChanged, Payload: AuthorizationEvent{ Type: EventLicenseChanged, Snapshot: cloneAuthorizationSnapshot(snapshot), }} } // ParseAuthorizationEvent validates and deep-copies a LicenseChanged payload. func ParseAuthorizationEvent(event Event) (AuthorizationEvent, bool, error) { if event.Type != EventLicenseChanged { return AuthorizationEvent{}, false, nil } payload, ok := event.Payload.(AuthorizationEvent) if !ok || payload.Type != EventLicenseChanged || event.RequestID != "" || event.AppID != "" || !validAuthorizationSnapshot(payload.Snapshot) { return AuthorizationEvent{}, true, ErrAuthorizationEventPayload } payload.Snapshot = cloneAuthorizationSnapshot(payload.Snapshot) return payload, true, nil } func cloneAuthorizationSnapshot(snapshot AuthorizationSnapshot) AuthorizationSnapshot { products := make([]AuthorizedProduct, len(snapshot.Products)) copy(products, snapshot.Products) snapshot.Products = products return snapshot } func validAuthorizationSnapshot(snapshot AuthorizationSnapshot) bool { if !snapshot.State.valid() { return false } if snapshot.State == AuthorizationStateUnconfigured && snapshot.MachineHash != "" { return false } if snapshot.State != AuthorizationStateUnconfigured && !machineHashForAuthorization(snapshot.MachineHash) { return false } if snapshot.Products == nil { return false } seen := make(map[string]struct{}, len(snapshot.Products)) for _, product := range snapshot.Products { if !authorizationProductIDPattern.MatchString(product.ProductID) || (product.Kind != LicenseKindPerpetual && product.Kind != LicenseKindNonPerpetual) || product.RebindPolicy == "" { return false } if _, exists := seen[product.ProductID]; exists { return false } seen[product.ProductID] = struct{}{} } if (snapshot.State == AuthorizationStateReady || snapshot.State == AuthorizationStateGrace) && len(snapshot.Products) == 0 { return false } if snapshot.State != AuthorizationStateReady && snapshot.State != AuthorizationStateGrace && len(snapshot.Products) != 0 { return false } return true } func (state AuthorizationState) valid() bool { return state == AuthorizationStateReady || state == AuthorizationStateGrace || state == AuthorizationStateNoLicense || state == AuthorizationStateRevoked || state == AuthorizationStateUnavailable || state == AuthorizationStateUnconfigured || state == AuthorizationStateImportFailed }